Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
85 changes: 85 additions & 0 deletions site/scripts/structured-data.mjs
Original file line number Diff line number Diff line change
@@ -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 = /<script[^>]*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).`,
);
29 changes: 14 additions & 15 deletions site/src/components/starlight/Head.astro
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,19 @@
*
* TechArticle the page itself, with its own frontmatter title and
* description, pointing back at the software it documents.
* BreadcrumbList Docs > <section> > <page>, matching the sidebar group
* the page sits in, so a result can show where it lives.
* BreadcrumbList Docs > <page>. 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
* each page. One entity, described once, referenced everywhere.
*/
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;
Expand All @@ -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 = {
Expand Down
Loading