openvibers/openvibe.network Commits
SEO-21: og:image on the five public pages that lacked it (#412)
Changes
@@@ -23,6 +23,8 @@ const FACTS = {};const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);++// The network's own share card, the one the home page and /sites carry (served at /shared/social/network.jpg).++const shareCard = require('openvibe-shared/social').url('network', 'https://openvibe.network/shared');function snippet(id, label, code) {return `<figure class="bp-code"><figcaption><span>${esc(label)}</span><button type="button" class="bp-copy" data-copy="${id}" aria-label="Copy: ${esc(label)}">Copy</button></figcaption><pre id="${id}"><code>${esc(code)}</code></pre></figure>`;@@@ -42,7 +44,7 @@ function render({ releaseId } = {}) {<meta name="description" content="${esc(desc)}"><link rel="canonical" href="https://openvibe.network/bot"><link rel="alternate" type="application/json" href="/bot.json" title="OpenVibeBot as JSON">--<meta property="og:title" content="${esc(title)}"><meta property="og:description" content="${esc(desc)}"><meta property="og:type" content="website">++<meta property="og:title" content="${esc(title)}"><meta property="og:description" content="${esc(desc)}"><meta property="og:type" content="website"><meta property="og:image" content="${esc(shareCard)}"><meta property="og:image:alt" content="OpenVibe.Network: one account for every OpenVibe site">${require('../boost').meta(releaseId)}${require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: '/assets' })}<meta name="color-scheme" content="dark light">@@@ -665,8 +665,22 @@ function requireAdmin(req, res, next) {}next();}--// Terms, Privacy and DMCA for this domain (openvibe-shared/legal; every site serves its own).--{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'network', service: 'network', host: 'openvibe.network', name: 'OpenVibe.Network', profile: 'account' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }++// Terms, Privacy and DMCA for this domain (openvibe-shared/legal; every site serves its own). The shared pages carry++// og:title/og:description and no share image (Shared cannot know which card a site serves), so the network's own card++// is added to the head here, and the small Twitter card they declare becomes the large one that image needs.++{++ const legal = require('openvibe-shared/legal');++ const card = require('openvibe-shared/social').url('network', 'https://openvibe.network/shared');++ const tags = `<meta property="og:image" content="${card}"><meta property="og:image:alt" content="OpenVibe.Network: one account for every OpenVibe site"><meta name="twitter:image" content="${card}">`;++ app.get(legal.PATHS, (req, res, next) => {++ const send = res.send.bind(res);++ res.send = (body) => send(String(body)++ .replace('<meta name="twitter:card" content="summary">', '<meta name="twitter:card" content="summary_large_image">')++ .replace('</head>', `${tags}</head>`));++ next();++ }, legal.handler({ id: 'network', service: 'network', host: 'openvibe.network', name: 'OpenVibe.Network', profile: 'account' }));++ app.get('/tos', (_req, res) => res.redirect(301, '/terms'));++}// The tool catalog, re-served from here: every OpenVibe site's content-security policy already allows// openvibe.network, so the navbar's search works on hosts that may not call openvibe.tools directly.@@@ -28,6 +28,8 @@ const esc = seo.esc;const DAY = 86400000;const WINDOWS = [7, 30, 90];const TTL_MS = 5 * 60 * 1000;++// The network's own share card, the one the home page and /sites carry (served at /shared/social/network.jpg).++const shareCard = require('openvibe-shared/social').url('network', 'https://openvibe.network/shared');// Routes that are plumbing, not something a person uses (as the admin insights): left out of the busiest pages.const PLUMBING = /^(\/metrics|\/api\/(ready|health)|static|\/release\.json|\/robots\.txt|\/favicon\.ico|\/internal\/|\/api\/frame\/hit|\/oauth\/token|\/\.well-known\/|\/api\/admin)/;@@@ -319,7 +321,7 @@ ${s.daily ? sparkline(s.daily) : '<span class="os-site-own">counts its own visit<meta name="robots" content="index, follow"><link rel="canonical" href="https://openvibe.network/stats${days === 30 ? '' : `?days=${days}`}"><link rel="alternate" type="application/json" href="/api/v1/stats?days=${days}" title="These numbers as JSON">--<meta property="og:title" content="${esc(title)}"><meta property="og:description" content="${esc(desc)}"><meta property="og:type" content="website">++<meta property="og:title" content="${esc(title)}"><meta property="og:description" content="${esc(desc)}"><meta property="og:type" content="website"><meta property="og:image" content="${esc(shareCard)}"><meta property="og:image:alt" content="OpenVibe.Network: one account for every OpenVibe site">${require('../boost').meta(releaseId)}${require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: '/assets' })}<meta name="color-scheme" content="dark light">@@@ -127,6 +127,35 @@ const cache = require('openvibe-shared/cache-policy');assert.ok(fullBody.includes('URL: https://openvibe.network/sites'), 'llms-full.txt lists /sites');assert.ok(fullBody.includes(`${one.name} (https://${one.host}/)`) || fullBody.includes(`OpenVibe.${one.name} (https://${one.host}/)`), 'llms-full.txt carries the site list under /sites');++ // ── og:image on every public page that carries social meta (SEO-21) ──++ // Every page in the sitemap that says og:title also names a card a crawler can fetch, and each card is an++ // image the site really serves (200, image/*). /updates carries no social meta at all yet, so it has nothing++ // to claim; the five pages fixed here are listed below so the set cannot quietly shrink.++ const heads = new Map();++ const cards = new Map();++ for (const { path } of seoRoutes.PAGES) {++ const page = await get(path);++ assert.strictEqual(page.status, 200, `${path} answers`);++ const head = page.body.slice(0, page.body.indexOf('</head>'));++ heads.set(path, head);++ if (!/property="og:title"/.test(head)) { assert.strictEqual(path, '/updates', `${path} carries social meta`); continue; }++ const card = /<meta property="og:image" content="([^"]+)"/.exec(head);++ assert.ok(card, `${path} has og:image`);++ cards.set(path, card[1]);++ if (/<meta name="twitter:card"/.test(head)) assert.ok(/<meta name="twitter:image" content="[^"]+">/.test(head), `${path} has twitter:image`);++ }++ for (const p of ['/stats', '/bot', '/terms', '/privacy', '/dmca']) {++ assert.ok(/<meta property="og:image" content="https:\/\/openvibe\.network\/shared\/social\/network\.jpg\?v=/.test(heads.get(p)), `${p} carries the network share card`);++ }++ for (const [path, card] of cards) {++ // The absolute URL names the live host; the test reads the same path from the booted server (no egress).++ const at = new URL(card);++ const img = await fetch(srv.base + at.pathname + at.search);++ assert.strictEqual(img.status, 200, `${path}: ${card} answers`);++ assert.ok((img.headers.get('content-type') || '').startsWith('image/'), `${path}: ${card} is an image (got ${img.headers.get('content-type')})`);++ await img.arrayBuffer();++ }++// ── IndexNow: no INDEXNOW_KEY → no key file ──const KEY = require('crypto').randomBytes(16).toString('hex');assert.strictEqual((await get(`/${KEY}.txt`)).status, 404, 'no key file is served without INDEXNOW_KEY');
@@ -23,6 +23,8 @@ const FACTS = {
@@ -23,6 +23,8 @@ const FACTS = {
};
};
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
+// The network's own share card, the one the home page and /sites carry (served at /shared/social/network.jpg).
+const shareCard = require('openvibe-shared/social').url('network', 'https://openvibe.network/shared');
function snippet(id, label, code) {
function snippet(id, label, code) {
return `<figure class="bp-code"><figcaption><span>${esc(label)}</span><button type="button" class="bp-copy" data-copy="${id}" aria-label="Copy: ${esc(label)}">Copy</button></figcaption><pre id="${id}"><code>${esc(code)}</code></pre></figure>`;
return `<figure class="bp-code"><figcaption><span>${esc(label)}</span><button type="button" class="bp-copy" data-copy="${id}" aria-label="Copy: ${esc(label)}">Copy</button></figcaption><pre id="${id}"><code>${esc(code)}</code></pre></figure>`;
@@ -42,7 +44,7 @@ function render({ releaseId } = {}) {
@@ -42,7 +44,7 @@ function render({ releaseId } = {}) {
<meta name="description" content="${esc(desc)}">
<meta name="description" content="${esc(desc)}">
<link rel="canonical" href="https://openvibe.network/bot">
<link rel="canonical" href="https://openvibe.network/bot">
<link rel="alternate" type="application/json" href="/bot.json" title="OpenVibeBot as JSON">
<link rel="alternate" type="application/json" href="/bot.json" title="OpenVibeBot as JSON">
-<meta property="og:title" content="${esc(title)}"><meta property="og:description" content="${esc(desc)}"><meta property="og:type" content="website">
+<meta property="og:title" content="${esc(title)}"><meta property="og:description" content="${esc(desc)}"><meta property="og:type" content="website"><meta property="og:image" content="${esc(shareCard)}"><meta property="og:image:alt" content="OpenVibe.Network: one account for every OpenVibe site">
${require('../boost').meta(releaseId)}
${require('../boost').meta(releaseId)}
${require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: '/assets' })}
${require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: '/assets' })}
<meta name="color-scheme" content="dark light">
<meta name="color-scheme" content="dark light">
@@ -665,8 +665,22 @@ function requireAdmin(req, res, next) {
@@ -665,8 +665,22 @@ function requireAdmin(req, res, next) {
}
}
next();
next();
}
}
-// Terms, Privacy and DMCA for this domain (openvibe-shared/legal; every site serves its own).
-{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'network', service: 'network', host: 'openvibe.network', name: 'OpenVibe.Network', profile: 'account' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }
+// Terms, Privacy and DMCA for this domain (openvibe-shared/legal; every site serves its own). The shared pages carry
+// og:title/og:description and no share image (Shared cannot know which card a site serves), so the network's own card
+// is added to the head here, and the small Twitter card they declare becomes the large one that image needs.
+{
+ const legal = require('openvibe-shared/legal');
+ const card = require('openvibe-shared/social').url('network', 'https://openvibe.network/shared');
+ const tags = `<meta property="og:image" content="${card}"><meta property="og:image:alt" content="OpenVibe.Network: one account for every OpenVibe site"><meta name="twitter:image" content="${card}">`;
+ app.get(legal.PATHS, (req, res, next) => {
+ const send = res.send.bind(res);
+ res.send = (body) => send(String(body)
+ .replace('<meta name="twitter:card" content="summary">', '<meta name="twitter:card" content="summary_large_image">')
+ .replace('</head>', `${tags}</head>`));
+ next();
+ }, legal.handler({ id: 'network', service: 'network', host: 'openvibe.network', name: 'OpenVibe.Network', profile: 'account' }));
+ app.get('/tos', (_req, res) => res.redirect(301, '/terms'));
+}
// The tool catalog, re-served from here: every OpenVibe site's content-security policy already allows
// The tool catalog, re-served from here: every OpenVibe site's content-security policy already allows
// openvibe.network, so the navbar's search works on hosts that may not call openvibe.tools directly.
// openvibe.network, so the navbar's search works on hosts that may not call openvibe.tools directly.
@@ -28,6 +28,8 @@ const esc = seo.esc;
@@ -28,6 +28,8 @@ const esc = seo.esc;
const DAY = 86400000;
const DAY = 86400000;
const WINDOWS = [7, 30, 90];
const WINDOWS = [7, 30, 90];
const TTL_MS = 5 * 60 * 1000;
const TTL_MS = 5 * 60 * 1000;
+// The network's own share card, the one the home page and /sites carry (served at /shared/social/network.jpg).
+const shareCard = require('openvibe-shared/social').url('network', 'https://openvibe.network/shared');
// Routes that are plumbing, not something a person uses (as the admin insights): left out of the busiest pages.
// Routes that are plumbing, not something a person uses (as the admin insights): left out of the busiest pages.
const PLUMBING = /^(\/metrics|\/api\/(ready|health)|static|\/release\.json|\/robots\.txt|\/favicon\.ico|\/internal\/|\/api\/frame\/hit|\/oauth\/token|\/\.well-known\/|\/api\/admin)/;
const PLUMBING = /^(\/metrics|\/api\/(ready|health)|static|\/release\.json|\/robots\.txt|\/favicon\.ico|\/internal\/|\/api\/frame\/hit|\/oauth\/token|\/\.well-known\/|\/api\/admin)/;
@@ -319,7 +321,7 @@ ${s.daily ? sparkline(s.daily) : '<span class="os-site-own">counts its own visit
@@ -319,7 +321,7 @@ ${s.daily ? sparkline(s.daily) : '<span class="os-site-own">counts its own visit
<meta name="robots" content="index, follow">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://openvibe.network/stats${days === 30 ? '' : `?days=${days}`}">
<link rel="canonical" href="https://openvibe.network/stats${days === 30 ? '' : `?days=${days}`}">
<link rel="alternate" type="application/json" href="/api/v1/stats?days=${days}" title="These numbers as JSON">
<link rel="alternate" type="application/json" href="/api/v1/stats?days=${days}" title="These numbers as JSON">
-<meta property="og:title" content="${esc(title)}"><meta property="og:description" content="${esc(desc)}"><meta property="og:type" content="website">
+<meta property="og:title" content="${esc(title)}"><meta property="og:description" content="${esc(desc)}"><meta property="og:type" content="website"><meta property="og:image" content="${esc(shareCard)}"><meta property="og:image:alt" content="OpenVibe.Network: one account for every OpenVibe site">
${require('../boost').meta(releaseId)}
${require('../boost').meta(releaseId)}
${require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: '/assets' })}
${require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: '/assets' })}
<meta name="color-scheme" content="dark light">
<meta name="color-scheme" content="dark light">
@@ -127,6 +127,35 @@ const cache = require('openvibe-shared/cache-policy');
@@ -127,6 +127,35 @@ const cache = require('openvibe-shared/cache-policy');
assert.ok(fullBody.includes('URL: https://openvibe.network/sites'), 'llms-full.txt lists /sites');
assert.ok(fullBody.includes('URL: https://openvibe.network/sites'), 'llms-full.txt lists /sites');
assert.ok(fullBody.includes(`${one.name} (https://${one.host}/)`) || fullBody.includes(`OpenVibe.${one.name} (https://${one.host}/)`), 'llms-full.txt carries the site list under /sites');
assert.ok(fullBody.includes(`${one.name} (https://${one.host}/)`) || fullBody.includes(`OpenVibe.${one.name} (https://${one.host}/)`), 'llms-full.txt carries the site list under /sites');
+ // ── og:image on every public page that carries social meta (SEO-21) ──
+ // Every page in the sitemap that says og:title also names a card a crawler can fetch, and each card is an
+ // image the site really serves (200, image/*). /updates carries no social meta at all yet, so it has nothing
+ // to claim; the five pages fixed here are listed below so the set cannot quietly shrink.
+ const heads = new Map();
+ const cards = new Map();
+ for (const { path } of seoRoutes.PAGES) {
+ const page = await get(path);
+ assert.strictEqual(page.status, 200, `${path} answers`);
+ const head = page.body.slice(0, page.body.indexOf('</head>'));
+ heads.set(path, head);
+ if (!/property="og:title"/.test(head)) { assert.strictEqual(path, '/updates', `${path} carries social meta`); continue; }
+ const card = /<meta property="og:image" content="([^"]+)"/.exec(head);
+ assert.ok(card, `${path} has og:image`);
+ cards.set(path, card[1]);
+ if (/<meta name="twitter:card"/.test(head)) assert.ok(/<meta name="twitter:image" content="[^"]+">/.test(head), `${path} has twitter:image`);
+ }
+ for (const p of ['/stats', '/bot', '/terms', '/privacy', '/dmca']) {
+ assert.ok(/<meta property="og:image" content="https:\/\/openvibe\.network\/shared\/social\/network\.jpg\?v=/.test(heads.get(p)), `${p} carries the network share card`);
+ }
+ for (const [path, card] of cards) {
+ // The absolute URL names the live host; the test reads the same path from the booted server (no egress).
+ const at = new URL(card);
+ const img = await fetch(srv.base + at.pathname + at.search);
+ assert.strictEqual(img.status, 200, `${path}: ${card} answers`);
+ assert.ok((img.headers.get('content-type') || '').startsWith('image/'), `${path}: ${card} is an image (got ${img.headers.get('content-type')})`);
+ await img.arrayBuffer();
+ }
+
// ── IndexNow: no INDEXNOW_KEY → no key file ──
// ── IndexNow: no INDEXNOW_KEY → no key file ──
const KEY = require('crypto').randomBytes(16).toString('hex');
const KEY = require('crypto').randomBytes(16).toString('hex');
assert.strictEqual((await get(`/${KEY}.txt`)).status, 404, 'no key file is served without INDEXNOW_KEY');
assert.strictEqual((await get(`/${KEY}.txt`)).status, 404, 'no key file is served without INDEXNOW_KEY');