openvibers/openvibe.shared Commits

Accept link, guid, content, language and selfUrl in feedXml (#8)

OP OpenVibers committed 2 Oct 2026 · 2b42ef07d8 · 4 files, +64 −12

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Shared/pull/8

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8fa18d4..bfe02c4 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 `&#39;`. The 2.4.0 `url`, `id` and `summary` still work.
- `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
`maxBytes` ends the file with a `(truncated: N more pages at <base>/llms.txt)` line. The
ddiff --git a/README.md b/README.md
iindex 8c99a14..5e92741 100644
---- a/README.md
++++ b/README.md
@@@ -98,9 +98,9 @@ ${summary.head}</head><body>${summary.body}…`; // or `${summary}` in <head> wh
```js
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: 'atom' }); // serve as application/atom+xml
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
`jsonLd` also builds `product`, `review`, `aggregateRating`, `imageGallery` (an ImageObject
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`)
ddiff --git a/seo.js b/seo.js
iindex 1ee39a8..6c7a036 100644
---- a/seo.js
++++ b/seo.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' } = {}) {
ddiff --git a/test/shared-modules.test.js b/test/shared-modules.test.js
iindex 6b39748..09a62eb 100644
---- a/test/shared-modules.test.js
++++ b/test/shared-modules.test.js
@@@ -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 &amp; &lt;Notes&gt; &quot;A&quot; &#39;B&#39;</title><link>https://x.test/</link><description>Fresh &amp; &lt;new&gt; &quot;A&quot; &#39;B&#39;</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 &amp; &lt;b&gt;</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 &amp; &lt;t&gt; &quot;q&quot; &#39;a&#39;</title><link>https://x.test/two?a=1&amp;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/&lt;x&gt;&amp;&quot;&#39;</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 &amp; &lt;Notes&gt; &quot;A&quot; &#39;B&#39;</title><subtitle>Fresh &amp; &lt;new&gt; &quot;A&quot; &#39;B&#39;</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 &amp; &lt;b&gt;</summary><content type="html">&lt;p&gt;Full &amp; body&lt;/p&gt;</content><author><name>Ada</name></author></entry>'));
++ assert.ok(atom.includes(`<entry><id>urn:x:two</id><title>T &amp; &lt;t&gt; &quot;q&quot; &#39;a&#39;</title><link href="https://x.test/two?a=1&amp;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');