openvibers/openvibe.events Commits
Events: the home page's JSON-LD is one script, one @graph (SEO-13) (#148)
Changes
@@@ -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' }),@@@ -55,7 +55,10 @@ t('published events show as counts by service, public types and the latest publiassert.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 publit('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(/'/g, "'"));@@@ -99,7 +99,10 @@ t('GET / for a crawler or a plain curl: the server-rendered HTML, with real readassert.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, descriptiassert.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');
@@ -26,6 +26,15 @@ const SOURCE = 'https://github.com/OpenVibers/OpenVibe.Events';
@@ -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, '
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.';
+ '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 = [
const HOME_CSP = [
"default-src 'self'",
"default-src 'self'",
// Cloudflare Web Analytics: Cloudflare injects its beacon at the edge on this zone; script-src loads it and connect-src
// 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 }) {
@@ -326,15 +335,18 @@ function renderHome({ siteUrl, apiUrl, stats = null }) {
title: `${SITE_NAME}: durable events, signed webhooks and replay`,
title: `${SITE_NAME}: durable events, signed webhooks and replay`,
siteName: SITE_NAME,
siteName: SITE_NAME,
description: DESCRIPTION,
description: DESCRIPTION,
- summary: DESCRIPTION,
canonical: `${siteUrl}/`,
canonical: `${siteUrl}/`,
robots: 'index, follow',
robots: 'index, follow',
- jsonLd: [
+ jsonLd: oneGraph([
seo.jsonLd.website({ name: SITE_NAME, url: siteUrl, description: DESCRIPTION }),
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'] }),
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 }))),
seo.jsonLd.faq(FAQ.map(([q, a]) => ({ q, a }))),
eventsJsonLd({ siteUrl, stats }),
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` }],
navbar: nav, footer, home: '/', navLinks: [{ label: 'Guide', href: `${SOURCE}#readme` }],
head: [
head: [
appIcon.headTags({ site: 'events' }),
appIcon.headTags({ site: 'events' }),
@@ -55,7 +55,10 @@ t('published events show as counts by service, public types and the latest publi
@@ -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.ok(!page.includes('live.internal.tick'));
assert.match(page, /<b>4<\/b><span>events this week<\/span>/);
assert.match(page, /<b>4<\/b><span>events this week<\/span>/);
assert.match(page, / 2 OpenVibe services published to it this week\./);
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');
const list = ld.find((x) => x['@type'] === 'ItemList');
assert.ok(list, 'the public events are an ItemList');
assert.ok(list, 'the public events are an ItemList');
assert.strictEqual(list.numberOfItems, 2);
assert.strictEqual(list.numberOfItems, 2);
@@ -65,7 +68,9 @@ t('published events show as counts by service, public types and the latest publi
@@ -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 () => {
t('JSON-LD: WebSite, the app, and a FAQPage with exactly the questions shown', async () => {
const page = await html();
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);
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 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(/'/g, "'"));
const shown = [...faq.matchAll(/<h3>([^<]+)<\/h3>/g)].map((m) => m[1].replace(/'/g, "'"));
@@ -99,7 +99,10 @@ t('GET / for a crawler or a plain curl: the server-rendered HTML, with real read
@@ -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.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.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));
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');
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
@@ -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, /<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');
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');
assert.ok(ld.some((x) => x['@type'] === 'WebSite'), 'the WebSite node');
const list = ld.find((x) => x['@type'] === 'ItemList');
const list = ld.find((x) => x['@type'] === 'ItemList');
assert.ok(list, 'an ItemList of the events');
assert.ok(list, 'an ItemList of the events');