openvibers/openvibe.help Commits
SEO-14: one FAQPage per answer page, from the page's own question and answer (#124)
Changes
@@@ -38,6 +38,15 @@ const SITE_STATE_TEXT = { live: 'live', coming: 'coming' };const textBlock = (t) => html`<p class="txt">${t}</p>`;const truncate = (s, n) => (String(s == null ? '' : s).length > n ? `${String(s).slice(0, n - 1).trimEnd()}…` : String(s == null ? '' : s));++/**++ * The page's JSON-LD in one script: every node in one @graph, so a page carries exactly one JSON-LD script.++ * The nodes keep their own fields; only their own @context goes, because the graph carries it once.++ */++const oneGraph = (nodes) => {++ const graph = nodes.filter(Boolean).map((n) => { const c = { ...n }; delete c['@context']; return c; });++ return [{ '@context': 'https://schema.org', '@graph': graph }];++};++function createPageRoutes(ctx) {const { config, s, catalog, search } = ctx;const r = asyncRouter();@@@ -209,10 +218,12 @@ ${textBlock(a.answer)}title: article.question,description: truncate(article.answer, 160),crumbs: [{ label: 'Home', href: '/' }, { label: 'Sites', href: '/sites' }, { label: article.siteName, href: `/sites/${article.site}` }, { label: truncate(article.question, 60) }],-- jsonLd: [++ jsonLd: oneGraph([seo.jsonLd.article({ headline: article.question, url: `${base()}${article.path}`, description: truncate(article.answer, 160) }),++ // One question, one answer, both verbatim from the manifest: the page's own <h1> and its body.++ seo.jsonLd.faq([{ q: article.question, a: article.answer }]),seo.jsonLd.breadcrumbs([{ name: 'OpenVibe.Help', url: `${base()}/` }, { name: article.siteName, url: `${base()}/sites/${article.site}` }, { name: article.question, url: `${base()}${article.path}` }]),-- ],++ ]),body: html`<article class="answer"><h1>${article.question}</h1><p class="small muted">Answered by <a href="/sites/${article.site}">${article.siteName}</a> ${siteBadge(article.siteState)}</p>@@@ -91,8 +91,17 @@ const WITH_FAQ = [if (entry.question.length <= 60) assert.ok(title.startsWith(esc(entry.question)), `${article.path}: the title is not the question (${title})`);assert.ok(r.text.includes(esc(entry.answer)), `${article.path}: the answer is not the manifest's`);assert.ok(r.text.includes(`<link rel="canonical" href="https://openvibe.help${article.path}">`), `${article.path}: canonical`);-- const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++ // One JSON-LD script per page: every node lives in the page's @graph.++ const scripts = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++ assert.strictEqual(scripts.length, 1, `${article.path}: expected one JSON-LD script`);++ const ld = scripts.flatMap((x) => (Array.isArray(x['@graph']) ? x['@graph'] : [x]));assert.ok(ld.some((n) => n['@type'] === 'Article' && n.headline && n.headline.startsWith(entry.question.slice(0, 40))), `${article.path}: no Article JSON-LD`);++ // One question, one answer: the page's own heading and the manifest's answer, never a second pair.++ const faq = ld.filter((n) => n['@type'] === 'FAQPage');++ assert.strictEqual(faq.length, 1, `${article.path}: expected one FAQPage`);++ assert.strictEqual(faq[0].mainEntity.length, 1, `${article.path}: one question per page`);++ assert.strictEqual(faq[0].mainEntity[0].name, entry.question, `${article.path}: the FAQPage question is not the page's`);++ assert.strictEqual(faq[0].mainEntity[0].acceptedAnswer.text, entry.answer, `${article.path}: the FAQPage answer is not the page's`);articles++;}const page = await t.get(`/sites/${domain}`);@@@ -101,6 +110,10 @@ const WITH_FAQ = [}}assert.ok(articles >= 20, `only ${articles} question pages`);++ // A page that answers nothing carries no FAQPage.++ const missing = await t.get('/a/openvibe.blog/this-question-does-not-exist');++ assert.strictEqual(missing.status, 404);++ assert.ok(!missing.text.includes('"@type":"FAQPage"'), 'a page without an answer has no FAQPage');});await check('a service manifest\'s own site faq is used, not only a product manifest\'s', () => {
@@ -38,6 +38,15 @@ const SITE_STATE_TEXT = { live: 'live', coming: 'coming' };
@@ -38,6 +38,15 @@ const SITE_STATE_TEXT = { live: 'live', coming: 'coming' };
const textBlock = (t) => html`<p class="txt">${t}</p>`;
const textBlock = (t) => html`<p class="txt">${t}</p>`;
const truncate = (s, n) => (String(s == null ? '' : s).length > n ? `${String(s).slice(0, n - 1).trimEnd()}…` : String(s == null ? '' : s));
const truncate = (s, n) => (String(s == null ? '' : s).length > n ? `${String(s).slice(0, n - 1).trimEnd()}…` : String(s == null ? '' : s));
+/**
+ * The page's JSON-LD in one script: every node in one @graph, so a page carries exactly one JSON-LD script.
+ * The nodes keep their own fields; only their own @context goes, because the graph carries it once.
+ */
+const oneGraph = (nodes) => {
+ const graph = nodes.filter(Boolean).map((n) => { const c = { ...n }; delete c['@context']; return c; });
+ return [{ '@context': 'https://schema.org', '@graph': graph }];
+};
+
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
const { config, s, catalog, search } = ctx;
const { config, s, catalog, search } = ctx;
const r = asyncRouter();
const r = asyncRouter();
@@ -209,10 +218,12 @@ ${textBlock(a.answer)}
@@ -209,10 +218,12 @@ ${textBlock(a.answer)}
title: article.question,
title: article.question,
description: truncate(article.answer, 160),
description: truncate(article.answer, 160),
crumbs: [{ label: 'Home', href: '/' }, { label: 'Sites', href: '/sites' }, { label: article.siteName, href: `/sites/${article.site}` }, { label: truncate(article.question, 60) }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Sites', href: '/sites' }, { label: article.siteName, href: `/sites/${article.site}` }, { label: truncate(article.question, 60) }],
- jsonLd: [
+ jsonLd: oneGraph([
seo.jsonLd.article({ headline: article.question, url: `${base()}${article.path}`, description: truncate(article.answer, 160) }),
seo.jsonLd.article({ headline: article.question, url: `${base()}${article.path}`, description: truncate(article.answer, 160) }),
+ // One question, one answer, both verbatim from the manifest: the page's own <h1> and its body.
+ seo.jsonLd.faq([{ q: article.question, a: article.answer }]),
seo.jsonLd.breadcrumbs([{ name: 'OpenVibe.Help', url: `${base()}/` }, { name: article.siteName, url: `${base()}/sites/${article.site}` }, { name: article.question, url: `${base()}${article.path}` }]),
seo.jsonLd.breadcrumbs([{ name: 'OpenVibe.Help', url: `${base()}/` }, { name: article.siteName, url: `${base()}/sites/${article.site}` }, { name: article.question, url: `${base()}${article.path}` }]),
- ],
+ ]),
body: html`<article class="answer">
body: html`<article class="answer">
<h1>${article.question}</h1>
<h1>${article.question}</h1>
<p class="small muted">Answered by <a href="/sites/${article.site}">${article.siteName}</a> ${siteBadge(article.siteState)}</p>
<p class="small muted">Answered by <a href="/sites/${article.site}">${article.siteName}</a> ${siteBadge(article.siteState)}</p>
@@ -91,8 +91,17 @@ const WITH_FAQ = [
@@ -91,8 +91,17 @@ const WITH_FAQ = [
if (entry.question.length <= 60) assert.ok(title.startsWith(esc(entry.question)), `${article.path}: the title is not the question (${title})`);
if (entry.question.length <= 60) assert.ok(title.startsWith(esc(entry.question)), `${article.path}: the title is not the question (${title})`);
assert.ok(r.text.includes(esc(entry.answer)), `${article.path}: the answer is not the manifest's`);
assert.ok(r.text.includes(esc(entry.answer)), `${article.path}: the answer is not the manifest's`);
assert.ok(r.text.includes(`<link rel="canonical" href="https://openvibe.help${article.path}">`), `${article.path}: canonical`);
assert.ok(r.text.includes(`<link rel="canonical" href="https://openvibe.help${article.path}">`), `${article.path}: canonical`);
- const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+ // One JSON-LD script per page: every node lives in the page's @graph.
+ const scripts = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+ assert.strictEqual(scripts.length, 1, `${article.path}: expected one JSON-LD script`);
+ const ld = scripts.flatMap((x) => (Array.isArray(x['@graph']) ? x['@graph'] : [x]));
assert.ok(ld.some((n) => n['@type'] === 'Article' && n.headline && n.headline.startsWith(entry.question.slice(0, 40))), `${article.path}: no Article JSON-LD`);
assert.ok(ld.some((n) => n['@type'] === 'Article' && n.headline && n.headline.startsWith(entry.question.slice(0, 40))), `${article.path}: no Article JSON-LD`);
+ // One question, one answer: the page's own heading and the manifest's answer, never a second pair.
+ const faq = ld.filter((n) => n['@type'] === 'FAQPage');
+ assert.strictEqual(faq.length, 1, `${article.path}: expected one FAQPage`);
+ assert.strictEqual(faq[0].mainEntity.length, 1, `${article.path}: one question per page`);
+ assert.strictEqual(faq[0].mainEntity[0].name, entry.question, `${article.path}: the FAQPage question is not the page's`);
+ assert.strictEqual(faq[0].mainEntity[0].acceptedAnswer.text, entry.answer, `${article.path}: the FAQPage answer is not the page's`);
articles++;
articles++;
}
}
const page = await t.get(`/sites/${domain}`);
const page = await t.get(`/sites/${domain}`);
@@ -101,6 +110,10 @@ const WITH_FAQ = [
@@ -101,6 +110,10 @@ const WITH_FAQ = [
}
}
}
}
assert.ok(articles >= 20, `only ${articles} question pages`);
assert.ok(articles >= 20, `only ${articles} question pages`);
+ // A page that answers nothing carries no FAQPage.
+ const missing = await t.get('/a/openvibe.blog/this-question-does-not-exist');
+ assert.strictEqual(missing.status, 404);
+ assert.ok(!missing.text.includes('"@type":"FAQPage"'), 'a page without an answer has no FAQPage');
});
});
await check('a service manifest\'s own site faq is used, not only a product manifest\'s', () => {
await check('a service manifest\'s own site faq is used, not only a product manifest\'s', () => {