openvibers/openvibe.shared Commits
SEO: the Organization joins any WebSite that names it, and robots.txt says yes to search, AI answers and AI training (Content-Signal) (#109)
Changes
@@@ -21,6 +21,12 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).- The hero gets a soft glow from the theme's accent. Sections rise in on scroll (CSS scroll-driven animations, onlywithout reduced motion).- The same safe-link and escaping rules as the other sections.++- **One OpenVibe for search engines and AI crawlers** (`seo.js`):++ - `headTags` adds OpenVibe's Organization node beside a WebSite that names it as publisher but did not include it.++ Chat, Video, OpenRestream, Actor and Work had the reference without the node.++ - `robotsTxt` says yes to search, AI answers and AI training in so many words (`Content-Signal: search=yes,++ ai-input=yes, ai-train=yes`) unless a site passes `allowAI: false`. The owner, 2026-10-25: "fully AI allowed and++ indexable".- **The player sounds, loads and looks better** (`player.js`, `player.css`). The owner, 2026-10-25: "the volume up anddown button on mobile will actually unmute… those little color sample type s*** so that it loads that then the@@@ -77,8 +77,12 @@ function headTags(o = {}) {out.push(`<meta name="twitter:title" content="${esc(title)}">`);if (description) out.push(`<meta name="twitter:description" content="${esc(description)}">`);if (image) out.push(`<meta name="twitter:image" content="${esc(image)}">`);-- const ld = o.jsonLd ? (Array.isArray(o.jsonLd) ? o.jsonLd : [o.jsonLd]) : [];-- for (const item of ld) if (item) out.push(jsonLdTag(item));++ const ld = o.jsonLd ? (Array.isArray(o.jsonLd) ? o.jsonLd : [o.jsonLd]).filter(Boolean) : [];++ // A WebSite names its publisher by @id (OpenVibe's Organization). A page with a WebSite but no Organization gets the++ // node too, so the reference resolves on every site and search engines see one OpenVibe across the network.++ const types = (x) => [].concat(x && x['@type'] || []);++ if (ld.some((x) => types(x).includes('WebSite')) && !ld.some((x) => types(x).includes('Organization') || x['@id'] === ORG['@id'])) ld.push(jsonLd.organization());++ for (const item of ld) out.push(jsonLdTag(item));return out.join('\n');}@@@ -202,7 +206,11 @@ function sitemapIndexXml(maps = []) {const AI_AND_SEARCH_BOTS = ['Googlebot', 'Bingbot', 'DuckDuckBot', 'Applebot', 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-Web', 'anthropic-ai', 'PerplexityBot', 'CCBot', 'Google-Extended', 'Amazonbot', 'meta-externalagent', 'YouBot', 'Bytespider'];function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true, block = [], crawlDelay } = {}) {-- const lines = ['User-agent: *'];++ const lines = [];++ // Content signals (contentsignals.org): with allowAI the network says yes to search, AI answers and training in so many++ // words (owner, 2026-10-25: "fully AI allowed and indexable"), so no crawler has to guess from silence.++ if (allowAI) lines.push('# Content signals: you may use this site\'s content for search, AI answers (ai-input) and AI training.', 'Content-Signal: search=yes, ai-input=yes, ai-train=yes', '');++ lines.push('User-agent: *');for (const a of allow) lines.push(`Allow: ${a}`);for (const d of disallow) lines.push(`Disallow: ${d}`);if (crawlDelay) lines.push(`Crawl-delay: ${crawlDelay}`);@@@ -7,6 +7,8 @@ const head = seo.headTags({ title: 'A very long title that certainly goes past tassert.ok(/<title>[^<]{1,62}<\/title>/.test(head), 'title is clipped');assert.ok(!head.includes('</script><script>alert'), 'JSON-LD cannot close its script tag');assert.ok(seo.robotsTxt({ sitemaps: ['https://x.test/sitemap.xml'] }).includes('GPTBot'), 'AI crawlers are named');++assert.ok(seo.robotsTxt({}).includes('Content-Signal: search=yes, ai-input=yes, ai-train=yes'), 'AI use allowed in so many words');++assert.ok(!seo.robotsTxt({ allowAI: false }).includes('Content-Signal'), 'and not when a site opts out');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'));@@@ -88,3 +90,12 @@ console.log('shared modules: all checks passed');}console.log('browser bundles evaluate: ok');}++++// A WebSite's publisher (@id OpenVibe's Organization) resolves on the page: headTags adds the Organization when it is missing.++{++ const withSite = seo.headTags({ title: 'Chat', canonical: 'https://openvibe.chat/', jsonLd: [seo.jsonLd.website({ name: 'Chat', url: 'https://openvibe.chat/' })] });++ assert.strictEqual((withSite.match(/"@type":"Organization"/g) || []).length, 1, 'the Organization joins a WebSite');++ const both = seo.headTags({ title: 'Live', jsonLd: [seo.jsonLd.website({ name: 'Live', url: 'https://openvibe.live/' }), seo.jsonLd.organization({ description: 'x' })] });++ assert.strictEqual((both.match(/"@type":"Organization"/g) || []).length, 1, 'never twice');++ assert.ok(!seo.headTags({ title: 'T', jsonLd: [{ '@type': 'Thing' }] }).includes('Organization'), 'only beside a WebSite');++}
@@ -21,6 +21,12 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -21,6 +21,12 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
- The hero gets a soft glow from the theme's accent. Sections rise in on scroll (CSS scroll-driven animations, only
- The hero gets a soft glow from the theme's accent. Sections rise in on scroll (CSS scroll-driven animations, only
without reduced motion).
without reduced motion).
- The same safe-link and escaping rules as the other sections.
- The same safe-link and escaping rules as the other sections.
+- **One OpenVibe for search engines and AI crawlers** (`seo.js`):
+ - `headTags` adds OpenVibe's Organization node beside a WebSite that names it as publisher but did not include it.
+ Chat, Video, OpenRestream, Actor and Work had the reference without the node.
+ - `robotsTxt` says yes to search, AI answers and AI training in so many words (`Content-Signal: search=yes,
+ ai-input=yes, ai-train=yes`) unless a site passes `allowAI: false`. The owner, 2026-10-25: "fully AI allowed and
+ indexable".
- **The player sounds, loads and looks better** (`player.js`, `player.css`). The owner, 2026-10-25: "the volume up and
- **The player sounds, loads and looks better** (`player.js`, `player.css`). The owner, 2026-10-25: "the volume up and
down button on mobile will actually unmute… those little color sample type s*** so that it loads that then the
down button on mobile will actually unmute… those little color sample type s*** so that it loads that then the
@@ -77,8 +77,12 @@ function headTags(o = {}) {
@@ -77,8 +77,12 @@ function headTags(o = {}) {
out.push(`<meta name="twitter:title" content="${esc(title)}">`);
out.push(`<meta name="twitter:title" content="${esc(title)}">`);
if (description) out.push(`<meta name="twitter:description" content="${esc(description)}">`);
if (description) out.push(`<meta name="twitter:description" content="${esc(description)}">`);
if (image) out.push(`<meta name="twitter:image" content="${esc(image)}">`);
if (image) out.push(`<meta name="twitter:image" content="${esc(image)}">`);
- const ld = o.jsonLd ? (Array.isArray(o.jsonLd) ? o.jsonLd : [o.jsonLd]) : [];
- for (const item of ld) if (item) out.push(jsonLdTag(item));
+ const ld = o.jsonLd ? (Array.isArray(o.jsonLd) ? o.jsonLd : [o.jsonLd]).filter(Boolean) : [];
+ // A WebSite names its publisher by @id (OpenVibe's Organization). A page with a WebSite but no Organization gets the
+ // node too, so the reference resolves on every site and search engines see one OpenVibe across the network.
+ const types = (x) => [].concat(x && x['@type'] || []);
+ if (ld.some((x) => types(x).includes('WebSite')) && !ld.some((x) => types(x).includes('Organization') || x['@id'] === ORG['@id'])) ld.push(jsonLd.organization());
+ for (const item of ld) out.push(jsonLdTag(item));
return out.join('\n');
return out.join('\n');
}
}
@@ -202,7 +206,11 @@ function sitemapIndexXml(maps = []) {
@@ -202,7 +206,11 @@ function sitemapIndexXml(maps = []) {
const AI_AND_SEARCH_BOTS = ['Googlebot', 'Bingbot', 'DuckDuckBot', 'Applebot', 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-Web', 'anthropic-ai', 'PerplexityBot', 'CCBot', 'Google-Extended', 'Amazonbot', 'meta-externalagent', 'YouBot', 'Bytespider'];
const AI_AND_SEARCH_BOTS = ['Googlebot', 'Bingbot', 'DuckDuckBot', 'Applebot', 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-Web', 'anthropic-ai', 'PerplexityBot', 'CCBot', 'Google-Extended', 'Amazonbot', 'meta-externalagent', 'YouBot', 'Bytespider'];
function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true, block = [], crawlDelay } = {}) {
function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true, block = [], crawlDelay } = {}) {
- const lines = ['User-agent: *'];
+ const lines = [];
+ // Content signals (contentsignals.org): with allowAI the network says yes to search, AI answers and training in so many
+ // words (owner, 2026-10-25: "fully AI allowed and indexable"), so no crawler has to guess from silence.
+ if (allowAI) lines.push('# Content signals: you may use this site\'s content for search, AI answers (ai-input) and AI training.', 'Content-Signal: search=yes, ai-input=yes, ai-train=yes', '');
+ lines.push('User-agent: *');
for (const a of allow) lines.push(`Allow: ${a}`);
for (const a of allow) lines.push(`Allow: ${a}`);
for (const d of disallow) lines.push(`Disallow: ${d}`);
for (const d of disallow) lines.push(`Disallow: ${d}`);
if (crawlDelay) lines.push(`Crawl-delay: ${crawlDelay}`);
if (crawlDelay) lines.push(`Crawl-delay: ${crawlDelay}`);
@@ -7,6 +7,8 @@ const head = seo.headTags({ title: 'A very long title that certainly goes past t
@@ -7,6 +7,8 @@ const head = seo.headTags({ title: 'A very long title that certainly goes past t
assert.ok(/<title>[^<]{1,62}<\/title>/.test(head), 'title is clipped');
assert.ok(/<title>[^<]{1,62}<\/title>/.test(head), 'title is clipped');
assert.ok(!head.includes('</script><script>alert'), 'JSON-LD cannot close its script tag');
assert.ok(!head.includes('</script><script>alert'), 'JSON-LD cannot close its script tag');
assert.ok(seo.robotsTxt({ sitemaps: ['https://x.test/sitemap.xml'] }).includes('GPTBot'), 'AI crawlers are named');
assert.ok(seo.robotsTxt({ sitemaps: ['https://x.test/sitemap.xml'] }).includes('GPTBot'), 'AI crawlers are named');
+assert.ok(seo.robotsTxt({}).includes('Content-Signal: search=yes, ai-input=yes, ai-train=yes'), 'AI use allowed in so many words');
+assert.ok(!seo.robotsTxt({ allowAI: false }).includes('Content-Signal'), 'and not when a site opts out');
assert.ok(!seo.sitemapXml([{ loc: '/relative' }, { loc: 'https://ok.test/' }]).includes('/relative'), 'sitemaps hold absolute URLs only');
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'));
assert.ok(seo.llmsTxt({ name: 'X', summary: 's', sections: [] }).startsWith('# X'));
@@ -88,3 +90,12 @@ console.log('shared modules: all checks passed');
@@ -88,3 +90,12 @@ console.log('shared modules: all checks passed');
}
}
console.log('browser bundles evaluate: ok');
console.log('browser bundles evaluate: ok');
}
}
+
+// A WebSite's publisher (@id OpenVibe's Organization) resolves on the page: headTags adds the Organization when it is missing.
+{
+ const withSite = seo.headTags({ title: 'Chat', canonical: 'https://openvibe.chat/', jsonLd: [seo.jsonLd.website({ name: 'Chat', url: 'https://openvibe.chat/' })] });
+ assert.strictEqual((withSite.match(/"@type":"Organization"/g) || []).length, 1, 'the Organization joins a WebSite');
+ const both = seo.headTags({ title: 'Live', jsonLd: [seo.jsonLd.website({ name: 'Live', url: 'https://openvibe.live/' }), seo.jsonLd.organization({ description: 'x' })] });
+ assert.strictEqual((both.match(/"@type":"Organization"/g) || []).length, 1, 'never twice');
+ assert.ok(!seo.headTags({ title: 'T', jsonLd: [{ '@type': 'Thing' }] }).includes('Organization'), 'only beside a WebSite');
+}