openvibers/openvibe.events Commits

Events: the home page's JSON-LD is one script, one @graph (SEO-13) (#148)

OP OpenVibers committed 15 days ago · 9ab91e1bfe · 3 files, +30 −7

Changes

ddiff --git a/server/home.js b/server/home.js
iindex dfe2eea..da207cb 100644
---- a/server/home.js
++++ b/server/home.js
@@@ -26,6 +26,15 @@ const SOURCE = 'https://github.com/OpenVibers/OpenVibe.Events';
const DESCRIPTION = 'The OpenVibe network\'s event bus: publish once and every subscriber gets it signed, retried and replayable, '
+ 'by webhook, pull or realtime stream.';
++/**
++ * 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 }];
++};
++
const HOME_CSP = [
"default-src 'self'",
// Cloudflare Web Analytics: Cloudflare injects its beacon at the edge on this zone; script-src loads it and connect-src
@@@ -326,15 +335,18 @@ function renderHome({ siteUrl, apiUrl, stats = null }) {
title: `${SITE_NAME}: durable events, signed webhooks and replay`,
siteName: SITE_NAME,
description: DESCRIPTION,
-- summary: DESCRIPTION,
canonical: `${siteUrl}/`,
robots: 'index, follow',
-- jsonLd: [
++ jsonLd: oneGraph([
seo.jsonLd.website({ name: SITE_NAME, url: siteUrl, description: DESCRIPTION }),
seo.jsonLd.softwareApp({ name: SITE_NAME, url: `${siteUrl}/`, description: DESCRIPTION, category: 'DeveloperApplication', keywords: ['event bus', 'webhooks', 'server-sent events', 'replay', 'dead-letter queue'] }),
++ // The page itself, in the same graph: `summary` would make shell.js emit it as a second script.
++ { ...seo.jsonLd.webPage({ name: SITE_NAME, url: `${siteUrl}/`, description: DESCRIPTION, siteUrl }), abstract: DESCRIPTION },
seo.jsonLd.faq(FAQ.map(([q, a]) => ({ q, a }))),
eventsJsonLd({ siteUrl, stats }),
-- ].filter(Boolean),
++ // The publisher the site's own WebSite node names by @id, so the reference resolves on the page.
++ seo.jsonLd.organization(),
++ ]),
navbar: nav, footer, home: '/', navLinks: [{ label: 'Guide', href: `${SOURCE}#readme` }],
head: [
appIcon.headTags({ site: 'events' }),
ddiff --git a/test/home-stats.test.js b/test/home-stats.test.js
iindex 72b47c7..c8d7a95 100644
---- a/test/home-stats.test.js
++++ b/test/home-stats.test.js
@@@ -55,7 +55,10 @@ t('published events show as counts by service, public types and the latest publi
assert.ok(!page.includes('live.internal.tick'));
assert.match(page, /<b>4<\/b><span>events this week<\/span>/);
assert.match(page, / 2 OpenVibe services published to it this week\./);
-- const ld = [...page.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 = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ assert.strictEqual(scripts.length, 1, 'the page carries exactly one JSON-LD script');
++ const ld = scripts.flatMap((x) => (Array.isArray(x['@graph']) ? x['@graph'] : [x]));
const list = ld.find((x) => x['@type'] === 'ItemList');
assert.ok(list, 'the public events are an ItemList');
assert.strictEqual(list.numberOfItems, 2);
@@@ -65,7 +68,9 @@ t('published events show as counts by service, public types and the latest publi
t('JSON-LD: WebSite, the app, and a FAQPage with exactly the questions shown', async () => {
const page = await html();
-- const ld = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ const scripts = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ assert.strictEqual(scripts.length, 1, 'the page carries exactly one JSON-LD script');
++ const ld = scripts.flatMap((x) => (Array.isArray(x['@graph']) ? x['@graph'] : [x]));
for (const type of ['WebSite', 'WebApplication', 'FAQPage']) assert.ok(ld.some((x) => x['@type'] === type), type);
const faq = (/<section class="sc-sec" id="faq"[\s\S]*?<\/section>/.exec(page) || [''])[0];
const shown = [...faq.matchAll(/<h3>([^<]+)<\/h3>/g)].map((m) => m[1].replace(/&#39;/g, "'"));
ddiff --git a/test/home.test.js b/test/home.test.js
iindex e81e53b..23f922d 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -99,7 +99,10 @@ t('GET / for a crawler or a plain curl: the server-rendered HTML, with real read
assert.ok(readable(r.body).length > 2000, `${readable(r.body).length} readable chars for ${JSON.stringify(headers)}`);
assert.strictEqual((r.body.match(/<h1\b/g) || []).length, 1, JSON.stringify(headers));
assert.ok(r.body.includes(`<link rel="canonical" href="${SITE}/">`), JSON.stringify(headers));
-- const ld = [...r.body.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.body.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ assert.strictEqual(scripts.length, 1, 'the page carries exactly one JSON-LD script');
++ const ld = scripts.flatMap((x) => (Array.isArray(x['@graph']) ? x['@graph'] : [x]));
assert.ok(ld.some((x) => x['@type'] === 'WebSite'), 'the WebSite node');
}
});
@@@ -175,7 +178,10 @@ t('the server-rendered event list: title, real <time datetime>, place, descripti
assert.match(page, /<nav class="hm-cats"[^>]*><a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a>/, 'the category nav');
assert.match(page, /<code>live\.stream\.started<\/code>/, 'the exact event type');
-- const ld = [...page.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 = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ assert.strictEqual(scripts.length, 1, 'the page carries exactly one JSON-LD script');
++ const ld = scripts.flatMap((x) => (Array.isArray(x['@graph']) ? x['@graph'] : [x]));
assert.ok(ld.some((x) => x['@type'] === 'WebSite'), 'the WebSite node');
const list = ld.find((x) => x['@type'] === 'ItemList');
assert.ok(list, 'an ItemList of the events');