openvibers/openvibe.today Commits
SEO-13: each dated row on the public schedule is an Event node (#5)
Changes
@@@ -30,6 +30,15 @@ const DAY = 24 * 60 * 60_000;/** One event's end: its own, or an hour after it starts. */const eventEnd = (e) => e.ends_at || new Date(new Date(e.starts_at).getTime() + 60 * 60_000).toISOString();++/**++ * 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 }];++};++/** schema.org Event for one of this site's events. */function eventJsonLd(e, config) {const base = String(config.baseUrl).replace(/\/+$/, '');@@@ -69,7 +78,8 @@ function createPublicPageViews(ctx) {title: null,body: raw(views.home({ upcoming: rows, now })),description: 'Streams, events and reminders across OpenVibe on one calendar: schedule a stream, get reminded, and subscribe in your own calendar app.',-- jsonLd: homeJsonLd(config),++ // Every row on the page is a dated, public event the reader can open: that is exactly one Event node.++ jsonLd: oneGraph([...homeJsonLd(config), ...rows.map((e) => eventJsonLd(e, config)), seo.jsonLd.organization()]),};}@@@ -61,7 +61,10 @@ const PRIVATE = ['/auth/', '/api/', '/me', '/brief', '/schedule', '/internal'];await check('home page: WebSite, WebApplication and WebPage JSON-LD', async () => {const r = await t.get('/');-- const blocks = [...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, 'the page carries exactly one JSON-LD script');++ const blocks = scripts.flatMap((x) => (Array.isArray(x['@graph']) ? x['@graph'] : [x]));const types = blocks.map((b) => b['@type']);assert.ok(types.includes('WebSite'), types.join(','));assert.ok(types.includes('WebApplication'), types.join(','));@@@ -7,9 +7,13 @@ const assert = require('assert');const { boot, check, done } = require('./helpers/boot');const { assetVersion } = require('../server/render/layout');--function jsonLd(html) {++// One JSON-LD script per page: every node lives in the page's @graph.++function scripts(html) {return [...String(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));}++function jsonLd(html) {++ return scripts(html).flatMap((x) => (Array.isArray(x['@graph']) ? x['@graph'] : [x]));++}(async () => {const t = await boot();@@@ -26,9 +30,21 @@ function jsonLd(html) {assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');assert.strictEqual(r.headers.get('vary'), 'Cookie');for (const marker of ['id="td-home"', 'id="td-upcoming"', 'id="main"', '<noscript><nav', '<footer id="ov-footer"', 'Public stream']) assert.ok(r.text.includes(marker), marker);++ assert.strictEqual(scripts(r.text).length, 1, 'the page carries exactly one JSON-LD script');const types = jsonLd(r.text).map((x) => x['@type']);assert.ok(types.includes('WebSite'), types.join(','));assert.ok(types.includes('WebApplication'), types.join(','));++ // The day's list is dated, so each row is an Event node describing the event it links to.++ const events = jsonLd(r.text).filter((x) => x['@type'] === 'Event');++ assert.strictEqual(events.length, 1, 'one Event for the one event on the page');++ assert.strictEqual(events[0].name, 'Public stream');++ assert.strictEqual(events[0].startDate, event.starts_at);++ assert.strictEqual(events[0].url, `https://openvibe.today/e/${event.id}`);++ assert.strictEqual(events[0].organizer.name, '@alice');++ assert.ok(r.text.includes(`href="/e/${event.id}"`), "the Event's url is the row's link");++ // A page with nothing dated says so by having no Event node.++ const developers = jsonLd((await t.get('/developers')).text);++ assert.ok(!developers.some((x) => x['@type'] === 'Event'));});await check('a page drawn for a signed-in person is private, no-store', async () => {
@@ -30,6 +30,15 @@ const DAY = 24 * 60 * 60_000;
@@ -30,6 +30,15 @@ const DAY = 24 * 60 * 60_000;
/** One event's end: its own, or an hour after it starts. */
/** One event's end: its own, or an hour after it starts. */
const eventEnd = (e) => e.ends_at || new Date(new Date(e.starts_at).getTime() + 60 * 60_000).toISOString();
const eventEnd = (e) => e.ends_at || new Date(new Date(e.starts_at).getTime() + 60 * 60_000).toISOString();
+/**
+ * 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 }];
+};
+
/** schema.org Event for one of this site's events. */
/** schema.org Event for one of this site's events. */
function eventJsonLd(e, config) {
function eventJsonLd(e, config) {
const base = String(config.baseUrl).replace(/\/+$/, '');
const base = String(config.baseUrl).replace(/\/+$/, '');
@@ -69,7 +78,8 @@ function createPublicPageViews(ctx) {
@@ -69,7 +78,8 @@ function createPublicPageViews(ctx) {
title: null,
title: null,
body: raw(views.home({ upcoming: rows, now })),
body: raw(views.home({ upcoming: rows, now })),
description: 'Streams, events and reminders across OpenVibe on one calendar: schedule a stream, get reminded, and subscribe in your own calendar app.',
description: 'Streams, events and reminders across OpenVibe on one calendar: schedule a stream, get reminded, and subscribe in your own calendar app.',
- jsonLd: homeJsonLd(config),
+ // Every row on the page is a dated, public event the reader can open: that is exactly one Event node.
+ jsonLd: oneGraph([...homeJsonLd(config), ...rows.map((e) => eventJsonLd(e, config)), seo.jsonLd.organization()]),
};
};
}
}
@@ -61,7 +61,10 @@ const PRIVATE = ['/auth/', '/api/', '/me', '/brief', '/schedule', '/internal'];
@@ -61,7 +61,10 @@ const PRIVATE = ['/auth/', '/api/', '/me', '/brief', '/schedule', '/internal'];
await check('home page: WebSite, WebApplication and WebPage JSON-LD', async () => {
await check('home page: WebSite, WebApplication and WebPage JSON-LD', async () => {
const r = await t.get('/');
const r = await t.get('/');
- const blocks = [...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, 'the page carries exactly one JSON-LD script');
+ const blocks = scripts.flatMap((x) => (Array.isArray(x['@graph']) ? x['@graph'] : [x]));
const types = blocks.map((b) => b['@type']);
const types = blocks.map((b) => b['@type']);
assert.ok(types.includes('WebSite'), types.join(','));
assert.ok(types.includes('WebSite'), types.join(','));
assert.ok(types.includes('WebApplication'), types.join(','));
assert.ok(types.includes('WebApplication'), types.join(','));
@@ -7,9 +7,13 @@ const assert = require('assert');
@@ -7,9 +7,13 @@ const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const { assetVersion } = require('../server/render/layout');
const { assetVersion } = require('../server/render/layout');
-function jsonLd(html) {
+// One JSON-LD script per page: every node lives in the page's @graph.
+function scripts(html) {
return [...String(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
return [...String(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
}
}
+function jsonLd(html) {
+ return scripts(html).flatMap((x) => (Array.isArray(x['@graph']) ? x['@graph'] : [x]));
+}
(async () => {
(async () => {
const t = await boot();
const t = await boot();
@@ -26,9 +30,21 @@ function jsonLd(html) {
@@ -26,9 +30,21 @@ function jsonLd(html) {
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
assert.strictEqual(r.headers.get('vary'), 'Cookie');
assert.strictEqual(r.headers.get('vary'), 'Cookie');
for (const marker of ['id="td-home"', 'id="td-upcoming"', 'id="main"', '<noscript><nav', '<footer id="ov-footer"', 'Public stream']) assert.ok(r.text.includes(marker), marker);
for (const marker of ['id="td-home"', 'id="td-upcoming"', 'id="main"', '<noscript><nav', '<footer id="ov-footer"', 'Public stream']) assert.ok(r.text.includes(marker), marker);
+ assert.strictEqual(scripts(r.text).length, 1, 'the page carries exactly one JSON-LD script');
const types = jsonLd(r.text).map((x) => x['@type']);
const types = jsonLd(r.text).map((x) => x['@type']);
assert.ok(types.includes('WebSite'), types.join(','));
assert.ok(types.includes('WebSite'), types.join(','));
assert.ok(types.includes('WebApplication'), types.join(','));
assert.ok(types.includes('WebApplication'), types.join(','));
+ // The day's list is dated, so each row is an Event node describing the event it links to.
+ const events = jsonLd(r.text).filter((x) => x['@type'] === 'Event');
+ assert.strictEqual(events.length, 1, 'one Event for the one event on the page');
+ assert.strictEqual(events[0].name, 'Public stream');
+ assert.strictEqual(events[0].startDate, event.starts_at);
+ assert.strictEqual(events[0].url, `https://openvibe.today/e/${event.id}`);
+ assert.strictEqual(events[0].organizer.name, '@alice');
+ assert.ok(r.text.includes(`href="/e/${event.id}"`), "the Event's url is the row's link");
+ // A page with nothing dated says so by having no Event node.
+ const developers = jsonLd((await t.get('/developers')).text);
+ assert.ok(!developers.some((x) => x['@type'] === 'Event'));
});
});
await check('a page drawn for a signed-in person is private, no-store', async () => {
await check('a page drawn for a signed-in person is private, no-store', async () => {