openvibers/openvibe.shared Commits

Implemented the SEO kit additions in seo.js, exposed the kit from…

OP OpenVibers committed 1 Oct 2026 · 5f451753c5 · 5 files, +200 −1

Done by the harness job shared-plan-t11-lane-d-follow-up-220729.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 3535a30..c74a0c3 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## Unreleased
++
++- The SEO kit adds `llmsFull` for full-text AI maps, `pageSummary` for escaped page
++ summaries and WebPage data, `feedXml` for RSS 2.0 and Atom 1.0, and `feedLinkTags`.
++ Product, review, rating, image gallery and VideoObject JSON-LD builders are available
++ through `jsonLd`. The package root now exposes the kit as `seo`.
++
## 2.3.1 — 2026-09-29
- `showcase.css` is served at `/shared/showcase.css` (files.js `STYLES`, text/css): the showcase sections and the ring
ddiff --git a/README.md b/README.md
iindex 5cba1d6..b6b4775 100644
---- a/README.md
++++ b/README.md
@@@ -50,6 +50,33 @@ const seo = require('openvibe-shared/seo');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
```
++### SEO kit
++
++`openvibe-shared/seo` exports the page head, sitemap, robots and `llms.txt` helpers, plus
++the discovery formats below. The package root also exports them as `seo`.
++
++```js
++const seo = require('openvibe-shared/seo');
++const site = { name: 'Example', url: 'https://example.com/' };
++const full = seo.llmsFull({ site, summary: 'What Example publishes',
++ sections: [{ title: 'Guide', url: '/guide', body: 'The full plain-text guide.' }],
++ maxChars: 20000, maxTotal: 100000 }); // serve as text/plain at /llms-full.txt
++const page = seo.pageSummary({ title: 'Guide', summary: 'A short guide',
++ facts: [['Topic', 'Getting started']], url: 'https://example.com/guide', updated: '2026-10-01' });
++// page.meta is an escaped description attribute value; page.jsonLd is a WebPage object;
++// page.html is an escaped, hidden summary section. Use jsonLdTag(page.jsonLd) in <head>.
++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' }] };
++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' });
++```
++
++`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 section URLs against `site.url`.
++
### Metrics and readiness (server)
```js
ddiff --git a/index.js b/index.js
iindex db30153..14d9584 100644
---- a/index.js
++++ b/index.js
@@@ -7,6 +7,7 @@ const { extractToken, requireOpenVibeAuth, optionalOpenVibeAuth } = require('./m
const { PRIORITY, CATEGORY, TYPES, SOUNDS, EMAIL_ELIGIBLE_CATEGORIES, createNotification, DEFAULT_NOTIFICATION_PREFS } = require('./notifications');
const { AnalyticsTracker, classifyRequest, parseUserAgent, ANALYTICS_SCHEMA, BOT_USER_AGENTS, SUSPICIOUS_PATTERNS } = require('./analytics');
const { URL_DEFINITIONS, normalizeValue, validateValue, resolveRegistryValues, formatRegistryEntry } = require('./url-resolver');
++const seo = require('./seo');
module.exports = {
// Brand
@@@ -50,6 +51,8 @@ module.exports = {
resolveRegistryValues,
formatRegistryEntry,
resolveBrandUrls,
++ // SEO kit (also available from openvibe-shared/seo)
++ seo,
// Client-side modules (browser only — require() for bundlers, <script> tag for direct use)
// OpenVibeNotifications: require('./notification-ui'),
// OpenVibeUserCard: require('./user-card'),
ddiff --git a/seo.js b/seo.js
iindex b3da096..188083e 100644
---- a/seo.js
++++ b/seo.js
@@@ -117,6 +117,31 @@ const jsonLd = {
video({ name, url, description, thumbnailUrl, uploadDate, duration, contentUrl, embedUrl } = {}) {
return { '@context': 'https://schema.org', '@type': 'VideoObject', name, url, description: description || name, thumbnailUrl, uploadDate, ...(duration ? { duration } : {}), ...(contentUrl ? { contentUrl } : {}), ...(embedUrl ? { embedUrl } : {}) };
},
++ videoObject(options = {}) { return jsonLd.video(options); },
++ product({ name, url, description, image, sku, brand, offers, aggregateRating, review } = {}) {
++ return { '@context': 'https://schema.org', '@type': 'Product', name, url, description,
++ ...(image ? { image } : {}), ...(sku ? { sku } : {}),
++ ...(brand ? { brand: typeof brand === 'string' ? { '@type': 'Brand', name: brand } : brand } : {}),
++ ...(offers ? { offers } : {}), ...(aggregateRating ? { aggregateRating } : {}), ...(review ? { review } : {}) };
++ },
++ review({ name, url, reviewBody, author, datePublished, itemReviewed, rating, bestRating = 5, worstRating = 1 } = {}) {
++ return { '@context': 'https://schema.org', '@type': 'Review', name, url, reviewBody,
++ ...(author ? { author: typeof author === 'string' ? { '@type': 'Person', name: author } : author } : {}),
++ ...(datePublished ? { datePublished } : {}), ...(itemReviewed ? { itemReviewed } : {}),
++ ...(rating != null ? { reviewRating: { '@type': 'Rating', ratingValue: rating, bestRating, worstRating } } : {}) };
++ },
++ aggregateRating({ ratingValue, ratingCount, reviewCount, bestRating = 5, worstRating = 1 } = {}) {
++ return { '@context': 'https://schema.org', '@type': 'AggregateRating', ratingValue,
++ ...(ratingCount != null ? { ratingCount } : {}), ...(reviewCount != null ? { reviewCount } : {}), bestRating, worstRating };
++ },
++ imageGallery(images = []) {
++ return images.map((image) => {
++ const item = typeof image === 'string' ? { url: image } : image;
++ return { '@context': 'https://schema.org', '@type': 'ImageObject', url: item.url,
++ ...(item.name ? { name: item.name } : {}), ...(item.caption ? { caption: item.caption } : {}),
++ ...(item.width != null ? { width: item.width } : {}), ...(item.height != null ? { height: item.height } : {}) };
++ });
++ },
};
/** urls: [{ loc, lastmod?, changefreq?, priority?, alternates?: [{hreflang, href}], images?: [url] }] — absolute http(s) only. */
@@@ -171,4 +196,67 @@ function llmsTxt({ name, summary, details, sections = [] } = {}) {
return out.join('\n');
}
--module.exports = { headTags, jsonLd, jsonLdTag, sitemapXml, sitemapIndexXml, robotsTxt, llmsTxt, clip, esc, absolute, AI_AND_SEARCH_BOTS };
++/** Preserve full-text paragraphs while enforcing a character limit, including the ellipsis. */
++function limitText(value, max) {
++ const text = String(value == null ? '' : value);
++ if (max == null || !Number.isFinite(max)) return text;
++ const size = Math.max(0, Math.floor(max));
++ if (text.length <= size) return text;
++ return size ? text.slice(0, size - 1).trimEnd() + '…' : '';
++}
++
++/** /llms-full.txt: the llms.txt header followed by full, plain-text sections. */
++function llmsFull({ site, summary, sections = [], maxChars, maxTotal } = {}) {
++ const name = site && typeof site === 'object' ? site.name || site.title : site;
++ const base = site && typeof site === 'object' ? site.url : site;
++ let out = llmsTxt({ name, summary });
++ for (const section of sections) {
++ if (!section) continue;
++ const url = absolute(section.url, base);
++ const body = limitText(String(section.body == null ? '' : section.body).trim(), section.maxChars == null ? maxChars : section.maxChars);
++ out += `\n## ${section.title || ''}\n\n${url}\n\n${body}\n`;
++ }
++ return limitText(out, maxTotal);
++}
++
++/** A description value, WebPage data, and a hidden text snapshot for a page. */
++function pageSummary({ title, summary, facts = [], url, updated } = {}) {
++ const pageUrl = absolute(url);
++ const text = String(summary == null ? '' : summary);
++ const jsonLd = { '@context': 'https://schema.org', '@type': 'WebPage', name: title, url: pageUrl,
++ abstract: text, ...(updated ? { dateModified: updated } : {}) };
++ const rows = facts.map(([key, value]) => `<dt>${esc(key)}</dt><dd>${esc(value)}</dd>`).join('');
++ const html = `<section data-ov-summary hidden><h2>${esc(title)}</h2><p>${esc(text)}</p>${rows ? `<dl>${rows}</dl>` : ''}${pageUrl ? `<a href="${esc(pageUrl)}">${esc(pageUrl)}</a>` : ''}${updated ? `<time datetime="${esc(updated)}">${esc(updated)}</time>` : ''}</section>`;
++ return { meta: esc(clip(text, 160)), jsonLd, html };
++}
++
++function feedDate(value, format) {
++ if (!value) return '';
++ const date = new Date(value);
++ 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' } = {}) {
++ if (format !== 'rss' && format !== 'atom') throw new TypeError('format must be rss or atom');
++ const home = absolute(link);
++ const rows = items.map((item) => {
++ const url = absolute(item.url, home);
++ const id = item.id || url;
++ 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 `<?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`;
++}
++
++function feedLinkTags({ rss, atom, title = 'Feed' } = {}) {
++ const out = [];
++ if (rss) out.push(`<link rel="alternate" type="application/rss+xml" title="${esc(title)}" href="${esc(absolute(rss))}">`);
++ if (atom) out.push(`<link rel="alternate" type="application/atom+xml" title="${esc(title)}" href="${esc(absolute(atom))}">`);
++ return out.join('\n');
++}
++
++module.exports = { headTags, jsonLd, jsonLdTag, sitemapXml, sitemapIndexXml, robotsTxt, llmsTxt, llmsFull, pageSummary, feedXml, feedLinkTags, clip, esc, absolute, AI_AND_SEARCH_BOTS };
ddiff --git a/test/seo.test.js b/test/seo.test.js
nnew file mode 100644
iindex 0000000..25df62d
---- /dev/null
++++ b/test/seo.test.js
@@@ -0,0 +1,74 @@
++'use strict';
++const assert = require('assert');
++const seo = require('../seo');
++
++for (const name of ['llmsFull', 'pageSummary', 'feedXml', 'feedLinkTags']) assert.strictEqual(typeof seo[name], 'function', `${name} resolves`);
++
++const full = seo.llmsFull({ site: { name: 'Example', url: 'https://example.test/' }, summary: ' A useful site ',
++ sections: [
++ { title: 'Guide', url: '/guide', body: 'First paragraph.\n\nSecond paragraph.' },
++ { title: 'Short', url: '/short', body: 'abcdefghijklmnop', maxChars: 7 },
++ ] });
++assert.ok(full.startsWith(seo.llmsTxt({ name: 'Example', summary: ' A useful site ' })), 'reuses the llms.txt header');
++assert.match(full, /## Guide\n\nhttps:\/\/example\.test\/guide\n\nFirst paragraph\.\n\nSecond paragraph\./);
++assert.match(full, /## Short\n\nhttps:\/\/example\.test\/short\n\nabcdef…/);
++assert.strictEqual(seo.llmsFull({ site: 'X', maxTotal: 0 }), '');
++assert.strictEqual(seo.llmsFull({ site: 'X', summary: 'text', maxTotal: 12 }).length, 12);
++assert.ok(!full.includes('abcdefghijklmnop'), 'per-section limit applies');
++
++const page = seo.pageSummary({ title: '<Guide & More>', summary: 'A "short" <script>alert(1)</script> & guide',
++ facts: [['<Topic>', 'A & B']], url: 'https://example.test/?a=1&b=2', updated: '2026-10-01' });
++assert.strictEqual(page.meta, 'A &quot;short&quot; &lt;script&gt;alert(1)&lt;/script&gt; &amp; guide');
++assert.deepStrictEqual(page.jsonLd, { '@context': 'https://schema.org', '@type': 'WebPage',
++ name: '<Guide & More>', url: 'https://example.test/?a=1&b=2',
++ abstract: 'A "short" <script>alert(1)</script> & guide', dateModified: '2026-10-01' });
++assert.match(page.html, /^<section data-ov-summary hidden>/);
++assert.match(page.html, /<dt>&lt;Topic&gt;<\/dt><dd>A &amp; B<\/dd>/);
++assert.match(page.html, /href="https:\/\/example\.test\/\?a=1&amp;b=2"/);
++assert.ok(!page.html.includes('<script>'), 'HTML fields are escaped');
++assert.ok(seo.jsonLdTag(page.jsonLd).includes('\\u003cscript>'), 'JSON-LD tag safely serializes raw data');
++
++const item = { title: 'One & <Two>', url: '/one?a=1&b=2', id: 'urn:example:one',
++ published: '2026-10-01T12:34:56Z', updated: '2026-10-02T10:00:00Z',
++ summary: 'A <b>summary</b> & more', author: 'A & B' };
++const feed = { title: 'News & Notes', link: 'https://example.test/', description: 'A <feed>',
++ updated: '2026-10-02T10:00:00Z', items: [item] };
++const rss = seo.feedXml(feed, { format: 'rss' });
++assert.match(rss, /<rss version="2\.0"><channel>/);
++assert.match(rss, /<link>https:\/\/example\.test\/one\?a=1&amp;b=2<\/link>/);
++assert.match(rss, /<guid isPermaLink="false">urn:example:one<\/guid>/);
++assert.match(rss, /<pubDate>Thu, 01 Oct 2026 12:34:56 GMT<\/pubDate>/);
++assert.match(rss, /<lastBuildDate>Fri, 02 Oct 2026 10:00:00 GMT<\/lastBuildDate>/);
++assert.match(rss, /<description>A &lt;b&gt;summary&lt;\/b&gt; &amp; more<\/description>/);
++assert.match(rss, /<author>A &amp; B<\/author>/);
++assert.ok(!rss.includes('<b>summary</b>'));
++assert.match(seo.feedXml({ ...feed, items: [{ title: 'A & B', url: '/plain' }] }), /<guid isPermaLink="true">https:\/\/example\.test\/plain<\/guid>/);
++assert.match(seo.feedXml({ ...feed, items: [{ ...item, id: 'urn:one&two' }] }), /<guid isPermaLink="false">urn:one&amp;two<\/guid>/);
++const atom = seo.feedXml(feed, { format: 'atom' });
++assert.match(atom, /<feed xmlns="http:\/\/www\.w3\.org\/2005\/Atom">/);
++assert.match(atom, /<link href="https:\/\/example\.test\/one\?a=1&amp;b=2"\/>/);
++assert.match(atom, /<id>urn:example:one<\/id>/);
++assert.match(atom, /<published>2026-10-01T12:34:56\.000Z<\/published>/);
++assert.match(atom, /<updated>2026-10-02T10:00:00\.000Z<\/updated>/);
++assert.match(atom, /<summary>A &lt;b&gt;summary&lt;\/b&gt; &amp; more<\/summary>/);
++assert.match(atom, /<author><name>A &amp; B<\/name><\/author>/);
++assert.throws(() => seo.feedXml(feed, { format: 'json' }), /rss or atom/);
++const links = seo.feedLinkTags({ rss: 'https://example.test/feed?a=1&b=2', atom: 'https://example.test/atom', title: 'News "A"' });
++assert.match(links, /type="application\/rss\+xml" title="News &quot;A&quot;" href="https:\/\/example\.test\/feed\?a=1&amp;b=2"/);
++assert.match(links, /type="application\/atom\+xml"/);
++
++const rating = seo.jsonLd.aggregateRating({ ratingValue: 4.5, ratingCount: 20 });
++assert.deepStrictEqual([rating['@type'], rating.ratingValue, rating.ratingCount], ['AggregateRating', 4.5, 20]);
++const review = seo.jsonLd.review({ reviewBody: 'Useful', author: 'Ada', rating: 5, itemReviewed: { '@type': 'Product', name: 'Kit' } });
++assert.strictEqual(review.reviewRating.ratingValue, 5);
++assert.deepStrictEqual(review.author, { '@type': 'Person', name: 'Ada' });
++const product = seo.jsonLd.product({ name: 'Kit', url: 'https://example.test/kit', brand: 'Example', aggregateRating: rating, review });
++assert.strictEqual(product['@type'], 'Product');
++assert.deepStrictEqual(product.brand, { '@type': 'Brand', name: 'Example' });
++assert.strictEqual(product.aggregateRating, rating);
++assert.strictEqual(product.review, review);
++assert.deepStrictEqual(seo.jsonLd.imageGallery(['https://example.test/a.jpg', { url: 'https://example.test/b.jpg', caption: 'Second', width: 800 }]).map((x) => x['@type']), ['ImageObject', 'ImageObject']);
++assert.strictEqual(seo.jsonLd.imageGallery([{ url: 'https://example.test/b.jpg', caption: 'Second' }])[0].caption, 'Second');
++assert.deepStrictEqual(seo.jsonLd.videoObject({ name: 'Demo', thumbnailUrl: 'https://example.test/demo.jpg', uploadDate: '2026-10-01' }), seo.jsonLd.video({ name: 'Demo', thumbnailUrl: 'https://example.test/demo.jpg', uploadDate: '2026-10-01' }));
++
++console.log('seo: all checks passed');