openvibers/openvibe.shared Commits

4.5.0: video sitemaps (seo.sitemapXml takes videos on a URL, written as Google's video:video; the namespace only when one is listed) (#88)

OP OpenVibers committed 24 days ago · 8f5d473190 · 5 files, +73 −6

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 1aa70d3..67d739e 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,16 @@ 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).
++## 4.5.0 — 2026-10-20
++
++- **Video sitemaps.** `seo.sitemapXml` takes `videos` on a URL: `{ thumbnail_loc, title, description, content_loc
++ and/or player_loc, duration?, view_count?, publication_date?, family_friendly?, uploader?, uploader_info?, live? }`,
++ written as Google's `<video:video>` (sitemap-video/1.1). The `xmlns:video` namespace is declared only when a video
++ is listed, so every existing sitemap is byte-for-byte the same. An entry missing a thumbnail, title, description or
++ a file/player is left out (its page stays). Titles are clipped to 100 characters, descriptions to 2,048, and a
++ duration outside 1–28,800 seconds is omitted. First user: openvibe.video, whose recordings and clips had no video
++ sitemap.
++
## 4.4.0 — 2026-10-17
- **Every site in the launcher, in groups.** The menu beside the brand showed the first 12 of the network's sites (the
ddiff --git a/package-lock.json b/package-lock.json
iindex da6082d..de6c95b 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.4.0",
++ "version": "4.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.4.0",
++ "version": "4.5.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 0110ee6..6d857e2 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.4.0",
++ "version": "4.5.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/seo.js b/seo.js
iindex 6c7a036..ddf210f 100644
---- a/seo.js
++++ b/seo.js
@@@ -4,7 +4,7 @@
*
* const seo = require('openvibe-shared/seo');
* seo.headTags({ title, description, canonical, image, jsonLd: [seo.jsonLd.website({...})] })
-- * seo.sitemapXml([{ loc, lastmod }]) seo.robotsTxt({ sitemaps }) seo.llmsTxt({...})
++ * seo.sitemapXml([{ loc, lastmod, videos? }]) seo.robotsTxt({ sitemaps }) seo.llmsTxt({...})
*
* Rules it encodes so pages do not have to remember them: one canonical per page, titles cut at
* 60 characters on a word, descriptions at 160, og/twitter mirrored from the same values, JSON-LD
@@@ -144,8 +144,41 @@ const jsonLd = {
},
};
--/** urls: [{ loc, lastmod?, changefreq?, priority?, alternates?: [{hreflang, href}], images?: [url] }] — absolute http(s) only. */
++const isAbs = (u) => typeof u === 'string' && /^https?:\/\//i.test(u);
++
++/**
++ * One <video:video> of a video sitemap (Google's sitemap-video/1.1), or '' when a required part is missing: a thumbnail,
++ * a title, a description and the video itself (content_loc, the file, or player_loc, a page or embed that plays it).
++ * Duration is whole seconds (1–28,800, longer is left out); publication_date an ISO date or date-time.
++ */
++function videoXml(v) {
++ if (!v || !isAbs(v.thumbnail_loc) || !v.title || !v.description || !(isAbs(v.content_loc) || isAbs(v.player_loc))) return '';
++ const parts = [`<video:thumbnail_loc>${xml(v.thumbnail_loc)}</video:thumbnail_loc>`,
++ `<video:title>${xml(String(v.title).slice(0, 100))}</video:title>`,
++ `<video:description>${xml(String(v.description).slice(0, 2048))}</video:description>`];
++ if (isAbs(v.content_loc)) parts.push(`<video:content_loc>${xml(v.content_loc)}</video:content_loc>`);
++ if (isAbs(v.player_loc)) parts.push(`<video:player_loc>${xml(v.player_loc)}</video:player_loc>`);
++ const d = Math.round(Number(v.duration));
++ if (d >= 1 && d <= 28800) parts.push(`<video:duration>${d}</video:duration>`);
++ if (v.view_count != null && Number.isFinite(Number(v.view_count))) parts.push(`<video:view_count>${Math.max(0, Math.floor(Number(v.view_count)))}</video:view_count>`);
++ if (v.publication_date) parts.push(`<video:publication_date>${xml(String(v.publication_date))}</video:publication_date>`);
++ if (v.family_friendly === false) parts.push('<video:family_friendly>no</video:family_friendly>');
++ if (v.uploader) {
++ const info = isAbs(v.uploader_info) ? ` info="${xml(v.uploader_info)}"` : '';
++ parts.push(`<video:uploader${info}>${xml(String(v.uploader).slice(0, 255))}</video:uploader>`);
++ }
++ if (v.live != null) parts.push(`<video:live>${v.live ? 'yes' : 'no'}</video:live>`);
++ return `<video:video>${parts.join('')}</video:video>`;
++}
++
++/**
++ * urls: [{ loc, lastmod?, changefreq?, priority?, alternates?: [{hreflang, href}], images?: [url], videos?: [video] }],
++ * absolute http(s) only. A video is { thumbnail_loc, title, description, content_loc and/or player_loc, duration?,
++ * view_count?, publication_date?, family_friendly?, uploader?, uploader_info?, live? } (see videoXml); the video
++ * namespace is declared only when a video is listed.
++ */
function sitemapXml(urls = []) {
++ let videos = false;
const rows = urls.filter((u) => u && /^https?:\/\//i.test(u.loc)).map((u) => {
const parts = [`<loc>${xml(u.loc)}</loc>`];
if (u.lastmod) parts.push(`<lastmod>${xml(String(u.lastmod).slice(0, 10))}</lastmod>`);
@@@ -153,9 +186,11 @@ function sitemapXml(urls = []) {
if (u.priority != null) parts.push(`<priority>${Number(u.priority).toFixed(1)}</priority>`);
for (const a of u.alternates || []) parts.push(`<xhtml:link rel="alternate" hreflang="${xml(a.hreflang)}" href="${xml(a.href)}"/>`);
for (const img of u.images || []) parts.push(`<image:image><image:loc>${xml(img)}</image:loc></image:image>`);
++ for (const v of u.videos || []) { const x = videoXml(v); if (x) { parts.push(x); videos = true; } }
return ` <url>${parts.join('')}</url>`;
});
-- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">\n${rows.join('\n')}\n</urlset>\n`;
++ const videoNs = videos ? ' xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"' : '';
++ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"${videoNs}>\n${rows.join('\n')}\n</urlset>\n`;
}
function sitemapIndexXml(maps = []) {
ddiff --git a/test/seo.test.js b/test/seo.test.js
iindex 25df62d..29412d9 100644
---- a/test/seo.test.js
++++ b/test/seo.test.js
@@@ -71,4 +71,26 @@ assert.deepStrictEqual(seo.jsonLd.imageGallery(['https://example.test/a.jpg', {
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' }));
++// Video sitemaps (Google's sitemap-video/1.1): the namespace only when a video is listed; an entry missing a required
++// part (thumbnail, title, description, a file or a player) is left out; text is escaped and clipped.
++const plain = seo.sitemapXml([{ loc: 'https://example.test/' }]);
++assert.ok(!plain.includes('xmlns:video'), 'no video namespace without videos');
++const vmap = seo.sitemapXml([
++ { loc: 'https://example.test/v/1', lastmod: '2026-10-20', videos: [{
++ thumbnail_loc: 'https://cdn.test/1.jpg', title: 'Tom & Jerry <live>', description: 'A "stream" recording', content_loc: 'https://cdn.test/1.mp4',
++ player_loc: 'https://example.test/v/1', duration: 600.4, view_count: 12, publication_date: '2026-10-20T13:26:38.000Z',
++ uploader: 'Ada', uploader_info: 'https://example.test/@ada', live: false }] },
++ { loc: 'https://example.test/v/2', videos: [{ thumbnail_loc: 'https://cdn.test/2.jpg', title: 'No file', description: 'x' }] },
++ { loc: 'https://example.test/v/3', videos: [{ thumbnail_loc: 'https://cdn.test/3.jpg', title: 'Long', description: 'y', player_loc: 'https://example.test/v/3', duration: 40000 }] },
++]);
++assert.ok(vmap.includes('xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"'));
++assert.ok(vmap.includes('<url><loc>https://example.test/v/1</loc><lastmod>2026-10-20</lastmod><video:video><video:thumbnail_loc>https://cdn.test/1.jpg</video:thumbnail_loc>'
++ + '<video:title>Tom &amp; Jerry &lt;live&gt;</video:title><video:description>A &quot;stream&quot; recording</video:description>'
++ + '<video:content_loc>https://cdn.test/1.mp4</video:content_loc><video:player_loc>https://example.test/v/1</video:player_loc>'
++ + '<video:duration>600</video:duration><video:view_count>12</video:view_count><video:publication_date>2026-10-20T13:26:38.000Z</video:publication_date>'
++ + '<video:uploader info="https://example.test/@ada">Ada</video:uploader><video:live>no</video:live></video:video></url>'), vmap);
++assert.ok(vmap.includes('<url><loc>https://example.test/v/2</loc></url>'), 'an entry with no file or player is dropped, the page stays');
++assert.ok(/<loc>https:\/\/example\.test\/v\/3<\/loc><video:video>(?:(?!<video:duration>).)*<\/video:video>/.test(vmap), 'a duration over 8 hours is left out');
++assert.ok(seo.sitemapXml([{ loc: 'https://example.test/', videos: [{ thumbnail_loc: 'https://cdn.test/t.jpg', title: 'T', description: 'D'.repeat(3000), player_loc: 'https://example.test/' }] }]).includes(`${'D'.repeat(2048)}</video:description>`), 'descriptions are clipped to 2,048');
++
console.log('seo: all checks passed');