familyfedie-website/scripts/audit-static-links.mjs
Loyyd 9d0a823351
Some checks are pending
/ deploy (push) Waiting to run
checks
2026-06-11 09:54:39 +02:00

202 lines
4.7 KiB
JavaScript

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 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 }) {
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 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);
}