Skip to content

Commit cf3e251

Browse files
authored
Docs breadcrumbs: drop the URL-less section step, and check JSON-LD (#531)
Search Console flagged every docs page: Missing field "item" (in "itemListElement"). The breadcrumb was Docs > <sidebar group> > <page>, and sidebar groups have no page, so the middle step had no item URL, which Google requires. Now Docs > <page>, 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.
1 parent f626b46 commit cf3e251

4 files changed

Lines changed: 104 additions & 15 deletions

File tree

‎.github/workflows/site.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@ jobs:
147147
# `astro check` or the a11y pass -- see the header of the script.
148148
- name: Changelog dates
149149
run: npm run check:changelog
150+
# JSON-LD renders as nothing, so a malformed breadcrumb shipped on every
151+
# docs page until Search Console flagged it. See the script's header.
152+
- name: Structured data
153+
run: npm run check:structured-data
150154
# Runs axe against both themes; see site/scripts/a11y.mjs for why
151155
# both are needed, and why it prefers the runner's matched
152156
# chromedriver over the one the npm package downloads.

‎site/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"preview": "astro preview",
1313
"check": "astro check",
1414
"check:changelog": "node ./scripts/changelog-dates.mjs",
15+
"check:structured-data": "node ./scripts/structured-data.mjs",
1516
"check:css": "node ./scripts/css-shadowing.mjs --max 0",
1617
"check:regions": "node ./scripts/css-regions.mjs",
1718
"css:equivalence": "node ./scripts/css-equivalence.mjs",

‎site/scripts/structured-data.mjs‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Is the structured data on every built page something Google accepts?
2+
//
3+
// JSON-LD renders as nothing. A breadcrumb with a nameless, URL-less middle
4+
// step shipped on every docs page and was only noticed when Search Console
5+
// raised it as a critical issue: Missing field "item" (in "itemListElement").
6+
// This reads the built HTML and checks the rules that would have caught it.
7+
// Run after `astro build`.
8+
//
9+
// node ./scripts/structured-data.mjs
10+
11+
import fs from 'node:fs';
12+
import path from 'node:path';
13+
import { fileURLToPath } from 'node:url';
14+
15+
const dist = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist');
16+
if (!fs.existsSync(dist)) {
17+
console.error(`No build at ${dist}. Run \`npm run build\` first.`);
18+
process.exit(1);
19+
}
20+
21+
const pages = [];
22+
const walk = (dir) => {
23+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
24+
const full = path.join(dir, entry.name);
25+
if (entry.isDirectory()) walk(full);
26+
else if (entry.name.endsWith('.html')) pages.push(full);
27+
}
28+
};
29+
walk(dist);
30+
31+
const problems = [];
32+
let blocks = 0;
33+
let breadcrumbs = 0;
34+
35+
// Google's rules for a BreadcrumbList: at least one ListItem, positions
36+
// counting up from 1, a name on each, and an `item` URL on each (Google
37+
// excuses the last; this does not, since the last is the page itself and
38+
// always has one). Repeating a URL is a step that goes nowhere.
39+
function checkBreadcrumb(list, where) {
40+
breadcrumbs += 1;
41+
const items = list.itemListElement;
42+
if (!Array.isArray(items) || items.length === 0) {
43+
problems.push(`${where}: BreadcrumbList has no itemListElement`);
44+
return;
45+
}
46+
const seen = new Set();
47+
items.forEach((step, index) => {
48+
const at = `${where}: breadcrumb step ${index + 1}`;
49+
if (step.position !== index + 1) problems.push(`${at} has position ${step.position}`);
50+
if (!step.name) problems.push(`${at} has no name`);
51+
if (!step.item) problems.push(`${at} ("${step.name}") has no item URL`);
52+
else if (seen.has(step.item)) problems.push(`${at} repeats ${step.item}`);
53+
else seen.add(step.item);
54+
});
55+
}
56+
57+
function visit(node, where) {
58+
if (Array.isArray(node)) return node.forEach((child) => visit(child, where));
59+
if (!node || typeof node !== 'object') return;
60+
if (node['@type'] === 'BreadcrumbList') checkBreadcrumb(node, where);
61+
for (const value of Object.values(node)) visit(value, where);
62+
}
63+
64+
const ldPattern = /<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g;
65+
for (const page of pages) {
66+
const where = path.relative(dist, page).replaceAll('\\', '/');
67+
const html = fs.readFileSync(page, 'utf8');
68+
for (const match of html.matchAll(ldPattern)) {
69+
blocks += 1;
70+
try {
71+
visit(JSON.parse(match[1]), where);
72+
} catch (error) {
73+
problems.push(`${where}: JSON-LD does not parse (${error.message})`);
74+
}
75+
}
76+
}
77+
78+
if (problems.length) {
79+
console.error(problems.join('\n'));
80+
console.error(`\n${problems.length} structured-data problem(s).`);
81+
process.exit(1);
82+
}
83+
console.log(
84+
`Structured data OK: ${blocks} JSON-LD block(s) on ${pages.length} page(s), ${breadcrumbs} breadcrumb list(s).`,
85+
);

