76 lines
3.8 KiB
JavaScript
76 lines
3.8 KiB
JavaScript
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const crypto = require('node:crypto');
|
|
const root = path.resolve(__dirname, '../..');
|
|
const baseline = path.join(__dirname, 'before');
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(baseline, 'manifest.json'), 'utf8').replace(/^\uFEFF/, ''));
|
|
const prior = new Map(manifest.map(x => [x.Path.replaceAll('\\', '/'), x]));
|
|
const files = [];
|
|
function walk(dir) {
|
|
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
|
|
if (entry.name.startsWith('.')) continue;
|
|
const file = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) walk(file);
|
|
else if (entry.isFile()) files.push(file);
|
|
}
|
|
}
|
|
walk(root);
|
|
const errors = [], changed = [];
|
|
const utf8 = new TextDecoder('utf-8', {fatal: true});
|
|
for (const file of files) {
|
|
const relative = path.relative(root, file).replaceAll('\\', '/');
|
|
const bytes = fs.readFileSync(file);
|
|
const hash = crypto.createHash('sha256').update(bytes).digest('hex').toUpperCase();
|
|
if (prior.get(relative)?.Hash === hash) continue;
|
|
changed.push(relative);
|
|
if (!/\.(c|h|md|ps1|cjs)$/.test(file)) continue;
|
|
let text;
|
|
try { text = utf8.decode(bytes); } catch { errors.push(`${relative}: invalid UTF-8`); continue; }
|
|
const bom = bytes.length >= 3 && bytes.subarray(0, 3).equals(Buffer.from([239, 187, 191]));
|
|
if (prior.has(relative) && prior.get(relative).Bom !== bom) errors.push(`${relative}: BOM changed`);
|
|
if (text.includes('\uFFFD')) errors.push(`${relative}: replacement character`);
|
|
if (text.includes('\r')) errors.push(`${relative}: non-LF newline`);
|
|
if (!text.endsWith('\n')) errors.push(`${relative}: missing terminal newline`);
|
|
if (/[^\S\n]+\n/.test(text)) errors.push(`${relative}: trailing whitespace`);
|
|
}
|
|
let linkCount = 0, anchorCount = 0;
|
|
const anchorCache = new Map();
|
|
function anchors(file) {
|
|
if (anchorCache.has(file)) return anchorCache.get(file);
|
|
const text = fs.readFileSync(file, 'utf8').replace(/```[^\n]*\n[\s\S]*?```/g, '');
|
|
const found = new Set(), duplicates = new Map();
|
|
for (const match of text.matchAll(/\bid=["']([^"']+)["']/g)) found.add(match[1]);
|
|
for (const match of text.matchAll(/^#{1,6}\s+(.+)$/gm)) {
|
|
const slug = match[1].trim().toLowerCase().replace(/[^\p{L}\p{N}_\-\s]/gu, '').replace(/\s/g, '-');
|
|
const count = duplicates.get(slug) || 0;
|
|
found.add(count ? `${slug}-${count}` : slug);
|
|
duplicates.set(slug, count + 1);
|
|
}
|
|
anchorCache.set(file, found);
|
|
return found;
|
|
}
|
|
for (const file of files.filter(f => f.endsWith('.md'))) {
|
|
const text = fs.readFileSync(file, 'utf8').replace(/```[^\n]*\n[\s\S]*?```/g, '');
|
|
for (const match of text.matchAll(/\[[^\]\n]*\]\(([^)\n]+)\)/g)) {
|
|
let target = match[1].replace(/^<|>$/g, '').trim();
|
|
if (/^[a-z]+:/i.test(target)) continue;
|
|
const fragment = decodeURIComponent(target.split('#')[1] || '');
|
|
target = decodeURIComponent(target.split('#')[0]);
|
|
const resolved = target ? path.resolve(path.dirname(file), target) : file;
|
|
if (!resolved.toLowerCase().startsWith(root.toLowerCase() + path.sep)) {
|
|
errors.push(`${path.relative(root, file)}: link outside workspace ${target}`); continue;
|
|
}
|
|
++linkCount;
|
|
if (!fs.existsSync(resolved)) errors.push(`${path.relative(root, file)}: missing ${target}`);
|
|
else if (fragment && resolved.endsWith('.md')) {
|
|
++anchorCount;
|
|
if (!anchors(resolved).has(fragment)) errors.push(`${path.relative(root, file)}: missing anchor ${target}#${fragment}`);
|
|
}
|
|
}
|
|
}
|
|
for (const old of prior.keys()) {
|
|
if (!fs.existsSync(path.join(root, old))) errors.push(`Missing original file: ${old}`);
|
|
}
|
|
console.log(JSON.stringify({changed, checkedLocalLinks: linkCount, checkedAnchors: anchorCount, errors}, null, 2));
|
|
process.exitCode = errors.length ? 1 : 0;
|