openvibers/openvibe.network Commits

SEO-21: og:image on the five public pages that lacked it (#412)

OP OpenVibers committed 15 days ago · 6bf8739ff8 · 4 files, +51 −4

/stats, /bot, /terms, /privacy and /dmca said og:title and og:description with
no image, so a share of any of them had no picture. Each now names the network's
own share card, https://openvibe.network/shared/social/network.jpg?v=b9d0812ba59b
(openvibe-shared/social, served by /shared/social/*), the one the home page and
/sites already carry. /stats and /bot are rendered here, so they get og:image and
og:image:alt directly. The legal pages are rendered by openvibe-shared/legal
without an image (Shared cannot know which card a site serves), so the network
adds og:image, og:image:alt and twitter:image to those responses and turns the
summary card they declare into the large one that image needs.

test/seo-routes.test.js now walks every public page in the sitemap: a page that
emits og:title must name an og:image, its twitter card must have twitter:image
where one is declared, and each card URL is fetched from the booted server and
checked for 200 image/*. /updates carries no social meta at all yet and is left
out of both the list and the assertion.

Co-authored-by: CommandCodeBot <[email protected]>

Changes

ddiff --git a/server/bot/page.js b/server/bot/page.js
iindex 73b098c1..4808d75e 100644
---- a/server/bot/page.js
++++ b/server/bot/page.js
@@@ -23,6 +23,8 @@ const FACTS = {
};
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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">
ddiff --git a/server/index.js b/server/index.js
iindex cd61e441..9c214253 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.
ddiff --git a/server/stats/open.js b/server/stats/open.js
iindex 23660559..eda70c0e 100644
---- a/server/stats/open.js
++++ b/server/stats/open.js
@@@ -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">
ddiff --git a/test/seo-routes.test.js b/test/seo-routes.test.js
iindex 1c54ae7d..b39c2ebc 100644
---- a/test/seo-routes.test.js
++++ b/test/seo-routes.test.js
@@@ -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');