openvibers/openvibe.help Commits

SEO-14: one FAQPage per answer page, from the page's own question and answer (#124)

OP OpenVibers committed 15 days ago · 9ad5f7257b · 2 files, +27 −3

* Help: one FAQPage per answer page, from the page's own question and answer (SEO-14)

* Help: an answer page's JSON-LD is one script, one @graph (SEO-14)

Changes

ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 8110893..fff0f6f 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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>
ddiff --git a/test/help-articles.test.js b/test/help-articles.test.js
iindex d707d3e..0f1097e 100644
---- a/test/help-articles.test.js
++++ b/test/help-articles.test.js
@@@ -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', () => {