From d9b54372de212e72f6cc8e054cdb58ed4ab1f529 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Mon, 5 Oct 2026 14:59:27 +0100 Subject: [PATCH] Docs breadcrumbs: drop the URL-less section step, and check JSON-LD Search Console flagged every docs page: Missing field "item" (in "itemListElement"). The breadcrumb was Docs > > , and sidebar groups have no page, so the middle step had no item URL, which Google requires. Now Docs > , and the docs index is a single step rather than the same URL twice. site/scripts/structured-data.mjs parses every built page's JSON-LD and checks each BreadcrumbList (positions, names, an item URL on every step, no repeated URLs). Run in the Site workflow after the build. Against the previous Head.astro it reports 12 problems, one per docs page. --- .github/workflows/site.yml | 4 ++ site/package.json | 1 + site/scripts/structured-data.mjs | 85 ++++++++++++++++++++++++ site/src/components/starlight/Head.astro | 29 ++++---- 4 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 site/scripts/structured-data.mjs 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 = {