1
0
mirror of https://github.com/docker/docs.git synced 2026-10-23 18:09:02 +03:00
Files
docs/hack/api-docs/verify-output.mjs
David Karlsson 2b12ac0ef8 build: vendor API reference data to speed up site builds (#26182)
API generation introduced in #26048 added roughly five minutes to site
builds: median successful main-branch build-workflow duration rose from
4m 05s to 8m 48s across September 3–25. The pre-build command took 4m
41s–4m 53s in each of four jobs on the merge commit.

Vendor the generated presentation model in `data/api-reference.json`,
mark it as generated, and let Docker, Netlify, and local Hugo builds
consume it directly. Keep the existing rendered-output checks.

Move generator tests, strict spec validation, and a byte-for-byte
regeneration check into `docker buildx bake validate-api-reference`,
inheriting the shared Docker base stage. The target uses the pinned
Vacuum release binary with Dockerfile ADD checksum verification and
archive extraction instead of compiling Vacuum from source. The existing
build workflow runs this target alongside the other validation matrix
entries. Only this matrix entry is advisory; its failures do not block
site builds or deployment.

Validated locally with the Docker target, rejection of deliberately
stale data, repeatable generation across directories, a production Hugo
build and verification of 453 API HTML/Markdown page pairs, workflow
syntax checks, and scoped documentation lint. The prebuilt-binary CI
check passed in 1m 40s, down from 5m 15s with source compilation. Remote
layer-cache settings were subsequently removed; local Go cache mounts
remain.

Generated by Codex
2026-09-25 18:45:09 +02:00

172 lines
5.8 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
const root = path.resolve(import.meta.dirname, "../..");
const base = path.resolve(process.argv[2] || "public");
const data = JSON.parse(
fs.readFileSync(path.join(root, "data/api-reference.json")),
);
const decode = (s) =>
s
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
.replaceAll("&", "&")
.replaceAll(""", '"')
.replaceAll("'", "'")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">");
// Hugo's production minifier removes optional attribute quotes.
function attributes(html, name) {
const pattern = new RegExp(
`\\s${name}=(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`,
"g",
);
return [...html.matchAll(pattern)].map((m) => decode(m[1] ?? m[2] ?? m[3]));
}
let count = 0;
const problems = [];
function check(url) {
const htmlPath = path.join(base, url, "index.html");
const mdPath = path.join(base, url.replace(/\/$/, "")) + ".md";
if (!fs.existsSync(htmlPath) || !fs.existsSync(mdPath)) {
problems.push(`Missing HTML/Markdown: ${url}`);
return ["", ""];
}
const html = fs.readFileSync(htmlPath, "utf8"),
md = fs.readFileSync(mdPath, "utf8");
count++;
return [decode(html), md];
}
check("/reference/api/");
for (const api of data.apis) {
check(api.url);
const publishedSource = fs.readFileSync(path.join(base, api.sourceURL));
if (!publishedSource.equals(fs.readFileSync(path.join(root, api.source))))
problems.push(`Published specification differs from source: ${api.id}`);
for (const op of api.operations) {
const [html, md] = check(op.url);
for (const variant of op.variants) {
if (
!attributes(html, "data-api-variant").includes(variant.pointer) ||
!md.includes(`### ${variant.direction} ${variant.status}`)
)
problems.push(`Missing variant: ${op.url} ${variant.pointer}`);
for (const example of variant.examples) {
if (
!html.includes(example.text.trim()) ||
!md.includes("```" + example.language + "\n" + example.text)
)
problems.push(`Example mismatch: ${op.url} ${variant.pointer}`);
}
}
for (const p of op.parameters)
if (
!attributes(html, "data-api-parameter").includes(p.name) ||
!md.includes(`### ${p.name}`)
)
problems.push(`Missing parameter: ${op.url} ${p.name}`);
if (!html.includes(op.path) || !md.includes(op.method + " " + op.path))
problems.push(`Missing operation signature: ${op.url}`);
if (!md.includes(op.curl))
problems.push(`Request example mismatch: ${op.url}`);
}
for (const schema of api.schemas) {
const [html, md] = check(schema.url);
if (
![...md.matchAll(/```json\n([\s\S]*?)\n```/g)].some((m) => {
try {
return isDeepStrictEqual(JSON.parse(m[1]), schema.schema);
} catch {
return false;
}
})
)
problems.push(`Incomplete Markdown schema: ${schema.url}`);
if (!html.includes(schema.name))
problems.push(`Missing schema heading: ${schema.url}`);
}
}
// Check local links and fragments in generated API pages, including specification prose.
function files(dir) {
return fs
.readdirSync(dir, { withFileTypes: true })
.flatMap((e) =>
e.isDirectory()
? files(path.join(dir, e.name))
: [path.join(dir, e.name)],
);
}
for (const file of [...files(path.join(base, "reference/api"))].filter((p) =>
p.endsWith(".html"),
)) {
const html = fs.readFileSync(file, "utf8");
if (!html.includes("data-api-view=")) continue;
const article = html.match(
/<article\b[^>]*class=["']?api-reference[\s\S]*?<\/article>/,
)?.[0];
if (!article) {
problems.push(`Missing API article: ${path.relative(base, file)}`);
continue;
}
for (const href of attributes(article, "href")) {
let url;
try {
url = new URL(href, "http://localhost:1314/" + path.relative(base, file));
} catch {
continue;
}
if (
!["localhost", "docs.docker.com"].includes(url.hostname) ||
!["http:", "https:"].includes(url.protocol)
)
continue;
const target = path.join(
base,
decodeURIComponent(url.pathname),
url.pathname.endsWith("/") ? "index.html" : "",
);
if (!fs.existsSync(target)) {
problems.push(`Broken API link: ${path.relative(base, file)} -> ${href}`);
} else if (url.hash && target.endsWith(".html")) {
const targetHTML = fs.readFileSync(target, "utf8");
const anchors = [
...attributes(targetHTML, "id"),
...attributes(targetHTML, "name"),
];
if (!anchors.includes(decodeURIComponent(url.hash.slice(1))))
problems.push(
`Broken API anchor: ${path.relative(base, file)} -> ${href}`,
);
}
}
}
if (fs.existsSync(path.join(base, "api-prototype")))
problems.push("Unexpected prototype routes");
// Historical Engine pages must keep the original renderer at their existing URLs.
for (let minor = 40; minor <= 56; minor++) {
const url = `/reference/api/engine/version/v1.${minor}/`;
const html = fs.readFileSync(path.join(base, url, "index.html"), "utf8");
if (!html.includes("<redoc") || html.includes("data-api-view="))
problems.push(`Legacy Engine renderer changed: ${url}`);
}
if (problems.length)
throw Error(
problems.slice(0, 25).join("\n") + `\n${problems.length} output failures`,
);
const bytes = files(path.join(base, "reference/api")).reduce(
(n, p) => n + fs.statSync(p).size,
0,
);
fs.mkdirSync(path.join(root, "tmp/api-reference/reports"), { recursive: true });
fs.writeFileSync(
path.join(root, "tmp/api-reference/reports/output.json"),
JSON.stringify(
{ htmlMarkdownPagePairs: count, referenceBytes: bytes, result: "pass" },
null,
2,
) + "\n",
);
console.log(
`Verified ${count} HTML/Markdown page pairs and API reference links.`,
);