diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml index fbe2e86f..63512886 100644 --- a/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -147,6 +147,10 @@ jobs: # `astro check` or the a11y pass -- see the header of the script. - name: Changelog dates run: npm run check:changelog + # JSON-LD renders as nothing, so a malformed breadcrumb shipped on every + # docs page until Search Console flagged it. See the script's header. + - name: Structured data + run: npm run check:structured-data # Runs axe against both themes; see site/scripts/a11y.mjs for why # both are needed, and why it prefers the runner's matched # chromedriver over the one the npm package downloads. diff --git a/site/package.json b/site/package.json index 5f3304c4..5cf16227 100644 --- a/site/package.json +++ b/site/package.json @@ -12,6 +12,7 @@ "preview": "astro preview", "check": "astro check", "check:changelog": "node ./scripts/changelog-dates.mjs", + "check:structured-data": "node ./scripts/structured-data.mjs", "check:css": "node ./scripts/css-shadowing.mjs --max 0", "check:regions": "node ./scripts/css-regions.mjs", "css:equivalence": "node ./scripts/css-equivalence.mjs", diff --git a/site/scripts/structured-data.mjs b/site/scripts/structured-data.mjs new file mode 100644 index 00000000..5a0ffa03 --- /dev/null +++ b/site/scripts/structured-data.mjs @@ -0,0 +1,85 @@ +// Is the structured data on every built page something Google accepts? +// +// JSON-LD renders as nothing. A breadcrumb with a nameless, URL-less middle +// step shipped on every docs page and was only noticed when Search Console +// raised it as a critical issue: Missing field "item" (in "itemListElement"). +// This reads the built HTML and checks the rules that would have caught it. +// Run after `astro build`. +// +// node ./scripts/structured-data.mjs + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const dist = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist'); +if (!fs.existsSync(dist)) { + console.error(`No build at ${dist}. Run \`npm run build\` first.`); + process.exit(1); +} + +const pages = []; +const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.html')) pages.push(full); + } +}; +walk(dist); + +const problems = []; +let blocks = 0; +let breadcrumbs = 0; + +// Google's rules for a BreadcrumbList: at least one ListItem, positions +// counting up from 1, a name on each, and an `item` URL on each (Google +// excuses the last; this does not, since the last is the page itself and +// always has one). Repeating a URL is a step that goes nowhere. +function checkBreadcrumb(list, where) { + breadcrumbs += 1; + const items = list.itemListElement; + if (!Array.isArray(items) || items.length === 0) { + problems.push(`${where}: BreadcrumbList has no itemListElement`); + return; + } + const seen = new Set(); + items.forEach((step, index) => { + const at = `${where}: breadcrumb step ${index + 1}`; + if (step.position !== index + 1) problems.push(`${at} has position ${step.position}`); + if (!step.name) problems.push(`${at} has no name`); + if (!step.item) problems.push(`${at} ("${step.name}") has no item URL`); + else if (seen.has(step.item)) problems.push(`${at} repeats ${step.item}`); + else seen.add(step.item); + }); +} + +function visit(node, where) { + if (Array.isArray(node)) return node.forEach((child) => visit(child, where)); + if (!node || typeof node !== 'object') return; + if (node['@type'] === 'BreadcrumbList') checkBreadcrumb(node, where); + for (const value of Object.values(node)) visit(value, where); +} + +const ldPattern = /]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g; +for (const page of pages) { + const where = path.relative(dist, page).replaceAll('\\', '/'); + const html = fs.readFileSync(page, 'utf8'); + for (const match of html.matchAll(ldPattern)) { + blocks += 1; + try { + visit(JSON.parse(match[1]), where); + } catch (error) { + problems.push(`${where}: JSON-LD does not parse (${error.message})`); + } + } +} + +if (problems.length) { + console.error(problems.join('\n')); + console.error(`\n${problems.length} structured-data problem(s).`); + process.exit(1); +} +console.log( + `Structured data OK: ${blocks} JSON-LD block(s) on ${pages.length} page(s), ${breadcrumbs} breadcrumb list(s).`, +); diff --git a/site/src/components/starlight/Head.astro b/site/src/components/starlight/Head.astro index 1133fbfc..63bb4f5c 100644 --- a/site/src/components/starlight/Head.astro +++ b/site/src/components/starlight/Head.astro @@ -13,8 +13,12 @@ * * TechArticle the page itself, with its own frontmatter title and * description, pointing back at the software it documents. - * BreadcrumbList Docs >
> , matching the sidebar group - * the page sits in, so a result can show where it lives. + * BreadcrumbList Docs > . Every step but the last needs an `item` + * URL, and the sidebar groups ("Start", "Interfaces") + * have no page of their own, so they are not steps. + * They were, as a nameless middle rung, and Search + * Console flagged every docs page: Missing field "item" + * (in "itemListElement"). * * `isPartOf` ties every docs page to the same SoftwareApplication `@id` the * landing page declares, rather than restating the software's details on @@ -22,7 +26,6 @@ */ import Default from '@astrojs/starlight/components/Head.astro'; import { meta } from '../../data/site-content/meta'; -import { docsSidebar } from '../../data/site-content/docs'; const route = Astro.locals.starlightRoute; const entry = route.entry; @@ -35,22 +38,18 @@ const description = entry?.data?.description ?? meta.description; // identity for one page. const pageUrl = new URL(Astro.url.pathname, meta.domain).href; -// Which sidebar group the page sits in, for the breadcrumb's middle rung. -// Matching on the link rather than the slug because docs.ts writes links -// with the leading and trailing slash this compares against. -const section = docsSidebar.find((group) => - group.items.some((item) => item.link === Astro.url.pathname), -)?.label; - const softwareId = `${meta.domain}/#software`; +const docsUrl = `${meta.domain}/docs/`; +// The docs index is the "Docs" step itself, so it gets one step, not the +// same URL twice. +const steps = [ + { name: 'Docs', item: docsUrl }, + ...(pageUrl === docsUrl ? [] : [{ name: title, item: pageUrl }]), +]; const breadcrumb = { '@type': 'BreadcrumbList', - itemListElement: [ - { '@type': 'ListItem', position: 1, name: 'Docs', item: `${meta.domain}/docs/` }, - ...(section ? [{ '@type': 'ListItem', position: 2, name: section }] : []), - { '@type': 'ListItem', position: section ? 3 : 2, name: title, item: pageUrl }, - ], + itemListElement: steps.map((step, index) => ({ '@type': 'ListItem', position: index + 1, ...step })), }; const techArticle = {