openvibers/openvibe.host Commits
Host pages carry one JSON-LD script, every node in one @graph (#190)
Changes
@@@ -91,6 +91,14 @@ function renderPage(o) {// footer.js with the navbar init, the noscript nav and the SSR footer; the rest of the head and the body// are the catalog's own pages.const canonical = canonicalOf(o);++ // One JSON-LD script per page, every node in one @graph (the estate's convention): a page that hands over a++ // list of nodes gets the graph wrapper here, and the nodes keep their own fields with the graph carrying the++ // one @context. A page that already passes a single object is left as it is.++ const jsonLd = (() => {++ if (!o.indexable || !Array.isArray(o.jsonLd)) return undefined;++ const nodes = o.jsonLd.filter(Boolean).map((n) => { const c = { ...n }; delete c['@context']; return c; });++ return [{ '@context': 'https://schema.org', '@graph': nodes }];++ })();return shell.page({// A page with a Markdown twin (o.agent) advertises it in the head; the twin itself is server/agent-pages.js.agentLayer: !!o.agent,@@@ -102,7 +110,7 @@ function renderPage(o) {description: o.description || DESCRIPTION,canonical,robots: o.indexable ? 'index, follow' : 'noindex, nofollow',-- jsonLd: o.indexable && Array.isArray(o.jsonLd) ? o.jsonLd : undefined,++ jsonLd,navbar: nav, footer, home: '/', navLinks: NAV_LINKS(signedIn),head: [appIcon.headTags({ site: 'host' }),@@@ -51,12 +51,20 @@ const { boot, check, done } = require('./helpers/boot');const bad = await t.api('GET', '/catalog?sort=affiliate'); assert.strictEqual(bad.status, 422);// JSON-LD on the indexable catalog pages: WebSite and an ItemList on the front, an ItemList on /catalog,// a Product with a monthly Offer on a plan; never on a filtered (noindex) view.-- const ld = (html) => [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])).flat();++ // One JSON-LD script per page, every node in one @graph (the estate's convention): the helper flattens++ // the graph, and the script count is asserted for each page below.++ const ld = (html) => [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)]++ .map((m) => JSON.parse(m[1]))++ .flatMap((doc) => (Array.isArray(doc['@graph']) ? doc['@graph'] : [doc]));++ const scripts = (html) => [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].length;const types = (html) => ld(html).map((x) => x['@type']);++ assert.strictEqual(scripts(front.text), 1, 'the front page carries exactly one JSON-LD script');++ assert.strictEqual(scripts(plans.text), 1, 'the catalog carries exactly one JSON-LD script');assert.ok(types(front.text).includes('WebSite') && types(front.text).includes('ItemList') && types(front.text).includes('FAQPage'), JSON.stringify(types(front.text)));assert.ok(types(plans.text).includes('ItemList'));const planPage = await t.api('GET', `/catalog/plans/${cheap.id}`);const product = ld(planPage.text).find((x) => x['@type'] === 'Product');++ assert.strictEqual(scripts(planPage.text), 1, 'a plan page carries exactly one JSON-LD script');assert.match(planPage.text, /class="hc-visit"/, 'a plan page offers a way to the provider');assert.match(planPage.text, /href="https:\/\/openvibe\.watch\/watches\/new\?template=json&url=[^"]*%2Fapi%2Fv1%2Fcatalog%2Fplans%2F[^"]*value_path=plan\.price_monthly&op=changed/, 'a plan page offers a prefilled price watch');assert.ok(product && product.offers.price === 4 && product.offers.priceCurrency === 'USD' && product.offers.priceSpecification.unitText === 'MONTH');
@@ -91,6 +91,14 @@ function renderPage(o) {
@@ -91,6 +91,14 @@ function renderPage(o) {
// footer.js with the navbar init, the noscript nav and the SSR footer; the rest of the head and the body
// footer.js with the navbar init, the noscript nav and the SSR footer; the rest of the head and the body
// are the catalog's own pages.
// are the catalog's own pages.
const canonical = canonicalOf(o);
const canonical = canonicalOf(o);
+ // One JSON-LD script per page, every node in one @graph (the estate's convention): a page that hands over a
+ // list of nodes gets the graph wrapper here, and the nodes keep their own fields with the graph carrying the
+ // one @context. A page that already passes a single object is left as it is.
+ const jsonLd = (() => {
+ if (!o.indexable || !Array.isArray(o.jsonLd)) return undefined;
+ const nodes = o.jsonLd.filter(Boolean).map((n) => { const c = { ...n }; delete c['@context']; return c; });
+ return [{ '@context': 'https://schema.org', '@graph': nodes }];
+ })();
return shell.page({
return shell.page({
// A page with a Markdown twin (o.agent) advertises it in the head; the twin itself is server/agent-pages.js.
// A page with a Markdown twin (o.agent) advertises it in the head; the twin itself is server/agent-pages.js.
agentLayer: !!o.agent,
agentLayer: !!o.agent,
@@ -102,7 +110,7 @@ function renderPage(o) {
@@ -102,7 +110,7 @@ function renderPage(o) {
description: o.description || DESCRIPTION,
description: o.description || DESCRIPTION,
canonical,
canonical,
robots: o.indexable ? 'index, follow' : 'noindex, nofollow',
robots: o.indexable ? 'index, follow' : 'noindex, nofollow',
- jsonLd: o.indexable && Array.isArray(o.jsonLd) ? o.jsonLd : undefined,
+ jsonLd,
navbar: nav, footer, home: '/', navLinks: NAV_LINKS(signedIn),
navbar: nav, footer, home: '/', navLinks: NAV_LINKS(signedIn),
head: [
head: [
appIcon.headTags({ site: 'host' }),
appIcon.headTags({ site: 'host' }),
@@ -51,12 +51,20 @@ const { boot, check, done } = require('./helpers/boot');
@@ -51,12 +51,20 @@ const { boot, check, done } = require('./helpers/boot');
const bad = await t.api('GET', '/catalog?sort=affiliate'); assert.strictEqual(bad.status, 422);
const bad = await t.api('GET', '/catalog?sort=affiliate'); assert.strictEqual(bad.status, 422);
// JSON-LD on the indexable catalog pages: WebSite and an ItemList on the front, an ItemList on /catalog,
// JSON-LD on the indexable catalog pages: WebSite and an ItemList on the front, an ItemList on /catalog,
// a Product with a monthly Offer on a plan; never on a filtered (noindex) view.
// a Product with a monthly Offer on a plan; never on a filtered (noindex) view.
- const ld = (html) => [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])).flat();
+ // One JSON-LD script per page, every node in one @graph (the estate's convention): the helper flattens
+ // the graph, and the script count is asserted for each page below.
+ const ld = (html) => [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)]
+ .map((m) => JSON.parse(m[1]))
+ .flatMap((doc) => (Array.isArray(doc['@graph']) ? doc['@graph'] : [doc]));
+ const scripts = (html) => [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].length;
const types = (html) => ld(html).map((x) => x['@type']);
const types = (html) => ld(html).map((x) => x['@type']);
+ assert.strictEqual(scripts(front.text), 1, 'the front page carries exactly one JSON-LD script');
+ assert.strictEqual(scripts(plans.text), 1, 'the catalog carries exactly one JSON-LD script');
assert.ok(types(front.text).includes('WebSite') && types(front.text).includes('ItemList') && types(front.text).includes('FAQPage'), JSON.stringify(types(front.text)));
assert.ok(types(front.text).includes('WebSite') && types(front.text).includes('ItemList') && types(front.text).includes('FAQPage'), JSON.stringify(types(front.text)));
assert.ok(types(plans.text).includes('ItemList'));
assert.ok(types(plans.text).includes('ItemList'));
const planPage = await t.api('GET', `/catalog/plans/${cheap.id}`);
const planPage = await t.api('GET', `/catalog/plans/${cheap.id}`);
const product = ld(planPage.text).find((x) => x['@type'] === 'Product');
const product = ld(planPage.text).find((x) => x['@type'] === 'Product');
+ assert.strictEqual(scripts(planPage.text), 1, 'a plan page carries exactly one JSON-LD script');
assert.match(planPage.text, /class="hc-visit"/, 'a plan page offers a way to the provider');
assert.match(planPage.text, /class="hc-visit"/, 'a plan page offers a way to the provider');
assert.match(planPage.text, /href="https:\/\/openvibe\.watch\/watches\/new\?template=json&url=[^"]*%2Fapi%2Fv1%2Fcatalog%2Fplans%2F[^"]*value_path=plan\.price_monthly&op=changed/, 'a plan page offers a prefilled price watch');
assert.match(planPage.text, /href="https:\/\/openvibe\.watch\/watches\/new\?template=json&url=[^"]*%2Fapi%2Fv1%2Fcatalog%2Fplans%2F[^"]*value_path=plan\.price_monthly&op=changed/, 'a plan page offers a prefilled price watch');
assert.ok(product && product.offers.price === 4 && product.offers.priceCurrency === 'USD' && product.offers.priceSpecification.unitText === 'MONTH');
assert.ok(product && product.offers.price === 4 && product.offers.priceCurrency === 'USD' && product.offers.priceSpecification.unitText === 'MONTH');