import fs from "node:fs"; import path from "node:path"; const repoRoot = process.cwd(); const distRoot = path.join(repoRoot, "dist"); const htmlAttributes = ["href", "src", "poster", "action"]; const internalHosts = new Set(["familyfed.ie", "www.familyfed.ie", "familyfed.bcgen.ie"]); const assetExtensions = new Set([ ".avif", ".css", ".gif", ".ico", ".jpeg", ".jpg", ".js", ".pdf", ".png", ".svg", ".webp", ".woff", ".woff2", ".ttf", ".eot", ]); const ignoredProtocols = /^[a-z][a-z0-9+.-]*:/i; const htmlAttributePattern = new RegExp( `\\b(${htmlAttributes.join("|")})\\s*=\\s*(["'])(.*?)\\2`, "gis", ); const srcsetPattern = /\bsrcset\s*=\s*(["'])(.*?)\1/gis; const cssUrlPattern = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^'")]*))\s*\)/gi; if (!fs.existsSync(distRoot)) { console.error("dist/ does not exist. Run `npm run build` before auditing."); process.exit(1); } const files = listFiles(distRoot); const htmlFiles = files.filter((file) => file.endsWith(".html")); const cssFiles = files.filter((file) => file.endsWith(".css")); const problems = []; let checked = 0; for (const file of htmlFiles) { const content = fs.readFileSync(file, "utf8"); for (const match of content.matchAll(htmlAttributePattern)) { checked += checkReference({ sourceFile: file, rawReference: match[3], sourceKind: match[1].toLowerCase(), }); } for (const match of content.matchAll(srcsetPattern)) { for (const entry of parseSrcset(match[2])) { checked += checkReference({ sourceFile: file, rawReference: entry, sourceKind: "srcset", }); } } } for (const file of cssFiles) { const content = fs.readFileSync(file, "utf8"); for (const match of content.matchAll(cssUrlPattern)) { checked += checkReference({ sourceFile: file, rawReference: match[1] ?? match[2] ?? match[3], sourceKind: "css url()", }); } } if (problems.length > 0) { console.error(`Static link/media audit found ${problems.length} problem(s):`); for (const problem of problems) { console.error( `- ${path.relative(repoRoot, problem.sourceFile)}: ${problem.sourceKind}="${problem.rawReference}" -> ${problem.reason}`, ); } process.exit(1); } console.log( `Static link/media audit passed: checked ${checked} local reference(s) in ${htmlFiles.length} HTML file(s) and ${cssFiles.length} CSS file(s).`, ); function listFiles(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const fullPath = path.join(dir, entry.name); return entry.isDirectory() ? listFiles(fullPath) : fullPath; }); } function checkReference({ sourceFile, rawReference, sourceKind }) { if ((sourceKind === "href" || sourceKind === "action") && isInternalHtmlPageReference(rawReference)) { problems.push({ sourceFile, sourceKind, rawReference, reason: "internal page links should omit the .html extension", }); } const reference = normalizeReference(rawReference); if (!reference) { return 0; } const target = resolveTargetPath(sourceFile, reference); if (!target) { return 0; } const exists = fs.existsSync(target); if (!exists) { problems.push({ sourceFile, sourceKind, rawReference, reason: `missing ${describeReference(reference)} at ${path.relative(repoRoot, target)}`, }); return 1; } if (isHtmlLikeReference(reference) && fs.statSync(target).isFile()) { return 1; } if (fs.statSync(target).isDirectory()) { const indexFile = path.join(target, "index.html"); if (!fs.existsSync(indexFile)) { problems.push({ sourceFile, sourceKind, rawReference, reason: `directory has no index.html at ${path.relative(repoRoot, target)}`, }); } } return 1; } function isInternalHtmlPageReference(rawReference) { const trimmed = rawReference.trim(); if ( trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("data:") || trimmed.startsWith("blob:") || trimmed.startsWith("//") || trimmed.startsWith("?") ) { return false; } const absoluteMatch = trimmed.match(/^https?:\/\/([^/?#]+)([^?#]*)(?:[?#].*)?$/i); if (absoluteMatch) { const [, host, pathname] = absoluteMatch; return internalHosts.has(host.toLowerCase()) && pathname.toLowerCase().endsWith(".html"); } if (ignoredProtocols.test(trimmed)) { return false; } const pathname = trimmed.split("#")[0].split("?")[0]; return pathname.toLowerCase().endsWith(".html"); } function normalizeReference(rawReference) { if (!rawReference) { return ""; } const trimmed = rawReference.trim(); if ( trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("data:") || trimmed.startsWith("blob:") || trimmed.startsWith("//") || trimmed.startsWith("?") ) { return ""; } if (ignoredProtocols.test(trimmed)) { return ""; } return decodeURI(trimmed.split("#")[0].split("?")[0]); } function resolveTargetPath(sourceFile, reference) { if (reference.startsWith("/")) { return path.join(distRoot, reference); } return path.resolve(path.dirname(sourceFile), reference); } function isHtmlLikeReference(reference) { return path.extname(reference) === "" || reference.endsWith(".html"); } function describeReference(reference) { const extension = path.extname(reference).toLowerCase(); if (extension === ".pdf") { return "PDF"; } if ([".avif", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"].includes(extension)) { return "image"; } if (assetExtensions.has(extension)) { return "asset"; } return "internal link"; } function parseSrcset(value) { return value .split(",") .map((candidate) => candidate.trim().split(/\s+/)[0]) .filter(Boolean); }