239 lines
5.7 KiB
JavaScript
239 lines
5.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 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);
|
|
}
|