openvibers/openvibe.live Commits

What the browser check found on Live (Host scripts/browser-check.js, WS-Q task 3): /chat and /search are server-rendered with their own title and canonical (/search?q=… names /search and stays noindex; its meta existed but the route was missing from SEO_ROUTE_RE, so the shell answered with no canonical); the home page's JSON-LD is one ItemList per snapshot section, named by its visible heading (Live now, Recent VODs, Recent clips) instead of "Live & recent on OpenVibe.Live", which no page showed; the docs pages get a canonical URL, an icon that exists (/assets/logo.svg; /assets/favicon.ico 404ed on every docs visit) and keyboard access to code blocks that scroll (tabindex, focus ring; axe scrollable-region-focusable). Tests: seo-ssr, docs-render

OP OpenVibers committed 26 Sept 2026 · 934156bb0f · 4 files, +54 −8


Changes

ddiff --git a/server/docs/routes.js b/server/docs/routes.js
iindex 30e2bfc5..427322c9 100644
---- a/server/docs/routes.js
++++ b/server/docs/routes.js
@@@ -123,7 +123,8 @@ function renderMarkdown(markdown) {
i++;
while (i < lines.length && !/^```\s*$/.test(lines[i])) code.push(lines[i++]);
i++; // closing fence
-- out.push(`<pre${lang ? ` data-lang="${escapeHtml(lang)}"` : ''}><code>${escapeHtml(code.join('\n'))}</code></pre>`);
++ // tabindex: a code block that scrolls sideways must be reachable by keyboard (axe scrollable-region-focusable).
++ out.push(`<pre${lang ? ` data-lang="${escapeHtml(lang)}"` : ''} tabindex="0"><code>${escapeHtml(code.join('\n'))}</code></pre>`);
continue;
}
@@@ -215,6 +216,14 @@ function renderMarkdown(markdown) {
// ── Page shell ───────────────────────────────────────────────
++// The public origin for canonical URLs: the configured base URL, never a local one (as server/seo/seo.js).
++let config = null; try { config = require('../config'); } catch { /* */ }
++function publicBase() {
++ let b = (config && config.baseUrl) || 'https://openvibe.live';
++ if (/localhost|127\.0\.0\.1/.test(b)) b = 'https://openvibe.live';
++ return b.replace(/\/+$/, '');
++}
++
function renderPage({ name, title, html, headings }) {
const pageTitle = title ? `${title} — OpenVibe.Live Docs` : 'OpenVibe.Live Docs';
const toc = headings.length
@@@ -226,7 +235,8 @@ function renderPage({ name, title, html, headings }) {
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(pageTitle)}</title>
-- <link rel="icon" href="/assets/favicon.ico">
++ <link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
++ <link rel="canonical" href="${escapeHtml(`${publicBase()}/docs${name === 'README' ? '' : `/${name}`}`)}">
<style>
:root { --bg: #0d0d0f; --bg-2: #16161a; --bg-3: #1e1e24; --text: #e8e6e3; --text-2: #9a9a9a; --muted: #666;
--accent: #8b5cf6; --accent-light: #a78bfa; --border: #2a2a32; }
@@@ -250,6 +260,7 @@ function renderPage({ name, title, html, headings }) {
li { margin: 0.25em 0; }
code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: var(--bg-3); padding: 1px 5px; border-radius: 4px; }
pre { background: var(--bg-2); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; overflow-x: auto; position: relative; }
++ pre:focus-visible { outline: 2px solid var(--accent-light); outline-offset: 2px; }
pre code { background: none; padding: 0; font-size: 0.85rem; line-height: 1.5; }
pre[data-lang]::before { content: attr(data-lang); position: absolute; top: 6px; right: 12px; font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
blockquote { border-left: 3px solid var(--accent); background: rgba(139, 92, 246, 0.08); padding: 10px 16px; border-radius: 0 8px 8px 0; }
@@@ -309,3 +320,4 @@ module.exports = router;
module.exports._renderMarkdown = renderMarkdown;
module.exports._rewriteHref = rewriteHref;
module.exports._slugify = slugify;
++module.exports._renderPage = renderPage;
ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex 0495384f..41585909 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -230,6 +230,8 @@ async function _pageMeta(routePath, { page = 1 } = {}) {
if (p === '/arena') return _arenaMeta();
// Search results (public/js/app-search.js) are never indexed; the page itself is findable.
if (p === '/search') return { title: `Search | ${SITE_NAME}`, description: 'Search channels, VODs and clips on OpenVibe.Live.', canonicalPath: '/search', ogType: 'website', robots: 'noindex,follow', jsonLd: [] };
++ // The global chat room: its own title and canonical (the shell left it with neither; browser check, WS-Q task 3).
++ if (p === '/chat') return { title: `Chat | ${SITE_NAME}`, description: 'The global chat room of OpenVibe.Live, open to everyone on the network.', canonicalPath: '/chat', ogType: 'website', robots: 'index,follow', jsonLd: [] };
return null;
void bu;
@@@ -481,9 +483,11 @@ async function _homeMeta() {
_mediaSection('Recent clips', clipItems) +
_mediaSection('Recent pastes', pasteItems);
-- // Rich JSON-LD: WebSite + an ItemList of what's live/recent right now.
-- const listEls = [...liveItems, ...vodItems, ...clipItems].slice(0, 20)
-- .map((it, i) => ({ '@type': 'ListItem', position: i + 1, url: abs(it.url), name: clean(it.name, 110) }));
++ // Rich JSON-LD: WebSite + one ItemList per section of the snapshot, named by that section's visible
++ // heading (structured data names what a visitor sees; the browser check compares them).
++ const itemList = (name, items) => ({ '@context': 'https://schema.org', '@type': 'ItemList', name,
++ itemListElement: items.slice(0, 10).map((it, i) => ({ '@type': 'ListItem', position: i + 1, url: abs(it.url), name: clean(it.name, 110) })) });
++ const lists = [['Live now', liveItems], ['Recent VODs', vodItems], ['Recent clips', clipItems]].filter(([, items]) => items.length).map(([n, items]) => itemList(n, items));
const jsonLd = [
{
'@context': 'https://schema.org', '@type': 'WebSite',
@@@ -503,7 +507,7 @@ async function _homeMeta() {
'https://openvibe.network',
],
},
-- { '@context': 'https://schema.org', '@type': 'ItemList', name: 'Live & recent on OpenVibe.Live', itemListElement: listEls },
++ ...lists,
];
return { title, description, canonicalPath: '/', image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow', jsonLd, snapshot, cacheTtlMs: track.partial ? SHORT_CACHE_MS : undefined };
}
@@@ -982,7 +986,7 @@ function _cacheSet(key, html, ttl = CACHE_TTL_MS) {
_cache.set(key, { html, at: Date.now(), ttl });
}
--const SEO_ROUTE_RE = /^\/(?:$|content$|moments$|vods$|clips$|pastes$|arena$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_]{3,24}$)/;
++const SEO_ROUTE_RE = /^\/(?:$|content$|moments$|vods$|clips$|pastes$|arena$|chat$|search$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_]{3,24}$)/;
/** ?page=N on a channel page (its video list); 1 everywhere else and for anything that is not a number. */
function _pageParam(p, query) {
ddiff --git a/test/docs-render.test.js b/test/docs-render.test.js
iindex c9d5653b..a857a887 100644
---- a/test/docs-render.test.js
++++ b/test/docs-render.test.js
@@@ -64,7 +64,7 @@ assert.ok(blocks.html.includes('<blockquote><p>quoted line one quoted line two</
assert.ok(blocks.html.includes('<th>A</th>') && blocks.html.includes('<td><code>2</code></td>'), 'tables');
assert.ok(blocks.html.includes('<ol><li>first<br>continued</li><li>second</li></ol>'), 'ordered list with continuation line');
assert.ok(blocks.html.includes('<ul><li>bullet</li></ul>'));
--assert.ok(blocks.html.includes('<pre data-lang="bash"><code>curl -X POST &quot;&lt;url&gt;&quot;</code></pre>'), 'fenced code is escaped verbatim');
++assert.ok(blocks.html.includes('<pre data-lang="bash" tabindex="0"><code>curl -X POST &quot;&lt;url&gt;&quot;</code></pre>'), 'fenced code is escaped verbatim, and reachable by keyboard when it scrolls');
console.log('✅ block elements');
// ── Every shipped doc renders cleanly with the anchors people link to ──
@@@ -84,4 +84,14 @@ assert.ok(whip.includes('id="publishing-from-a-browser"'), 'the browser-publishi
assert.ok(whip.includes('href="/whip-publisher.html"'), 'link to the hosted publisher resolves on-site');
console.log('✅ all docs/*.md render without leaking markdown; public anchors present');
++// The page shell (the browser check, WS-Q task 3): one canonical URL on the public origin, and an icon
++// that exists (it pointed at /assets/favicon.ico, which 404s).
++const { _renderPage: renderPage } = require('../server/docs/routes');
++const shell = renderPage({ name: 'whip', title: 'WHIP', html: '<p>x</p>', headings: [] });
++assert.deepStrictEqual(shell.match(/<link rel="canonical"[^>]*>/g), ['<link rel="canonical" href="https://openvibe.live/docs/whip">']);
++assert.ok(renderPage({ name: 'README', title: '', html: '', headings: [] }).includes('<link rel="canonical" href="https://openvibe.live/docs">'), 'the index is /docs');
++assert.ok(shell.includes('<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">'));
++assert.ok(fs.existsSync(path.join(__dirname, '..', 'public', 'assets', 'logo.svg')), 'the icon file exists');
++console.log('✅ page shell: canonical and icon');
++
console.log('\n✅ All docs renderer tests passed');
ddiff --git a/test/seo-ssr.test.js b/test/seo-ssr.test.js
iindex e7bc7f2e..86570407 100644
---- a/test/seo-ssr.test.js
++++ b/test/seo-ssr.test.js
@@@ -209,6 +209,26 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
}
});
++ await check('/chat and /search have their own title and canonical; search results are noindex (browser check, WS-Q task 3)', async () => {
++ const chat = (await get('/chat')).body;
++ assert.deepStrictEqual(canonicalsOf(chat), ['https://openvibe.live/chat']);
++ assert.strictEqual(titleOf(chat), 'Chat | OpenVibe.Live');
++ const search = (await get('/search?q=minecraft')).body;
++ assert.deepStrictEqual(canonicalsOf(search), ['https://openvibe.live/search'], 'every query is the one search page');
++ assert.ok(/<meta name="robots" content="noindex,follow">/.test(head(search)));
++ });
++
++ await check('the home page\'s JSON-LD lists are named by the snapshot\'s visible headings', async () => {
++ const home = (await get('/')).body;
++ const lists = ldOf(home).filter((n) => n['@type'] === 'ItemList');
++ assert.ok(lists.length >= 1, 'at least one list');
++ for (const l of lists) {
++ assert.ok(['Live now', 'Recent VODs', 'Recent clips'].includes(l.name), l.name);
++ assert.ok(bodyOf(home).includes(`<h2>${l.name}</h2>`), `${l.name} is a visible heading`);
++ assert.ok(l.itemListElement.length && l.itemListElement.length <= 10);
++ }
++ });
++
await check('no other shell page claims the home page as canonical; a slot names its channel', async () => {
const dash = (await get('/dashboard')).body;
assert.deepStrictEqual(canonicalsOf(dash), []);