openvibers/openvibe.shared Commits
Accept link, guid, content, language and selfUrl in feedXml (#8)
Changes
@@@ -6,6 +6,12 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++- `seo.feedXml(feed, { format })` takes items as `{ title, link, guid?, description?, content?, author?,++ published, updated? }` and the feed's `language` and `selfUrl`. RSS adds `<language>`, an++ `atom:link rel="self"` and a `lastBuildDate` that is always present. Atom adds++ `link rel="alternate"`/`rel="self"`, `xml:lang`, `<content type="html">` and an `<updated>` that is++ always present. Without `updated`, both use the newest item date, or now when there are no items.++ Text is escaped with `esc`, so `'` becomes `'`. The 2.4.0 `url`, `id` and `summary` still work.- `seo.llmsFull` takes `base` and sections of `pages: [{ title, url, text | html }]`: each pagerenders as `### title`, `URL: …` and its full text (HTML stripped), in input order, and`maxBytes` ends the file with a `(truncated: N more pages at <base>/llms.txt)` line. The@@@ -98,9 +98,9 @@ ${summary.head}</head><body>${summary.body}…`; // or `${summary}` in <head> wh```jsconst site = { name: 'Example', url: 'https://example.com/' };--const feed = { title: 'Example', link: site.url, description: 'Recent guides',-- updated: '2026-10-01T12:00:00Z', items: [{ title: 'Guide', url: '/guide', id: 'guide-1',-- published: '2026-10-01T12:00:00Z', summary: 'A short guide', author: 'Example' }] };++const feed = { title: 'Example', link: site.url, description: 'Recent guides', language: 'en',++ selfUrl: '/feed.xml', items: [{ title: 'Guide', link: '/guide', guid: 'guide-1',++ published: '2026-10-01T12:00:00Z', description: 'A short guide', content: '<p>Full text</p>', author: 'Example' }] };seo.feedXml(feed, { format: 'rss' }); // serve as application/rss+xmlseo.feedXml(feed, { format: 'atom' }); // serve as application/atom+xmlseo.feedLinkTags({ rss: 'https://example.com/feed.xml', atom: 'https://example.com/atom.xml', title: 'Example' });@@@ -108,7 +108,10 @@ seo.feedLinkTags({ rss: 'https://example.com/feed.xml', atom: 'https://example.c`jsonLd` also builds `product`, `review`, `aggregateRating`, `imageGallery` (an ImageObjectarray) and `videoObject` (the same VideoObject shape as `video`). Feed URLs should be absolute,--or relative to the absolute `link`; `llmsFull` resolves page URLs against `base` (or `site.url`).++or relative to the absolute `link`. `feedXml` throws on a format other than `rss` or `atom`. Without++`updated`, the feed uses its newest item date, or the current time when there are no items. Without a++`guid`, an item's link is its RSS guid (`isPermaLink="true"`) and its Atom id. The 2.4.0 item names `url`,++`id` and `summary` still work. `llmsFull` resolves page URLs against `base` (or `site.url`).### Images and responsive pictures (`assets.js`)@@@ -296,20 +296,31 @@ function feedDate(value, format) {return Number.isNaN(date.getTime()) ? '' : (format === 'rss' ? date.toUTCString() : date.toISOString());}--/** One item shape for RSS 2.0 and Atom 1.0. Dates are rendered in each format's required form. */--function feedXml({ title, link, description, updated, items = [] } = {}, { format = 'rss' } = {}) {++/**++ * One feed shape for RSS 2.0 and Atom 1.0; `format` is 'rss' or 'atom', anything else throws.++ * feed: { title, link, description, language?, updated?, selfUrl?, items }++ * item: { title, link, guid?, description?, content?, author?, published, updated? } (the 2.4.0++ * `url`, `id` and `summary` names still work). Dates are Dates or ISO strings: RFC 822 in RSS,++ * RFC 3339 in Atom. Relative item links resolve against feed.link; items keep their order.++ */++function feedXml({ title, link, description, language, updated, selfUrl, items = [] } = {}, { format = 'rss' } = {}) {if (format !== 'rss' && format !== 'atom') throw new TypeError('format must be rss or atom');const home = absolute(link);++ const self = absolute(selfUrl, home);++ const times = items.map((item) => new Date(item.updated || item.published || 0).getTime()).filter((t) => t > 0);++ const built = feedDate(updated || (times.length ? Math.max(...times) : Date.now()), format);const rows = items.map((item) => {-- const url = absolute(item.url, home);-- const id = item.id || url;++ const url = absolute(item.link || item.url, home);++ const guid = item.guid || item.id;++ const summary = item.description || item.summary;const published = feedDate(item.published || item.updated, format);const modified = feedDate(item.updated || item.published, format);-- if (format === 'rss') return ` <item><title>${xml(item.title)}</title><link>${xml(url)}</link><guid isPermaLink="${item.id ? 'false' : 'true'}">${xml(id)}</guid>${published ? `<pubDate>${xml(published)}</pubDate>` : ''}${item.summary ? `<description>${xml(item.summary)}</description>` : ''}${item.author ? `<author>${xml(item.author)}</author>` : ''}</item>`;-- return ` <entry><title>${xml(item.title)}</title><link href="${xml(url)}"/><id>${xml(absolute(id, home))}</id>${modified ? `<updated>${xml(modified)}</updated>` : ''}${published ? `<published>${xml(published)}</published>` : ''}${item.summary ? `<summary>${xml(item.summary)}</summary>` : ''}${item.author ? `<author><name>${xml(item.author)}</name></author>` : ''}</entry>`;++ if (format === 'rss') return ` <item><title>${esc(item.title)}</title><link>${esc(url)}</link><guid isPermaLink="${guid ? 'false' : 'true'}">${esc(guid || url)}</guid>${summary ? `<description>${esc(summary)}</description>` : ''}${published ? `<pubDate>${esc(published)}</pubDate>` : ''}${item.author ? `<author>${esc(item.author)}</author>` : ''}</item>`;++ return ` <entry><id>${esc(guid ? absolute(guid, home) : url)}</id><title>${esc(item.title)}</title><link href="${esc(url)}"/>${published ? `<published>${esc(published)}</published>` : ''}${modified ? `<updated>${esc(modified)}</updated>` : ''}${summary ? `<summary>${esc(summary)}</summary>` : ''}${item.content ? `<content type="html">${esc(item.content)}</content>` : ''}${item.author ? `<author><name>${esc(item.author)}</name></author>` : ''}</entry>`;});-- if (format === 'rss') return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0"><channel><title>${xml(title)}</title><link>${xml(home)}</link><description>${xml(description)}</description>${feedDate(updated, format) ? `<lastBuildDate>${xml(feedDate(updated, format))}</lastBuildDate>` : ''}\n${rows.join('\n')}\n</channel></rss>\n`;-- return `<?xml version="1.0" encoding="UTF-8"?>\n<feed xmlns="http://www.w3.org/2005/Atom"><title>${xml(title)}</title><link href="${xml(home)}"/><id>${xml(home)}</id>${description ? `<subtitle>${xml(description)}</subtitle>` : ''}${feedDate(updated, format) ? `<updated>${xml(feedDate(updated, format))}</updated>` : ''}\n${rows.join('\n')}\n</feed>\n`;++ const body = rows.length ? `\n${rows.join('\n')}` : '';++ if (format === 'rss') return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0"${self ? ' xmlns:atom="http://www.w3.org/2005/Atom"' : ''}><channel><title>${esc(title)}</title><link>${esc(home)}</link><description>${esc(description)}</description>${language ? `<language>${esc(language)}</language>` : ''}<lastBuildDate>${esc(built)}</lastBuildDate>${self ? `<atom:link href="${esc(self)}" rel="self" type="application/rss+xml"/>` : ''}${body}\n</channel></rss>\n`;++ return `<?xml version="1.0" encoding="UTF-8"?>\n<feed xmlns="http://www.w3.org/2005/Atom"${language ? ` xml:lang="${esc(language)}"` : ''}><id>${esc(home)}</id><title>${esc(title)}</title>${description ? `<subtitle>${esc(description)}</subtitle>` : ''}<updated>${esc(built)}</updated><link rel="alternate" href="${esc(home)}"/>${self ? `<link rel="self" href="${esc(self)}"/>` : ''}${body}\n</feed>\n`;}function feedLinkTags({ rss, atom, title = 'Feed' } = {}) {@@@ -10,6 +10,38 @@ assert.ok(seo.robotsTxt({ sitemaps: ['https://x.test/sitemap.xml'] }).includes('assert.ok(!seo.sitemapXml([{ loc: '/relative' }, { loc: 'https://ok.test/' }]).includes('/relative'), 'sitemaps hold absolute URLs only');assert.ok(seo.llmsTxt({ name: 'X', summary: 's', sections: [] }).startsWith('# X'));++{++ const feed = { title: `News & <Notes> "A" 'B'`, link: 'https://x.test/', description: `Fresh & <new> "A" 'B'`, language: 'en',++ selfUrl: '/feed.xml', items: [++ { title: 'First', link: '/one', description: 'One & <b>', content: '<p>Full & body</p>', author: 'Ada',++ published: '2026-10-01T12:34:56Z', updated: new Date('2026-10-03T08:00:00Z') },++ { title: `T & <t> "q" 'a'`, link: `/two?a=1&b="2"'`, guid: 'urn:x:two', published: new Date('2026-10-02T09:00:00Z') },++ ] };++ const rss = seo.feedXml(feed);++ assert.ok(rss.startsWith('<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel>'));++ assert.ok(rss.includes(`<title>News & <Notes> "A" 'B'</title><link>https://x.test/</link><description>Fresh & <new> "A" 'B'</description><language>en</language><lastBuildDate>Sat, 03 Oct 2026 08:00:00 GMT</lastBuildDate><atom:link href="https://x.test/feed.xml" rel="self" type="application/rss+xml"/>`), 'channel elements, updated = newest item');++ assert.ok(rss.includes('<item><title>First</title><link>https://x.test/one</link><guid isPermaLink="true">https://x.test/one</guid><description>One & <b></description><pubDate>Thu, 01 Oct 2026 12:34:56 GMT</pubDate><author>Ada</author></item>'), 'guid defaults to the link');++ assert.ok(rss.includes('<item><title>T & <t> "q" 'a'</title><link>https://x.test/two?a=1&b=%222%22%27</link><guid isPermaLink="false">urn:x:two</guid><pubDate>Fri, 02 Oct 2026 09:00:00 GMT</pubDate></item>'), 'explicit guid, escaped title and link');++ assert.ok(seo.feedXml({ ...feed, items: [{ title: 't', link: `http://a b/<x>&"'` }] }).includes('<link>http://a b/<x>&"'</link>'), 'an unparseable link is still escaped');++ assert.ok(rss.indexOf('<title>First</title>') < rss.indexOf('urn:x:two'), 'items keep their order');++ for (const m of rss.match(/<pubDate>[^<]*<\/pubDate>|<lastBuildDate>[^<]*<\/lastBuildDate>/g)) assert.match(m, />[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT</, 'RFC 822');++++ const atom = seo.feedXml(feed, { format: 'atom' });++ assert.ok(atom.includes(`<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><id>https://x.test/</id><title>News & <Notes> "A" 'B'</title><subtitle>Fresh & <new> "A" 'B'</subtitle><updated>2026-10-03T08:00:00.000Z</updated><link rel="alternate" href="https://x.test/"/><link rel="self" href="https://x.test/feed.xml"/>`), 'feed elements, updated = newest item');++ assert.ok(atom.includes('<entry><id>https://x.test/one</id><title>First</title><link href="https://x.test/one"/><published>2026-10-01T12:34:56.000Z</published><updated>2026-10-03T08:00:00.000Z</updated><summary>One & <b></summary><content type="html"><p>Full & body</p></content><author><name>Ada</name></author></entry>'));++ assert.ok(atom.includes(`<entry><id>urn:x:two</id><title>T & <t> "q" 'a'</title><link href="https://x.test/two?a=1&b=%222%22%27"/><published>2026-10-02T09:00:00.000Z</published><updated>2026-10-02T09:00:00.000Z</updated></entry>`), 'updated falls back to published');++ for (const m of atom.match(/<(published|updated)>[^<]*</g)) assert.match(m, />\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z</, 'RFC 3339');++ assert.ok(seo.feedXml({ ...feed, updated: '2026-09-01T00:00:00Z' }, { format: 'atom' }).includes('<updated>2026-09-01T00:00:00.000Z</updated><link'), 'feed.updated wins');++++ const empty = { title: 'E', link: 'https://x.test/', description: 'd', items: [] };++ assert.match(seo.feedXml(empty), /<channel><title>E<\/title><link>https:\/\/x\.test\/<\/link><description>d<\/description><lastBuildDate>[^<]+GMT<\/lastBuildDate>\n<\/channel><\/rss>\n$/);++ assert.ok(!seo.feedXml(empty).includes('<item>') && !seo.feedXml(empty).includes('atom:link'));++ const emptyAtom = seo.feedXml(empty, { format: 'atom' });++ assert.match(emptyAtom, /<updated>\d{4}-\d{2}-\d{2}T[^<]+Z<\/updated><link rel="alternate" href="https:\/\/x\.test\/"\/>\n<\/feed>\n$/, 'empty feed is updated now');++ assert.ok(!emptyAtom.includes('<entry>') && !emptyAtom.includes('rel="self"'));++ assert.throws(() => seo.feedXml(feed, { format: 'json' }), /rss or atom/);++}++assert.ok(icons.names().length >= 40);for (const n of icons.names()) assert.ok(/<(path|circle|rect)/.test(icons.svg(n)), `glyph: ${n}`);assert.equal(icons.resolve('yt'), 'youtube');
@@ -6,6 +6,12 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,12 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+- `seo.feedXml(feed, { format })` takes items as `{ title, link, guid?, description?, content?, author?,
+ published, updated? }` and the feed's `language` and `selfUrl`. RSS adds `<language>`, an
+ `atom:link rel="self"` and a `lastBuildDate` that is always present. Atom adds
+ `link rel="alternate"`/`rel="self"`, `xml:lang`, `<content type="html">` and an `<updated>` that is
+ always present. Without `updated`, both use the newest item date, or now when there are no items.
+ Text is escaped with `esc`, so `'` becomes `'`. The 2.4.0 `url`, `id` and `summary` still work.
- `seo.llmsFull` takes `base` and sections of `pages: [{ title, url, text | html }]`: each page
- `seo.llmsFull` takes `base` and sections of `pages: [{ title, url, text | html }]`: each page
renders as `### title`, `URL: …` and its full text (HTML stripped), in input order, and
renders as `### title`, `URL: …` and its full text (HTML stripped), in input order, and
`maxBytes` ends the file with a `(truncated: N more pages at <base>/llms.txt)` line. The
`maxBytes` ends the file with a `(truncated: N more pages at <base>/llms.txt)` line. The
@@ -98,9 +98,9 @@ ${summary.head}</head><body>${summary.body}…`; // or `${summary}` in <head> wh
@@ -98,9 +98,9 @@ ${summary.head}</head><body>${summary.body}…`; // or `${summary}` in <head> wh
```js
```js
const site = { name: 'Example', url: 'https://example.com/' };
const site = { name: 'Example', url: 'https://example.com/' };
-const feed = { title: 'Example', link: site.url, description: 'Recent guides',
- updated: '2026-10-01T12:00:00Z', items: [{ title: 'Guide', url: '/guide', id: 'guide-1',
- published: '2026-10-01T12:00:00Z', summary: 'A short guide', author: 'Example' }] };
+const feed = { title: 'Example', link: site.url, description: 'Recent guides', language: 'en',
+ selfUrl: '/feed.xml', items: [{ title: 'Guide', link: '/guide', guid: 'guide-1',
+ published: '2026-10-01T12:00:00Z', description: 'A short guide', content: '<p>Full text</p>', author: 'Example' }] };
seo.feedXml(feed, { format: 'rss' }); // serve as application/rss+xml
seo.feedXml(feed, { format: 'rss' }); // serve as application/rss+xml
seo.feedXml(feed, { format: 'atom' }); // serve as application/atom+xml
seo.feedXml(feed, { format: 'atom' }); // serve as application/atom+xml
seo.feedLinkTags({ rss: 'https://example.com/feed.xml', atom: 'https://example.com/atom.xml', title: 'Example' });
seo.feedLinkTags({ rss: 'https://example.com/feed.xml', atom: 'https://example.com/atom.xml', title: 'Example' });
@@ -108,7 +108,10 @@ seo.feedLinkTags({ rss: 'https://example.com/feed.xml', atom: 'https://example.c
@@ -108,7 +108,10 @@ seo.feedLinkTags({ rss: 'https://example.com/feed.xml', atom: 'https://example.c
`jsonLd` also builds `product`, `review`, `aggregateRating`, `imageGallery` (an ImageObject
`jsonLd` also builds `product`, `review`, `aggregateRating`, `imageGallery` (an ImageObject
array) and `videoObject` (the same VideoObject shape as `video`). Feed URLs should be absolute,
array) and `videoObject` (the same VideoObject shape as `video`). Feed URLs should be absolute,
-or relative to the absolute `link`; `llmsFull` resolves page URLs against `base` (or `site.url`).
+or relative to the absolute `link`. `feedXml` throws on a format other than `rss` or `atom`. Without
+`updated`, the feed uses its newest item date, or the current time when there are no items. Without a
+`guid`, an item's link is its RSS guid (`isPermaLink="true"`) and its Atom id. The 2.4.0 item names `url`,
+`id` and `summary` still work. `llmsFull` resolves page URLs against `base` (or `site.url`).
### Images and responsive pictures (`assets.js`)
### Images and responsive pictures (`assets.js`)
@@ -296,20 +296,31 @@ function feedDate(value, format) {
@@ -296,20 +296,31 @@ function feedDate(value, format) {
return Number.isNaN(date.getTime()) ? '' : (format === 'rss' ? date.toUTCString() : date.toISOString());
return Number.isNaN(date.getTime()) ? '' : (format === 'rss' ? date.toUTCString() : date.toISOString());
}
}
-/** One item shape for RSS 2.0 and Atom 1.0. Dates are rendered in each format's required form. */
-function feedXml({ title, link, description, updated, items = [] } = {}, { format = 'rss' } = {}) {
+/**
+ * One feed shape for RSS 2.0 and Atom 1.0; `format` is 'rss' or 'atom', anything else throws.
+ * feed: { title, link, description, language?, updated?, selfUrl?, items }
+ * item: { title, link, guid?, description?, content?, author?, published, updated? } (the 2.4.0
+ * `url`, `id` and `summary` names still work). Dates are Dates or ISO strings: RFC 822 in RSS,
+ * RFC 3339 in Atom. Relative item links resolve against feed.link; items keep their order.
+ */
+function feedXml({ title, link, description, language, updated, selfUrl, items = [] } = {}, { format = 'rss' } = {}) {
if (format !== 'rss' && format !== 'atom') throw new TypeError('format must be rss or atom');
if (format !== 'rss' && format !== 'atom') throw new TypeError('format must be rss or atom');
const home = absolute(link);
const home = absolute(link);
+ const self = absolute(selfUrl, home);
+ const times = items.map((item) => new Date(item.updated || item.published || 0).getTime()).filter((t) => t > 0);
+ const built = feedDate(updated || (times.length ? Math.max(...times) : Date.now()), format);
const rows = items.map((item) => {
const rows = items.map((item) => {
- const url = absolute(item.url, home);
- const id = item.id || url;
+ const url = absolute(item.link || item.url, home);
+ const guid = item.guid || item.id;
+ const summary = item.description || item.summary;
const published = feedDate(item.published || item.updated, format);
const published = feedDate(item.published || item.updated, format);
const modified = feedDate(item.updated || item.published, format);
const modified = feedDate(item.updated || item.published, format);
- if (format === 'rss') return ` <item><title>${xml(item.title)}</title><link>${xml(url)}</link><guid isPermaLink="${item.id ? 'false' : 'true'}">${xml(id)}</guid>${published ? `<pubDate>${xml(published)}</pubDate>` : ''}${item.summary ? `<description>${xml(item.summary)}</description>` : ''}${item.author ? `<author>${xml(item.author)}</author>` : ''}</item>`;
- return ` <entry><title>${xml(item.title)}</title><link href="${xml(url)}"/><id>${xml(absolute(id, home))}</id>${modified ? `<updated>${xml(modified)}</updated>` : ''}${published ? `<published>${xml(published)}</published>` : ''}${item.summary ? `<summary>${xml(item.summary)}</summary>` : ''}${item.author ? `<author><name>${xml(item.author)}</name></author>` : ''}</entry>`;
+ if (format === 'rss') return ` <item><title>${esc(item.title)}</title><link>${esc(url)}</link><guid isPermaLink="${guid ? 'false' : 'true'}">${esc(guid || url)}</guid>${summary ? `<description>${esc(summary)}</description>` : ''}${published ? `<pubDate>${esc(published)}</pubDate>` : ''}${item.author ? `<author>${esc(item.author)}</author>` : ''}</item>`;
+ return ` <entry><id>${esc(guid ? absolute(guid, home) : url)}</id><title>${esc(item.title)}</title><link href="${esc(url)}"/>${published ? `<published>${esc(published)}</published>` : ''}${modified ? `<updated>${esc(modified)}</updated>` : ''}${summary ? `<summary>${esc(summary)}</summary>` : ''}${item.content ? `<content type="html">${esc(item.content)}</content>` : ''}${item.author ? `<author><name>${esc(item.author)}</name></author>` : ''}</entry>`;
});
});
- if (format === 'rss') return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0"><channel><title>${xml(title)}</title><link>${xml(home)}</link><description>${xml(description)}</description>${feedDate(updated, format) ? `<lastBuildDate>${xml(feedDate(updated, format))}</lastBuildDate>` : ''}\n${rows.join('\n')}\n</channel></rss>\n`;
- return `<?xml version="1.0" encoding="UTF-8"?>\n<feed xmlns="http://www.w3.org/2005/Atom"><title>${xml(title)}</title><link href="${xml(home)}"/><id>${xml(home)}</id>${description ? `<subtitle>${xml(description)}</subtitle>` : ''}${feedDate(updated, format) ? `<updated>${xml(feedDate(updated, format))}</updated>` : ''}\n${rows.join('\n')}\n</feed>\n`;
+ const body = rows.length ? `\n${rows.join('\n')}` : '';
+ if (format === 'rss') return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0"${self ? ' xmlns:atom="http://www.w3.org/2005/Atom"' : ''}><channel><title>${esc(title)}</title><link>${esc(home)}</link><description>${esc(description)}</description>${language ? `<language>${esc(language)}</language>` : ''}<lastBuildDate>${esc(built)}</lastBuildDate>${self ? `<atom:link href="${esc(self)}" rel="self" type="application/rss+xml"/>` : ''}${body}\n</channel></rss>\n`;
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<feed xmlns="http://www.w3.org/2005/Atom"${language ? ` xml:lang="${esc(language)}"` : ''}><id>${esc(home)}</id><title>${esc(title)}</title>${description ? `<subtitle>${esc(description)}</subtitle>` : ''}<updated>${esc(built)}</updated><link rel="alternate" href="${esc(home)}"/>${self ? `<link rel="self" href="${esc(self)}"/>` : ''}${body}\n</feed>\n`;
}
}
function feedLinkTags({ rss, atom, title = 'Feed' } = {}) {
function feedLinkTags({ rss, atom, title = 'Feed' } = {}) {
@@ -10,6 +10,38 @@ assert.ok(seo.robotsTxt({ sitemaps: ['https://x.test/sitemap.xml'] }).includes('
@@ -10,6 +10,38 @@ assert.ok(seo.robotsTxt({ sitemaps: ['https://x.test/sitemap.xml'] }).includes('
assert.ok(!seo.sitemapXml([{ loc: '/relative' }, { loc: 'https://ok.test/' }]).includes('/relative'), 'sitemaps hold absolute URLs only');
assert.ok(!seo.sitemapXml([{ loc: '/relative' }, { loc: 'https://ok.test/' }]).includes('/relative'), 'sitemaps hold absolute URLs only');
assert.ok(seo.llmsTxt({ name: 'X', summary: 's', sections: [] }).startsWith('# X'));
assert.ok(seo.llmsTxt({ name: 'X', summary: 's', sections: [] }).startsWith('# X'));
+{
+ const feed = { title: `News & <Notes> "A" 'B'`, link: 'https://x.test/', description: `Fresh & <new> "A" 'B'`, language: 'en',
+ selfUrl: '/feed.xml', items: [
+ { title: 'First', link: '/one', description: 'One & <b>', content: '<p>Full & body</p>', author: 'Ada',
+ published: '2026-10-01T12:34:56Z', updated: new Date('2026-10-03T08:00:00Z') },
+ { title: `T & <t> "q" 'a'`, link: `/two?a=1&b="2"'`, guid: 'urn:x:two', published: new Date('2026-10-02T09:00:00Z') },
+ ] };
+ const rss = seo.feedXml(feed);
+ assert.ok(rss.startsWith('<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel>'));
+ assert.ok(rss.includes(`<title>News & <Notes> "A" 'B'</title><link>https://x.test/</link><description>Fresh & <new> "A" 'B'</description><language>en</language><lastBuildDate>Sat, 03 Oct 2026 08:00:00 GMT</lastBuildDate><atom:link href="https://x.test/feed.xml" rel="self" type="application/rss+xml"/>`), 'channel elements, updated = newest item');
+ assert.ok(rss.includes('<item><title>First</title><link>https://x.test/one</link><guid isPermaLink="true">https://x.test/one</guid><description>One & <b></description><pubDate>Thu, 01 Oct 2026 12:34:56 GMT</pubDate><author>Ada</author></item>'), 'guid defaults to the link');
+ assert.ok(rss.includes('<item><title>T & <t> "q" 'a'</title><link>https://x.test/two?a=1&b=%222%22%27</link><guid isPermaLink="false">urn:x:two</guid><pubDate>Fri, 02 Oct 2026 09:00:00 GMT</pubDate></item>'), 'explicit guid, escaped title and link');
+ assert.ok(seo.feedXml({ ...feed, items: [{ title: 't', link: `http://a b/<x>&"'` }] }).includes('<link>http://a b/<x>&"'</link>'), 'an unparseable link is still escaped');
+ assert.ok(rss.indexOf('<title>First</title>') < rss.indexOf('urn:x:two'), 'items keep their order');
+ for (const m of rss.match(/<pubDate>[^<]*<\/pubDate>|<lastBuildDate>[^<]*<\/lastBuildDate>/g)) assert.match(m, />[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT</, 'RFC 822');
+
+ const atom = seo.feedXml(feed, { format: 'atom' });
+ assert.ok(atom.includes(`<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><id>https://x.test/</id><title>News & <Notes> "A" 'B'</title><subtitle>Fresh & <new> "A" 'B'</subtitle><updated>2026-10-03T08:00:00.000Z</updated><link rel="alternate" href="https://x.test/"/><link rel="self" href="https://x.test/feed.xml"/>`), 'feed elements, updated = newest item');
+ assert.ok(atom.includes('<entry><id>https://x.test/one</id><title>First</title><link href="https://x.test/one"/><published>2026-10-01T12:34:56.000Z</published><updated>2026-10-03T08:00:00.000Z</updated><summary>One & <b></summary><content type="html"><p>Full & body</p></content><author><name>Ada</name></author></entry>'));
+ assert.ok(atom.includes(`<entry><id>urn:x:two</id><title>T & <t> "q" 'a'</title><link href="https://x.test/two?a=1&b=%222%22%27"/><published>2026-10-02T09:00:00.000Z</published><updated>2026-10-02T09:00:00.000Z</updated></entry>`), 'updated falls back to published');
+ for (const m of atom.match(/<(published|updated)>[^<]*</g)) assert.match(m, />\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z</, 'RFC 3339');
+ assert.ok(seo.feedXml({ ...feed, updated: '2026-09-01T00:00:00Z' }, { format: 'atom' }).includes('<updated>2026-09-01T00:00:00.000Z</updated><link'), 'feed.updated wins');
+
+ const empty = { title: 'E', link: 'https://x.test/', description: 'd', items: [] };
+ assert.match(seo.feedXml(empty), /<channel><title>E<\/title><link>https:\/\/x\.test\/<\/link><description>d<\/description><lastBuildDate>[^<]+GMT<\/lastBuildDate>\n<\/channel><\/rss>\n$/);
+ assert.ok(!seo.feedXml(empty).includes('<item>') && !seo.feedXml(empty).includes('atom:link'));
+ const emptyAtom = seo.feedXml(empty, { format: 'atom' });
+ assert.match(emptyAtom, /<updated>\d{4}-\d{2}-\d{2}T[^<]+Z<\/updated><link rel="alternate" href="https:\/\/x\.test\/"\/>\n<\/feed>\n$/, 'empty feed is updated now');
+ assert.ok(!emptyAtom.includes('<entry>') && !emptyAtom.includes('rel="self"'));
+ assert.throws(() => seo.feedXml(feed, { format: 'json' }), /rss or atom/);
+}
+
assert.ok(icons.names().length >= 40);
assert.ok(icons.names().length >= 40);
for (const n of icons.names()) assert.ok(/<(path|circle|rect)/.test(icons.svg(n)), `glyph: ${n}`);
for (const n of icons.names()) assert.ok(/<(path|circle|rect)/.test(icons.svg(n)), `glyph: ${n}`);
assert.equal(icons.resolve('yt'), 'youtube');
assert.equal(icons.resolve('yt'), 'youtube');