familyfedie-website/scripts/audit-static-links.mjs
Loyyd 10d3bbefa4
Some checks are pending
/ deploy (push) Waiting to run
no bcgen domain
2026-07-08 11:16:55 +02:00

269 lines
6.4 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 internalHosts = new Set(["familyfed.ie"]);
const forbiddenSiteHosts = new Set(["familyfed.bcgen.ie", "www.familyfed.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 forbiddenHost = getForbiddenSiteHost(rawReference);
if (forbiddenHost) {
problems.push({
sourceFile,
sourceKind,
rawReference,
reason: `site URLs must use familyfed.ie, not ${forbiddenHost}`,
});
}
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 getForbiddenSiteHost(rawReference) {
if (!rawReference) {
return "";
}
const trimmed = rawReference.trim();
if (!trimmed) {
return "";
}
try {
const url = new URL(trimmed.startsWith("//") ? `https:${trimmed}` : trimmed);
const host = url.hostname.toLowerCase();
return forbiddenSiteHosts.has(host) ? host : "";
} catch {
return "";
}
}
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);
}