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
Changes
@@@ -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;@@@ -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) {@@@ -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 "<url>"</code></pre>'), 'fenced code is escaped verbatim');++assert.ok(blocks.html.includes('<pre data-lang="bash" tabindex="0"><code>curl -X POST "<url>"</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-publishiassert.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');@@@ -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), []);
@@ -123,7 +123,8 @@ function renderMarkdown(markdown) {
@@ -123,7 +123,8 @@ function renderMarkdown(markdown) {
i++;
i++;
while (i < lines.length && !/^```\s*$/.test(lines[i])) code.push(lines[i++]);
while (i < lines.length && !/^```\s*$/.test(lines[i])) code.push(lines[i++]);
i++; // closing fence
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;
continue;
}
}
@@ -215,6 +216,14 @@ function renderMarkdown(markdown) {
@@ -215,6 +216,14 @@ function renderMarkdown(markdown) {
// ── Page shell ───────────────────────────────────────────────
// ── 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 }) {
function renderPage({ name, title, html, headings }) {
const pageTitle = title ? `${title} — OpenVibe.Live Docs` : 'OpenVibe.Live Docs';
const pageTitle = title ? `${title} — OpenVibe.Live Docs` : 'OpenVibe.Live Docs';
const toc = headings.length
const toc = headings.length
@@ -226,7 +235,8 @@ function renderPage({ name, title, html, headings }) {
@@ -226,7 +235,8 @@ function renderPage({ name, title, html, headings }) {
<meta charset="UTF-8">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(pageTitle)}</title>
<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>
<style>
:root { --bg: #0d0d0f; --bg-2: #16161a; --bg-3: #1e1e24; --text: #e8e6e3; --text-2: #9a9a9a; --muted: #666;
:root { --bg: #0d0d0f; --bg-2: #16161a; --bg-3: #1e1e24; --text: #e8e6e3; --text-2: #9a9a9a; --muted: #666;
--accent: #8b5cf6; --accent-light: #a78bfa; --border: #2a2a32; }
--accent: #8b5cf6; --accent-light: #a78bfa; --border: #2a2a32; }
@@ -250,6 +260,7 @@ function renderPage({ name, title, html, headings }) {
@@ -250,6 +260,7 @@ function renderPage({ name, title, html, headings }) {
li { margin: 0.25em 0; }
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; }
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 { 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 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; }
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; }
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;
@@ -309,3 +320,4 @@ module.exports = router;
module.exports._renderMarkdown = renderMarkdown;
module.exports._renderMarkdown = renderMarkdown;
module.exports._rewriteHref = rewriteHref;
module.exports._rewriteHref = rewriteHref;
module.exports._slugify = slugify;
module.exports._slugify = slugify;
+module.exports._renderPage = renderPage;
@@ -230,6 +230,8 @@ async function _pageMeta(routePath, { page = 1 } = {}) {
@@ -230,6 +230,8 @@ async function _pageMeta(routePath, { page = 1 } = {}) {
if (p === '/arena') return _arenaMeta();
if (p === '/arena') return _arenaMeta();
// Search results (public/js/app-search.js) are never indexed; the page itself is findable.
// 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: [] };
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;
return null;
void bu;
void bu;
@@ -481,9 +483,11 @@ async function _homeMeta() {
@@ -481,9 +483,11 @@ async function _homeMeta() {
_mediaSection('Recent clips', clipItems) +
_mediaSection('Recent clips', clipItems) +
_mediaSection('Recent pastes', pasteItems);
_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 = [
const jsonLd = [
{
{
'@context': 'https://schema.org', '@type': 'WebSite',
'@context': 'https://schema.org', '@type': 'WebSite',
@@ -503,7 +507,7 @@ async function _homeMeta() {
@@ -503,7 +507,7 @@ async function _homeMeta() {
'https://openvibe.network',
'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 };
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) {
@@ -982,7 +986,7 @@ function _cacheSet(key, html, ttl = CACHE_TTL_MS) {
_cache.set(key, { html, at: Date.now(), ttl });
_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. */
/** ?page=N on a channel page (its video list); 1 everywhere else and for anything that is not a number. */
function _pageParam(p, query) {
function _pageParam(p, query) {
@@ -64,7 +64,7 @@ assert.ok(blocks.html.includes('<blockquote><p>quoted line one quoted line two</
@@ -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('<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('<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('<ul><li>bullet</li></ul>'));
-assert.ok(blocks.html.includes('<pre data-lang="bash"><code>curl -X POST "<url>"</code></pre>'), 'fenced code is escaped verbatim');
+assert.ok(blocks.html.includes('<pre data-lang="bash" tabindex="0"><code>curl -X POST "<url>"</code></pre>'), 'fenced code is escaped verbatim, and reachable by keyboard when it scrolls');
console.log('✅ block elements');
console.log('✅ block elements');
// ── Every shipped doc renders cleanly with the anchors people link to ──
// ── 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
@@ -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');
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');
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');
console.log('\n✅ All docs renderer tests passed');
@@ -209,6 +209,26 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -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 () => {
await check('no other shell page claims the home page as canonical; a slot names its channel', async () => {
const dash = (await get('/dashboard')).body;
const dash = (await get('/dashboard')).body;
assert.deepStrictEqual(canonicalsOf(dash), []);
assert.deepStrictEqual(canonicalsOf(dash), []);