parent
39f68f0051
commit
9d0a823351
23 changed files with 1158 additions and 31 deletions
202
scripts/audit-static-links.mjs
Normal file
202
scripts/audit-static-links.mjs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue