| 1 | import { SOCIAL_REDIRECTS } from "../src/social-redirects.ts"; |
| 2 | import type { AstroIntegration } from "astro"; |
| 3 | import { promises as fs } from "node:fs"; |
| 4 | import path from "node:path"; |
| 5 | import { fileURLToPath } from "node:url"; |
| 6 | import { rewriteForPagefind } from "./pagefind-ignore-noise.ts"; |
| 7 | import { rewriteReferenceLinks } from "../src/reference-links.ts"; |
| 8 | |
| 9 | /** Populated before the sitemap integration runs. */ |
| 10 | export const noindexPaths = new Set(); |
| 11 | const 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 | */ |
| 39 | export 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 | } |