Add lint check for hashed dist asset references

This commit is contained in:
thePR0M3TH3AN committed 2026-02-10 00:48:28 -05:00
1 parent 01764b1002
commit 7289bfe0f4
2 files changed
+143 -2

No files matched your search

+3 -2
View File
@@ -16,7 +16,7 @@
"ci:beacon": "npm run build:beacon",
"format": "prettier --cache --write css/tailwind.source.css css/tokens.css css/docs.css \"docs/**/*.html\" tailwind.config.cjs README.md CONTRIBUTING.md",
"audit": "node scripts/daily-design-system-audit.mjs",
"lint": "npm run lint:css && npm run lint:hex && npm run lint:inline-styles && npm run lint:tokens && npm run lint:tailwind-brackets && npm run lint:tailwind-colors && npm run lint:file-size && npm run lint:innerhtml",
"lint": "npm run lint:css && npm run lint:hex && npm run lint:inline-styles && npm run lint:tokens && npm run lint:tailwind-brackets && npm run lint:tailwind-colors && npm run lint:file-size && npm run lint:innerhtml && npm run lint:assets",
"lint:file-size": "node scripts/check-file-size.mjs",
"lint:innerhtml": "node scripts/check-innerhtml.mjs",
"lint:css": "stylelint css/tailwind.source.css css/tokens.css css/docs.css",
@@ -37,7 +37,8 @@
"test:unit:shard1": "cross-env UNIT_TEST_SHARD=1/3 node scripts/run-unit-tests.mjs",
"test:unit:shard2": "cross-env UNIT_TEST_SHARD=2/3 node scripts/run-unit-tests.mjs",
"test:unit:shard3": "cross-env UNIT_TEST_SHARD=3/3 node scripts/run-unit-tests.mjs",
"telemetry:aggregate": "node scripts/agent/telemetry-aggregator.mjs"
"telemetry:aggregate": "node scripts/agent/telemetry-aggregator.mjs",
"lint:assets": "node scripts/check-asset-references.mjs"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
+140
View File
@@ -0,0 +1,140 @@
import fs from 'node:fs';
import path from 'node:path';
const DIST_DIR = 'dist';
const HTML_FILES = ['index.html', 'embed.html'];
const MANIFEST_PATH = path.join(DIST_DIR, 'asset-manifest.json');
const ALLOWLISTED_STATIC_PATHS = new Set([
'vendor/marked.min.js',
'vendor/highlight.min.js'
]);
const CSS_JS_ATTR_PATTERN = /<(script|link)\b[^>]*\b(src|href)=(["'])([^"']+)\3[^>]*>/gi;
function isLocalAssetReference(reference) {
return !/^(?:[a-zA-Z][a-zA-Z\d+.-]*:|\/\/)/.test(reference);
}
function normalizeAssetPath(reference) {
return reference.replace(/^\/+/, '').replace(/^\.\//, '');
}
function extractPathAndQuery(reference) {
const [pathPart, queryPart = ''] = reference.split('?', 2);
return {
path: normalizeAssetPath(pathPart),
query: queryPart
};
}
function hasCssOrJsExtension(assetPath) {
return assetPath.endsWith('.css') || assetPath.endsWith('.js');
}
function findLineNumber(content, index) {
return content.slice(0, index).split('\n').length;
}
function loadManifestHashedPaths() {
if (!fs.existsSync(MANIFEST_PATH)) {
throw new Error(
`Missing ${MANIFEST_PATH}. Run \"npm run build\" before running this check.`
);
}
const rawManifest = fs.readFileSync(MANIFEST_PATH, 'utf8');
const manifest = JSON.parse(rawManifest);
return new Set(Object.values(manifest).map((entry) => normalizeAssetPath(String(entry))));
}
function collectAssetViolations(htmlFilePath, hashedAssetPaths) {
const content = fs.readFileSync(htmlFilePath, 'utf8');
const violations = [];
for (const match of content.matchAll(CSS_JS_ATTR_PATTERN)) {
const fullTag = match[0];
const rawReference = match[4];
const line = findLineNumber(content, match.index ?? 0);
if (!isLocalAssetReference(rawReference)) {
continue;
}
const { path: assetPath, query } = extractPathAndQuery(rawReference);
if (!hasCssOrJsExtension(assetPath)) {
continue;
}
if (/^v=/i.test(query) || /(?:^|&)v=/i.test(query)) {
violations.push({
type: 'legacy-cache-busting',
htmlFilePath,
line,
tag: fullTag,
reference: rawReference,
detail: 'Legacy ?v= cache-busting query detected in built output.'
});
continue;
}
if (hashedAssetPaths.has(assetPath) || ALLOWLISTED_STATIC_PATHS.has(assetPath)) {
continue;
}
violations.push({
type: 'unapproved-asset-reference',
htmlFilePath,
line,
tag: fullTag,
reference: rawReference,
detail:
'Local CSS/JS asset reference is not hashed in asset-manifest.json and not allowlisted.'
});
}
return violations;
}
function formatViolation(violation) {
return [
`- File: ${violation.htmlFilePath}:${violation.line}`,
` Reference: ${violation.reference}`,
` Issue: ${violation.detail}`,
` Tag: ${violation.tag}`
].join('\n');
}
function main() {
const hashedAssetPaths = loadManifestHashedPaths();
const htmlPaths = HTML_FILES.map((file) => path.join(DIST_DIR, file));
const missingHtml = htmlPaths.filter((filePath) => !fs.existsSync(filePath));
if (missingHtml.length > 0) {
throw new Error(
`Missing built HTML files: ${missingHtml.join(', ')}. Run \"npm run build\" before running this check.`
);
}
const violations = htmlPaths.flatMap((htmlPath) =>
collectAssetViolations(htmlPath, hashedAssetPaths)
);
if (violations.length > 0) {
console.error(`\nAsset reference lint failed (${violations.length} issue${violations.length === 1 ? '' : 's'}):`);
for (const violation of violations) {
console.error(formatViolation(violation));
}
process.exitCode = 1;
return;
}
console.log('Asset reference lint passed: all local CSS/JS references are hashed or allowlisted, and no ?v= query cache-busting remains.');
}
try {
main();
} catch (error) {
console.error(`Asset reference lint failed: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}