openvibers/openvibe.shared Commits
Implemented the SEO kit additions in seo.js, exposed the kit from…
Changes
@@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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@@@ -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@@@ -7,6 +7,7 @@ const { extractToken, requireOpenVibeAuth, optionalOpenVibeAuth } = require('./mconst { 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'),@@@ -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 };nnew file mode 100644@@@ -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 "short" <script>alert(1)</script> & 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><Topic><\/dt><dd>A & B<\/dd>/);++assert.match(page.html, /href="https:\/\/example\.test\/\?a=1&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&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 <b>summary<\/b> & more<\/description>/);++assert.match(rss, /<author>A & 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&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&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 <b>summary<\/b> & more<\/summary>/);++assert.match(atom, /<author><name>A & 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 "A"" href="https:\/\/example\.test\/feed\?a=1&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');
@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 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
- `showcase.css` is served at `/shared/showcase.css` (files.js `STYLES`, text/css): the showcase sections and the ring
@@ -50,6 +50,33 @@ const seo = require('openvibe-shared/seo');
@@ -50,6 +50,33 @@ const seo = require('openvibe-shared/seo');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
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)
### Metrics and readiness (server)
```js
```js
@@ -7,6 +7,7 @@ const { extractToken, requireOpenVibeAuth, optionalOpenVibeAuth } = require('./m
@@ -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 { 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 { AnalyticsTracker, classifyRequest, parseUserAgent, ANALYTICS_SCHEMA, BOT_USER_AGENTS, SUSPICIOUS_PATTERNS } = require('./analytics');
const { URL_DEFINITIONS, normalizeValue, validateValue, resolveRegistryValues, formatRegistryEntry } = require('./url-resolver');
const { URL_DEFINITIONS, normalizeValue, validateValue, resolveRegistryValues, formatRegistryEntry } = require('./url-resolver');
+const seo = require('./seo');
module.exports = {
module.exports = {
// Brand
// Brand
@@ -50,6 +51,8 @@ module.exports = {
@@ -50,6 +51,8 @@ module.exports = {
resolveRegistryValues,
resolveRegistryValues,
formatRegistryEntry,
formatRegistryEntry,
resolveBrandUrls,
resolveBrandUrls,
+ // SEO kit (also available from openvibe-shared/seo)
+ seo,
// Client-side modules (browser only — require() for bundlers, <script> tag for direct use)
// Client-side modules (browser only — require() for bundlers, <script> tag for direct use)
// OpenVibeNotifications: require('./notification-ui'),
// OpenVibeNotifications: require('./notification-ui'),
// OpenVibeUserCard: require('./user-card'),
// OpenVibeUserCard: require('./user-card'),
@@ -117,6 +117,31 @@ const jsonLd = {
@@ -117,6 +117,31 @@ const jsonLd = {
video({ name, url, description, thumbnailUrl, uploadDate, duration, contentUrl, embedUrl } = {}) {
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 } : {}) };
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. */
/** urls: [{ loc, lastmod?, changefreq?, priority?, alternates?: [{hreflang, href}], images?: [url] }] — absolute http(s) only. */
@@ -171,4 +196,67 @@ function llmsTxt({ name, summary, details, sections = [] } = {}) {
@@ -171,4 +196,67 @@ function llmsTxt({ name, summary, details, sections = [] } = {}) {
return out.join('\n');
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -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 "short" <script>alert(1)</script> & 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><Topic><\/dt><dd>A & B<\/dd>/);
+assert.match(page.html, /href="https:\/\/example\.test\/\?a=1&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&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 <b>summary<\/b> & more<\/description>/);
+assert.match(rss, /<author>A & 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&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&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 <b>summary<\/b> & more<\/summary>/);
+assert.match(atom, /<author><name>A & 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 "A"" href="https:\/\/example\.test\/feed\?a=1&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');