openvibers/openvibe.live Commits
SEO-29: noindex pastes and send X-Robots-Tag on noindex pages (#441)
Changes
@@@ -17,6 +17,7 @@'use strict';const seo = require('openvibe-shared/seo');const { esc } = seo;++const { setRobotsHeader } = require('../web/robots-header');const db = require('../db/database');const media = require('../media-client');let config = null; try { config = require('../config'); } catch { /* */ }@@@ -618,8 +619,9 @@ function _authorLd(name) { return name ? { '@type': 'Person', name: clean(name,// as AI-made, attributed to no person, kept out of search (noindex,follow: the page still passes// its links on) and made canonical to its source: the VOD at the moment it came from, when that// VOD is known and public. The store decides what is AI (Media's auto_generated, Community's--// origin, the recap's own ai flag); nothing is inferred here. People's clips and pastes keep their--// normal, indexable pages.++// origin, the recap's own ai flag); nothing is inferred here. People's clips keep their normal,++// indexable pages; pastes are link-shared and noindex (SEO-29), and their canonical page is++// OpenVibe.Community's.const AI_ROBOTS = 'noindex,follow';const AI_KEYWORDS = 'AI-generated';const VOD_MOMENT_RE = /^\/vod\/(\d+)(?:\?t=(\d+(?:\.\d+)?))?$/;@@@ -656,8 +658,9 @@ async function _pasteMeta(slug) {if (!p) return null;if (isAiPaste(p)) return await _aiPasteMeta(p, slug);const isScreenshot = p.type === 'screenshot';-- // Only public, non-NSFW, non-burn pastes are indexable.-- const indexable = (p.visibility === 'public' || p.visibility == null) && !Number(p.is_nsfw) && !Number(p.burn_after_read);++ // A paste is link-shared content, not a page meant to be found (SEO-29): noindex whether or++ // not it is public. In production /p/:slug 301s to OpenVibe.Community before this runs; this++ // keeps the answer right if the mount order ever changes.const author = p.display_name || p.username;const title = clean(p.title || (isScreenshot ? 'Screenshot' : 'Paste'), 80);const desc = clean(p.ai_summary || (isScreenshot ? `A screenshot shared on ${SITE_NAME}.` : String(p.content || '').slice(0, 220)) || `A paste on ${SITE_NAME}.`, 200);@@@ -676,7 +679,7 @@ async function _pasteMeta(slug) {});return {title: `${title} — paste | ${SITE_NAME}`, description: desc,-- canonicalPath, image, ogType: isScreenshot ? 'article' : 'article', robots: indexable ? 'index,follow' : 'noindex,follow',++ canonicalPath, image, ogType: isScreenshot ? 'article' : 'article', robots: 'noindex',jsonLd: [ld, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: canonicalPath }])],snapshot,};@@@ -946,6 +949,7 @@ async function middleware(req, res, next) {// This renderer and the SPA fallback's status check share one wait on upstreams (page-status.js).req.ovLookupDeadlineAt = Date.now() + pageStatus.LOOKUP_DEADLINE_MS;const send = (html, ttl) => {++ setRobotsHeader(res, html);res.set('Cache-Control', `public, max-age=${Math.max(0, Math.round(ttl / 1000))}, must-revalidate`);res.set('Link', seo.linkHeaderTags(abs(key), `${baseUrl()}/llms.txt`).header);res.set('Vary', 'Accept');nnew file mode 100644@@@ -0,0 +1,28 @@++'use strict';++/**++ * Copy a page's noindex rule into an X-Robots-Tag response header.++ *++ * Google's 2026-10-26 UGC guidance asks a platform to keep link-shared pages out of Search with++ * "a robots meta tag or X-Robots-Tag HTTP response header"; sending both means a client that never++ * parses the head (or a proxy that strips it) still sees the rule. The value mirrors the meta tag,++ * so the page and the response never disagree.++ */++const ROBOTS_TAG_RE = /<meta[^>]*\bname=["']robots["'][^>]*>/i;++const CONTENT_RE = /\bcontent=["']([^"']*)["']/i;++const NOINDEX_RE = /(?:^|[\s,;])noindex(?:[\s,;]|$)/i;++++/** The page's robots content when it says noindex, else null. */++function noindexOf(html) {++ const tag = ROBOTS_TAG_RE.exec(String(html || ''));++ const content = tag && CONTENT_RE.exec(tag[0]);++ return content && NOINDEX_RE.test(content[1]) ? content[1].replace(/\s+/g, '') : null;++}++++/** Set X-Robots-Tag on `res` when `html` carries a noindex robots meta tag. */++function setRobotsHeader(res, html) {++ const robots = noindexOf(html);++ if (robots) res.set('X-Robots-Tag', robots);++ return robots;++}++++module.exports = { setRobotsHeader, noindexOf };@@@ -1,4 +1,5 @@'use strict';++const { setRobotsHeader } = require('./robots-header');/*** sendShell for the SPA fallback (server/web/page-status.js spaFallback): the page shell from seo.shellHtml for a URL,@@@ -15,6 +16,7 @@ function createSendShell({ shellHtml, sendDocument, assets }) {try { html = shellHtml ? await shellHtml(urlPath, res.statusCode) : null; } catch { html = null; }if (typeof html !== 'string' || !html) return sendDocument(res, 'index.html', urlPath);if (!res.getHeader('Cache-Control')) assets.setNoCache(res);++ setRobotsHeader(res, html);try { res.setHeader('Content-Security-Policy-Report-Only', assets.cspReportOnly(html)); } catch { /* the header is optional */ }res.type('html').send(html);return true;@@@ -126,9 +126,9 @@ console.log('AI Moments pages');assertNoPerson(m, 'live AI paste');});-- await check("a person's paste stays indexable and credited", async () => {++ await check("a person's paste is still credited but noindex (link-shared, canonical at Community)", async () => {const m = await seo._pageMeta('/p/human');-- assert.strictEqual(m.robots, 'index,follow');++ assert.strictEqual(m.robots, 'noindex');assert.strictEqual(m.canonicalPath, '/p/human');assert.deepStrictEqual(mainLd(m).author, { '@type': 'Person', name: 'Bob' });});@@@ -82,12 +82,12 @@ const pageStatus = require('../server/web/page-status');const app = express();require('../server/web/video-handover').register(app); // before SEO, as server/index.js mounts itseo.register(app);--app.get('*', pageStatus.spaFallback(async (res, urlPath) => {-- const html = await seo.shellHtml(urlPath, res.statusCode);-- if (!html) return false;-- res.type('html').send(html);-- return true;--}));++const sendShell = require('../server/web/shell').createSendShell({++ shellHtml: seo.shellHtml,++ sendDocument: async () => false,++ assets: { setNoCache: (res) => res.setHeader('Cache-Control', 'no-cache'), cspReportOnly: () => '' },++});++app.get('*', pageStatus.spaFallback((res, urlPath) => sendShell(res, urlPath)));let base;function get(p, headers = {}) {@@@ -143,6 +143,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {assert.ok(r.body.includes('<meta property="og:url" content="https://openvibe.live/@alice">'), c.name);assert.ok(r.body.includes('<meta property="og:type" content="profile">'), c.name);assert.deepStrictEqual(robotsOf(r.body), ['index,follow'], c.name);++ assert.strictEqual(r.headers['x-robots-tag'], undefined, `${c.name}: an indexable page sends no X-Robots-Tag`);assert.ok(ldOf(r.body).some((n) => n['@type'] === 'ProfilePage' && n.mainEntity.name === 'Alice'), c.name);}});@@@ -184,8 +185,10 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {assert.ok(!b2.includes('<h2>AI Moments</h2>'), 'clips are on page 1 only');const b3 = bodyOf((await get('/@alice?page=3')).body);assert.ok(b3.includes('/v/129"') && !b3.includes('rel="next"'), 'the last page has no next link');-- const past = (await get('/@alice?page=9')).body;++ const pastRes = await get('/@alice?page=9');++ const past = pastRes.body;assert.deepStrictEqual(robotsOf(past), ['noindex,follow']);++ assert.strictEqual(pastRes.headers['x-robots-tag'], 'noindex,follow');assert.deepStrictEqual(canonicalsOf(past), ['https://openvibe.live/@alice']);const junk = (await get('/@alice?page=abc')).body;assert.deepStrictEqual(canonicalsOf(junk), ['https://openvibe.live/@alice'], 'a non-number is page 1');@@@ -210,6 +213,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {assert.strictEqual(r.status, 404, `${p} ${c.name}`);assert.deepStrictEqual(canonicalsOf(r.body), [], `${p}: no canonical`);assert.deepStrictEqual(robotsOf(r.body), ['noindex'], `${p}: noindex`);++ assert.strictEqual(r.headers['x-robots-tag'], 'noindex', `${p}: X-Robots-Tag`);assert.strictEqual(titleOf(r.body), 'Page not found — OpenVibe.Live', p);assert.ok(!/og:url|og:title|application\/ld\+json/.test(head(r.body)), `${p}: home OG/JSON-LD left in`);assert.ok(bodyOf(r.body).includes('<h1>Page not found</h1>'), `${p}: a not-found body`);@@@ -225,6 +229,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {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)));++ assert.strictEqual((await get('/search?q=minecraft')).headers['x-robots-tag'], 'noindex,follow');});await check('/documentation and /updates have their own title, description and canonical (they repeated the home page\'s)', async () => {
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
'use strict';
'use strict';
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const { esc } = seo;
const { esc } = seo;
+const { setRobotsHeader } = require('../web/robots-header');
const db = require('../db/database');
const db = require('../db/database');
const media = require('../media-client');
const media = require('../media-client');
let config = null; try { config = require('../config'); } catch { /* */ }
let config = null; try { config = require('../config'); } catch { /* */ }
@@ -618,8 +619,9 @@ function _authorLd(name) { return name ? { '@type': 'Person', name: clean(name,
@@ -618,8 +619,9 @@ function _authorLd(name) { return name ? { '@type': 'Person', name: clean(name,
// as AI-made, attributed to no person, kept out of search (noindex,follow: the page still passes
// as AI-made, attributed to no person, kept out of search (noindex,follow: the page still passes
// its links on) and made canonical to its source: the VOD at the moment it came from, when that
// its links on) and made canonical to its source: the VOD at the moment it came from, when that
// VOD is known and public. The store decides what is AI (Media's auto_generated, Community's
// VOD is known and public. The store decides what is AI (Media's auto_generated, Community's
-// origin, the recap's own ai flag); nothing is inferred here. People's clips and pastes keep their
-// normal, indexable pages.
+// origin, the recap's own ai flag); nothing is inferred here. People's clips keep their normal,
+// indexable pages; pastes are link-shared and noindex (SEO-29), and their canonical page is
+// OpenVibe.Community's.
const AI_ROBOTS = 'noindex,follow';
const AI_ROBOTS = 'noindex,follow';
const AI_KEYWORDS = 'AI-generated';
const AI_KEYWORDS = 'AI-generated';
const VOD_MOMENT_RE = /^\/vod\/(\d+)(?:\?t=(\d+(?:\.\d+)?))?$/;
const VOD_MOMENT_RE = /^\/vod\/(\d+)(?:\?t=(\d+(?:\.\d+)?))?$/;
@@ -656,8 +658,9 @@ async function _pasteMeta(slug) {
@@ -656,8 +658,9 @@ async function _pasteMeta(slug) {
if (!p) return null;
if (!p) return null;
if (isAiPaste(p)) return await _aiPasteMeta(p, slug);
if (isAiPaste(p)) return await _aiPasteMeta(p, slug);
const isScreenshot = p.type === 'screenshot';
const isScreenshot = p.type === 'screenshot';
- // Only public, non-NSFW, non-burn pastes are indexable.
- const indexable = (p.visibility === 'public' || p.visibility == null) && !Number(p.is_nsfw) && !Number(p.burn_after_read);
+ // A paste is link-shared content, not a page meant to be found (SEO-29): noindex whether or
+ // not it is public. In production /p/:slug 301s to OpenVibe.Community before this runs; this
+ // keeps the answer right if the mount order ever changes.
const author = p.display_name || p.username;
const author = p.display_name || p.username;
const title = clean(p.title || (isScreenshot ? 'Screenshot' : 'Paste'), 80);
const title = clean(p.title || (isScreenshot ? 'Screenshot' : 'Paste'), 80);
const desc = clean(p.ai_summary || (isScreenshot ? `A screenshot shared on ${SITE_NAME}.` : String(p.content || '').slice(0, 220)) || `A paste on ${SITE_NAME}.`, 200);
const desc = clean(p.ai_summary || (isScreenshot ? `A screenshot shared on ${SITE_NAME}.` : String(p.content || '').slice(0, 220)) || `A paste on ${SITE_NAME}.`, 200);
@@ -676,7 +679,7 @@ async function _pasteMeta(slug) {
@@ -676,7 +679,7 @@ async function _pasteMeta(slug) {
});
});
return {
return {
title: `${title} — paste | ${SITE_NAME}`, description: desc,
title: `${title} — paste | ${SITE_NAME}`, description: desc,
- canonicalPath, image, ogType: isScreenshot ? 'article' : 'article', robots: indexable ? 'index,follow' : 'noindex,follow',
+ canonicalPath, image, ogType: isScreenshot ? 'article' : 'article', robots: 'noindex',
jsonLd: [ld, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: canonicalPath }])],
jsonLd: [ld, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: canonicalPath }])],
snapshot,
snapshot,
};
};
@@ -946,6 +949,7 @@ async function middleware(req, res, next) {
@@ -946,6 +949,7 @@ async function middleware(req, res, next) {
// This renderer and the SPA fallback's status check share one wait on upstreams (page-status.js).
// This renderer and the SPA fallback's status check share one wait on upstreams (page-status.js).
req.ovLookupDeadlineAt = Date.now() + pageStatus.LOOKUP_DEADLINE_MS;
req.ovLookupDeadlineAt = Date.now() + pageStatus.LOOKUP_DEADLINE_MS;
const send = (html, ttl) => {
const send = (html, ttl) => {
+ setRobotsHeader(res, html);
res.set('Cache-Control', `public, max-age=${Math.max(0, Math.round(ttl / 1000))}, must-revalidate`);
res.set('Cache-Control', `public, max-age=${Math.max(0, Math.round(ttl / 1000))}, must-revalidate`);
res.set('Link', seo.linkHeaderTags(abs(key), `${baseUrl()}/llms.txt`).header);
res.set('Link', seo.linkHeaderTags(abs(key), `${baseUrl()}/llms.txt`).header);
res.set('Vary', 'Accept');
res.set('Vary', 'Accept');
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+'use strict';
+/**
+ * Copy a page's noindex rule into an X-Robots-Tag response header.
+ *
+ * Google's 2026-10-26 UGC guidance asks a platform to keep link-shared pages out of Search with
+ * "a robots meta tag or X-Robots-Tag HTTP response header"; sending both means a client that never
+ * parses the head (or a proxy that strips it) still sees the rule. The value mirrors the meta tag,
+ * so the page and the response never disagree.
+ */
+const ROBOTS_TAG_RE = /<meta[^>]*\bname=["']robots["'][^>]*>/i;
+const CONTENT_RE = /\bcontent=["']([^"']*)["']/i;
+const NOINDEX_RE = /(?:^|[\s,;])noindex(?:[\s,;]|$)/i;
+
+/** The page's robots content when it says noindex, else null. */
+function noindexOf(html) {
+ const tag = ROBOTS_TAG_RE.exec(String(html || ''));
+ const content = tag && CONTENT_RE.exec(tag[0]);
+ return content && NOINDEX_RE.test(content[1]) ? content[1].replace(/\s+/g, '') : null;
+}
+
+/** Set X-Robots-Tag on `res` when `html` carries a noindex robots meta tag. */
+function setRobotsHeader(res, html) {
+ const robots = noindexOf(html);
+ if (robots) res.set('X-Robots-Tag', robots);
+ return robots;
+}
+
+module.exports = { setRobotsHeader, noindexOf };
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const { setRobotsHeader } = require('./robots-header');
/**
/**
* sendShell for the SPA fallback (server/web/page-status.js spaFallback): the page shell from seo.shellHtml for a URL,
* sendShell for the SPA fallback (server/web/page-status.js spaFallback): the page shell from seo.shellHtml for a URL,
@@ -15,6 +16,7 @@ function createSendShell({ shellHtml, sendDocument, assets }) {
@@ -15,6 +16,7 @@ function createSendShell({ shellHtml, sendDocument, assets }) {
try { html = shellHtml ? await shellHtml(urlPath, res.statusCode) : null; } catch { html = null; }
try { html = shellHtml ? await shellHtml(urlPath, res.statusCode) : null; } catch { html = null; }
if (typeof html !== 'string' || !html) return sendDocument(res, 'index.html', urlPath);
if (typeof html !== 'string' || !html) return sendDocument(res, 'index.html', urlPath);
if (!res.getHeader('Cache-Control')) assets.setNoCache(res);
if (!res.getHeader('Cache-Control')) assets.setNoCache(res);
+ setRobotsHeader(res, html);
try { res.setHeader('Content-Security-Policy-Report-Only', assets.cspReportOnly(html)); } catch { /* the header is optional */ }
try { res.setHeader('Content-Security-Policy-Report-Only', assets.cspReportOnly(html)); } catch { /* the header is optional */ }
res.type('html').send(html);
res.type('html').send(html);
return true;
return true;
@@ -126,9 +126,9 @@ console.log('AI Moments pages');
@@ -126,9 +126,9 @@ console.log('AI Moments pages');
assertNoPerson(m, 'live AI paste');
assertNoPerson(m, 'live AI paste');
});
});
- await check("a person's paste stays indexable and credited", async () => {
+ await check("a person's paste is still credited but noindex (link-shared, canonical at Community)", async () => {
const m = await seo._pageMeta('/p/human');
const m = await seo._pageMeta('/p/human');
- assert.strictEqual(m.robots, 'index,follow');
+ assert.strictEqual(m.robots, 'noindex');
assert.strictEqual(m.canonicalPath, '/p/human');
assert.strictEqual(m.canonicalPath, '/p/human');
assert.deepStrictEqual(mainLd(m).author, { '@type': 'Person', name: 'Bob' });
assert.deepStrictEqual(mainLd(m).author, { '@type': 'Person', name: 'Bob' });
});
});
@@ -82,12 +82,12 @@ const pageStatus = require('../server/web/page-status');
@@ -82,12 +82,12 @@ const pageStatus = require('../server/web/page-status');
const app = express();
const app = express();
require('../server/web/video-handover').register(app); // before SEO, as server/index.js mounts it
require('../server/web/video-handover').register(app); // before SEO, as server/index.js mounts it
seo.register(app);
seo.register(app);
-app.get('*', pageStatus.spaFallback(async (res, urlPath) => {
- const html = await seo.shellHtml(urlPath, res.statusCode);
- if (!html) return false;
- res.type('html').send(html);
- return true;
-}));
+const sendShell = require('../server/web/shell').createSendShell({
+ shellHtml: seo.shellHtml,
+ sendDocument: async () => false,
+ assets: { setNoCache: (res) => res.setHeader('Cache-Control', 'no-cache'), cspReportOnly: () => '' },
+});
+app.get('*', pageStatus.spaFallback((res, urlPath) => sendShell(res, urlPath)));
let base;
let base;
function get(p, headers = {}) {
function get(p, headers = {}) {
@@ -143,6 +143,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -143,6 +143,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.ok(r.body.includes('<meta property="og:url" content="https://openvibe.live/@alice">'), c.name);
assert.ok(r.body.includes('<meta property="og:url" content="https://openvibe.live/@alice">'), c.name);
assert.ok(r.body.includes('<meta property="og:type" content="profile">'), c.name);
assert.ok(r.body.includes('<meta property="og:type" content="profile">'), c.name);
assert.deepStrictEqual(robotsOf(r.body), ['index,follow'], c.name);
assert.deepStrictEqual(robotsOf(r.body), ['index,follow'], c.name);
+ assert.strictEqual(r.headers['x-robots-tag'], undefined, `${c.name}: an indexable page sends no X-Robots-Tag`);
assert.ok(ldOf(r.body).some((n) => n['@type'] === 'ProfilePage' && n.mainEntity.name === 'Alice'), c.name);
assert.ok(ldOf(r.body).some((n) => n['@type'] === 'ProfilePage' && n.mainEntity.name === 'Alice'), c.name);
}
}
});
});
@@ -184,8 +185,10 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -184,8 +185,10 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.ok(!b2.includes('<h2>AI Moments</h2>'), 'clips are on page 1 only');
assert.ok(!b2.includes('<h2>AI Moments</h2>'), 'clips are on page 1 only');
const b3 = bodyOf((await get('/@alice?page=3')).body);
const b3 = bodyOf((await get('/@alice?page=3')).body);
assert.ok(b3.includes('/v/129"') && !b3.includes('rel="next"'), 'the last page has no next link');
assert.ok(b3.includes('/v/129"') && !b3.includes('rel="next"'), 'the last page has no next link');
- const past = (await get('/@alice?page=9')).body;
+ const pastRes = await get('/@alice?page=9');
+ const past = pastRes.body;
assert.deepStrictEqual(robotsOf(past), ['noindex,follow']);
assert.deepStrictEqual(robotsOf(past), ['noindex,follow']);
+ assert.strictEqual(pastRes.headers['x-robots-tag'], 'noindex,follow');
assert.deepStrictEqual(canonicalsOf(past), ['https://openvibe.live/@alice']);
assert.deepStrictEqual(canonicalsOf(past), ['https://openvibe.live/@alice']);
const junk = (await get('/@alice?page=abc')).body;
const junk = (await get('/@alice?page=abc')).body;
assert.deepStrictEqual(canonicalsOf(junk), ['https://openvibe.live/@alice'], 'a non-number is page 1');
assert.deepStrictEqual(canonicalsOf(junk), ['https://openvibe.live/@alice'], 'a non-number is page 1');
@@ -210,6 +213,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -210,6 +213,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.strictEqual(r.status, 404, `${p} ${c.name}`);
assert.strictEqual(r.status, 404, `${p} ${c.name}`);
assert.deepStrictEqual(canonicalsOf(r.body), [], `${p}: no canonical`);
assert.deepStrictEqual(canonicalsOf(r.body), [], `${p}: no canonical`);
assert.deepStrictEqual(robotsOf(r.body), ['noindex'], `${p}: noindex`);
assert.deepStrictEqual(robotsOf(r.body), ['noindex'], `${p}: noindex`);
+ assert.strictEqual(r.headers['x-robots-tag'], 'noindex', `${p}: X-Robots-Tag`);
assert.strictEqual(titleOf(r.body), 'Page not found — OpenVibe.Live', p);
assert.strictEqual(titleOf(r.body), 'Page not found — OpenVibe.Live', p);
assert.ok(!/og:url|og:title|application\/ld\+json/.test(head(r.body)), `${p}: home OG/JSON-LD left in`);
assert.ok(!/og:url|og:title|application\/ld\+json/.test(head(r.body)), `${p}: home OG/JSON-LD left in`);
assert.ok(bodyOf(r.body).includes('<h1>Page not found</h1>'), `${p}: a not-found body`);
assert.ok(bodyOf(r.body).includes('<h1>Page not found</h1>'), `${p}: a not-found body`);
@@ -225,6 +229,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -225,6 +229,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
const search = (await get('/search?q=minecraft')).body;
const search = (await get('/search?q=minecraft')).body;
assert.deepStrictEqual(canonicalsOf(search), ['https://openvibe.live/search'], 'every query is the one search page');
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)));
assert.ok(/<meta name="robots" content="noindex,follow">/.test(head(search)));
+ assert.strictEqual((await get('/search?q=minecraft')).headers['x-robots-tag'], 'noindex,follow');
});
});
await check('/documentation and /updates have their own title, description and canonical (they repeated the home page\'s)', async () => {
await check('/documentation and /updates have their own title, description and canonical (they repeated the home page\'s)', async () => {