1import { SOCIAL_REDIRECTS } from "../src/social-redirects.ts";
2import type { AstroIntegration } from "astro";
3import { promises as fs } from "node:fs";
4import path from "node:path";
5import { fileURLToPath } from "node:url";
6import { rewriteForPagefind } from "./pagefind-ignore-noise.ts";
7import { rewriteReferenceLinks } from "../src/reference-links.ts";
8
9/** Populated before the sitemap integration runs. */
10export const noindexPaths = new Set();
11const noindexRegex =
12 /<meta\b(?=[^>]*\bname="robots")(?=[^>]*\bcontent="[^"]*noindex)/i;
13
14/**
15 * Build-output checks — one pass over every rendered HTML page:
16 *
17 * 1. Case-sensitive internal-link check: validates every internal
18 * `<a href>` / `<img src>` against a case-sensitive Set of output
19 * paths. Case-sensitivity matters: `fs.existsSync`-based checkers
20 * (e.g. astro-broken-links-checker, which this replaced) resolve
21 * `/foo/Bar` to `/foo/bar` on macOS but 404 on Linux CI.
22 *
23 * 2. Diff-block indent check: in every rendered ```diff code block,
24 * each line's indentation must be even (the docs use 2-space
25 * indents everywhere). An odd indent means a `+`/`-` marker line
26 * was authored in the wrong convention — expressive-code strips
27 * only the marker character, so markers must be written as an
28 * EXTRA column followed by the line's full indentation, with
29 * context lines flush. See git history: three different authoring
30 * conventions had accumulated and all rendered misaligned.
31 *
32 * 3. `og:image` check: every page's og:image URL must not contain
33 * "undefined"/"null" (a broken slug lookup — Starlight 0.39 renamed
34 * routeData `slug` to `id` and the old field silently reads as
35 * undefined), and when OG images were emitted (full builds; the
36 * DOCS_FAST target skips them) the URL's path must exist in the
37 * build output.
38 */
39export function buildOutputChecks(): AstroIntegration {
40 return {
41 name: "build-output-checks",
42 hooks: {
43 "astro:build:done": async ({ dir, logger }) => {
44 const started = performance.now();
45 const distPath = fileURLToPath(dir);
46 noindexPaths.clear();
47
48 const paths = new Set<string>();
49 const dirs = new Set<string>();
50
51 // One directory inventory and one HTML read per page. Keep only paths
52 // globally; HTML buffers are limited to the small worker batch below.
53 const entries = await fs.readdir(distPath, {
54 recursive: true,
55 withFileTypes: true,
56 });
57 for (const entry of entries) {
58 const rel =
59 "/" +
60 path
61 .relative(distPath, path.join(entry.parentPath, entry.name))
62 .split(path.sep)
63 .join("/");
64 if (entry.isDirectory()) dirs.add(rel);
65 else if (entry.isFile()) paths.add(rel);
66 }
67
68 const linkExists = new Map<string, boolean>();
69 const broken = new Map<string, Set<string>>();
70
71 const oddIndents: { file: string; line: string }[] = [];
72 const badOgImages: { file: string; url: string }[] = [];
73 const htmlFiles = [...paths].filter((p) => p.endsWith(".html")).sort();
74 const hasOgImages = [...paths].some(
75 (p) => p.startsWith("/og/") && p.endsWith(".webp"),
76 );
77
78 async function checkFile(htmlFile: string) {
79 const before = await fs.readFile(
80 path.join(distPath, htmlFile.slice(1)),
81 "utf8",
82 );
83 const html = rewriteForPagefind(rewriteReferenceLinks(before));
84 if (noindexRegex.test(html)) {
85 noindexPaths.add(
86 htmlFile.endsWith("/index.html")
87 ? htmlFile.slice(0, -"index.html".length)
88 : htmlFile,
89 );
90 }
91 const links = [
92 ...html.matchAll(/<a\s+[^>]*href="([^"#?]+)/gi),
93 ...html.matchAll(/<img\s+[^>]*src="([^"#?]+)/gi),
94 ].map((m) => m[1]);
95
96 for (const link of links) {
97 if (!link.startsWith("/")) continue; // skip external, anchors, mailto, etc.
98 let exists = linkExists.get(link);
99 if (exists === undefined) {
100 const clean = link.replace(/\/$/, "");
101 exists =
102 Object.hasOwn(SOCIAL_REDIRECTS, clean) ||
103 paths.has(clean) ||
104 paths.has(clean + "/index.html") ||
105 paths.has(clean + ".html") ||
106 dirs.has(clean);
107 linkExists.set(link, exists);
108 }
109 if (!exists) {
110 if (!broken.has(link)) broken.set(link, new Set());
111 broken.get(link)?.add(htmlFile);
112 }
113 }
114
115 // og:image check (see integration docstring).
116 for (const m of html.matchAll(
117 /property="og:image"\s+content="([^"]+)"/g,
118 )) {
119 const url = m[1];
120 let pathname;
121 try {
122 pathname = new URL(url).pathname;
123 } catch {
124 badOgImages.push({ file: htmlFile, url });
125 continue;
126 }
127 if (
128 /\b(?:undefined|null)\b/.test(pathname) ||
129 (hasOgImages && !paths.has(pathname))
130 ) {
131 badOgImages.push({ file: htmlFile, url });
132 }
133 }
134
135 // Diff-block indent check (see integration docstring).
136 if (
137 html.includes("highlight ins") ||
138 html.includes("highlight del")
139 ) {
140 for (const fig of html.matchAll(
141 /<figure class="frame[^"]*">.*?<\/figure>/gs,
142 )) {
143 const block = fig[0];
144 if (
145 !block.includes("highlight ins") &&
146 !block.includes("highlight del")
147 )
148 continue;
149 for (const m of block.matchAll(
150 /<div class="ec-line[^"]*"><div class="code">(.*?)<\/div><\/div>/gs,
151 )) {
152 const text = m[1]
153 .replace(/<[^>]+>/g, "")
154 .replace(/&quot;/g, '"')
155 .replace(/&#39;/g, "'")
156 .replace(/&lt;/g, "<")
157 .replace(/&gt;/g, ">")
158 .replace(/&amp;/g, "&");
159 const trimmed = text.trim();
160 if (!trimmed) continue;
161 // JSDoc continuation lines legitimately indent by one.
162 if (trimmed.startsWith("*")) continue;
163 const indent = text.length - text.trimStart().length;
164 if (indent % 2 === 1) {
165 oddIndents.push({ file: htmlFile, line: text.slice(0, 60) });
166 }
167 }
168 }
169 }
170 if (html !== before)
171 await fs.writeFile(path.join(distPath, htmlFile.slice(1)), html);
172 }
173
174 // Read/scan in bounded parallel batches — serial reads dominate
175 // the checker's runtime on 4k+ pages.
176 const BATCH = 8;
177 for (let i = 0; i < htmlFiles.length; i += BATCH) {
178 await Promise.all(htmlFiles.slice(i, i + BATCH).map(checkFile));
179 }
180
181 if (broken.size > 0) {
182 let msg = "Case-sensitive broken links detected:\n";
183 for (const [link, docs] of broken.entries()) {
184 msg += `\n ${link}\n Found in:\n`;
185 for (const doc of docs) msg += ` - ${doc}\n`;
186 }
187 logger.error(msg);
188 throw new Error(
189 `Case-sensitive broken links detected (${broken.size})`,
190 );
191 }
192 if (badOgImages.length > 0) {
193 let msg = "Broken og:image URLs detected:\n";
194 for (const { file, url } of badOgImages.slice(0, 20)) {
195 msg += ` ${file}: ${url}\n`;
196 }
197 logger.error(msg);
198 throw new Error(
199 `Broken og:image URLs detected (${badOgImages.length})`,
200 );
201 }
202 if (oddIndents.length > 0) {
203 let msg =
204 "Misindented diff-block lines detected (write markers as an " +
205 "extra column before the line's full indentation, context " +
206 "lines flush):\n";
207 for (const { file, line } of oddIndents.slice(0, 20)) {
208 msg += ` ${file}: ${JSON.stringify(line)}\n`;
209 }
210 logger.error(msg);
211 throw new Error(
212 `Misindented diff-block lines detected (${oddIndents.length})`,
213 );
214 }
215 logger.info(
216 `Build-output checks passed (${htmlFiles.length} pages: links + diff indents + search exclusions; ${noindexPaths.size} noindex) in ${((performance.now() - started) / 1000).toFixed(2)}s`,
217 );
218 },
219 },
220 };
221}