‎site/src/components/starlight/Head.astro‎

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,19 @@
1313
*
1414
* TechArticle the page itself, with its own frontmatter title and
1515
* description, pointing back at the software it documents.
16-
* BreadcrumbList Docs > <section> > <page>, matching the sidebar group
17-
* the page sits in, so a result can show where it lives.
16+
* BreadcrumbList Docs > <page>. Every step but the last needs an `item`
17+
* URL, and the sidebar groups ("Start", "Interfaces")
18+
* have no page of their own, so they are not steps.
19+
* They were, as a nameless middle rung, and Search
20+
* Console flagged every docs page: Missing field "item"
21+
* (in "itemListElement").
1822
*
1923
* `isPartOf` ties every docs page to the same SoftwareApplication `@id` the
2024
* landing page declares, rather than restating the software's details on
2125
* each page. One entity, described once, referenced everywhere.
2226
*/
2327
import Default from '@astrojs/starlight/components/Head.astro';
2428
import { meta } from '../../data/site-content/meta';
25-
import { docsSidebar } from '../../data/site-content/docs';
2629
2730
const route = Astro.locals.starlightRoute;
2831
const entry = route.entry;
@@ -35,22 +38,18 @@ const description = entry?.data?.description ?? meta.description;
3538
// identity for one page.
3639
const pageUrl = new URL(Astro.url.pathname, meta.domain).href;
3740
38-
// Which sidebar group the page sits in, for the breadcrumb's middle rung.
39-
// Matching on the link rather than the slug because docs.ts writes links
40-
// with the leading and trailing slash this compares against.
41-
const section = docsSidebar.find((group) =>
42-
group.items.some((item) => item.link === Astro.url.pathname),
43-
)?.label;
44-
4541
const softwareId = `${meta.domain}/#software`;
4642
43+
const docsUrl = `${meta.domain}/docs/`;
44+
// The docs index is the "Docs" step itself, so it gets one step, not the
45+
// same URL twice.
46+
const steps = [
47+
{ name: 'Docs', item: docsUrl },
48+
...(pageUrl === docsUrl ? [] : [{ name: title, item: pageUrl }]),
49+
];
4750
const breadcrumb = {
4851
'@type': 'BreadcrumbList',
49-
itemListElement: [
50-
{ '@type': 'ListItem', position: 1, name: 'Docs', item: `${meta.domain}/docs/` },
51-
...(section ? [{ '@type': 'ListItem', position: 2, name: section }] : []),
52-
{ '@type': 'ListItem', position: section ? 3 : 2, name: title, item: pageUrl },
53-
],
52+
itemListElement: steps.map((step, index) => ({ '@type': 'ListItem', position: index + 1, ...step })),
5453
};
5554
5655
const techArticle = {

0 commit comments

Comments
 (0)