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)
Changes
@@@ -4,6 +4,16 @@ 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).++## 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@@@ -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"@@@ -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": {@@@ -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 = []) {@@@ -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 & Jerry <live></video:title><video:description>A "stream" 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');
@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
+## 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
## 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
- **Every site in the launcher, in groups.** The menu beside the brand showed the first 12 of the network's sites (the
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.4.0",
+ "version": "4.5.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.4.0",
+ "version": "4.5.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
*
*
* const seo = require('openvibe-shared/seo');
* const seo = require('openvibe-shared/seo');
* seo.headTags({ title, description, canonical, image, jsonLd: [seo.jsonLd.website({...})] })
* 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
* 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
* 60 characters on a word, descriptions at 160, og/twitter mirrored from the same values, JSON-LD
@@ -144,8 +144,41 @@ const jsonLd = {
@@ -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 = []) {
function sitemapXml(urls = []) {
+ let videos = false;
const rows = urls.filter((u) => u && /^https?:\/\//i.test(u.loc)).map((u) => {
const rows = urls.filter((u) => u && /^https?:\/\//i.test(u.loc)).map((u) => {
const parts = [`<loc>${xml(u.loc)}</loc>`];
const parts = [`<loc>${xml(u.loc)}</loc>`];
if (u.lastmod) parts.push(`<lastmod>${xml(String(u.lastmod).slice(0, 10))}</lastmod>`);
if (u.lastmod) parts.push(`<lastmod>${xml(String(u.lastmod).slice(0, 10))}</lastmod>`);
@@ -153,9 +186,11 @@ function sitemapXml(urls = []) {
@@ -153,9 +186,11 @@ function sitemapXml(urls = []) {
if (u.priority != null) parts.push(`<priority>${Number(u.priority).toFixed(1)}</priority>`);
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 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 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 ` <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 = []) {
function sitemapIndexXml(maps = []) {
@@ -71,4 +71,26 @@ assert.deepStrictEqual(seo.jsonLd.imageGallery(['https://example.test/a.jpg', {
@@ -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.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' }));
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 & Jerry <live></video:title><video:description>A "stream" 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');
console.log('seo: all checks passed');