Incognito-Wiki/scripts/lib/internal-links.mjs
msa46 1d4d832b6c
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
fix: verify programme routes across deployment bases
2026-08-15 21:09:25 +02:00

119 lines
3.8 KiB
JavaScript

import { readFile, readdir, stat } from 'node:fs/promises';
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
import { parseHTML } from 'linkedom';
function normalizeBase(base) {
const value = `/${String(base || '/').replace(/^\/+|\/+$/g, '')}/`;
return value === '//' ? '/' : value;
}
async function listHtmlFiles(root, directory = root) {
const entries = await readdir(directory, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const path = join(directory, entry.name);
if (entry.isDirectory()) files.push(...await listHtmlFiles(root, path));
else if (entry.isFile() && entry.name.endsWith('.html')) files.push(path);
}
return files.sort();
}
function sourceRoute(source, distRoot) {
const path = relative(distRoot, source).split(sep).join('/');
if (path === 'index.html') return '/';
if (path.endsWith('/index.html')) return `/${path.slice(0, -'index.html'.length)}`;
return `/${path}`;
}
function isIgnoredHref(href) {
return href === ''
|| href.startsWith('#')
|| href.startsWith('//')
|| /^[a-z][a-z\d+.-]*:/i.test(href);
}
function hasBasePrefix(pathname, base) {
return base === '/' || pathname === base.slice(0, -1) || pathname.startsWith(base);
}
function removeBasePrefix(pathname, base) {
if (base === '/') return pathname;
if (pathname === base.slice(0, -1)) return '/';
return `/${pathname.slice(base.length)}`;
}
async function isBuiltTarget(distRoot, route) {
let decoded;
try {
decoded = decodeURIComponent(route);
} catch {
return false;
}
const normalized = decoded.replaceAll('\\', '/');
if (normalized.split('/').includes('..')) return false;
const absoluteDistRoot = resolve(distRoot);
const relativeTarget = normalized.replace(/^\/+/, '');
const exact = resolve(absoluteDistRoot, relativeTarget);
const candidates = normalized.endsWith('/')
? [join(exact, 'index.html')]
: [exact, join(exact, 'index.html')];
for (const candidate of candidates) {
const candidateRelativePath = relative(absoluteDistRoot, candidate);
if (isAbsolute(candidateRelativePath)
|| candidateRelativePath === '..'
|| candidateRelativePath.startsWith(`..${sep}`)) {
continue;
}
try {
if ((await stat(candidate)).isFile()) return true;
} catch {
// Try the directory-index form before reporting the route.
}
}
return false;
}
export async function checkInternalLinks({ distRoot, base }) {
const normalizedBase = normalizeBase(base);
const htmlFiles = await listHtmlFiles(distRoot);
const broken = [];
let linksChecked = 0;
for (const sourcePath of htmlFiles) {
const source = relative(distRoot, sourcePath).split(sep).join('/');
const route = sourceRoute(sourcePath, distRoot);
const pageUrl = new URL(`${normalizedBase.replace(/\/$/, '')}${route}`, 'https://built.invalid');
const html = await readFile(sourcePath, 'utf8');
const { document } = parseHTML(html);
if (document.querySelector('meta[http-equiv="refresh"]')) continue;
for (const element of document.querySelectorAll('[href]')) {
const href = element.getAttribute('href')?.trim() ?? '';
if (isIgnoredHref(href)) continue;
const targetUrl = new URL(href, pageUrl);
if (targetUrl.origin !== pageUrl.origin) continue;
if (/(?:^|\/)\_astro(?:\/|$)/.test(targetUrl.pathname)) continue;
linksChecked += 1;
const hasBase = hasBasePrefix(targetUrl.pathname, normalizedBase);
const resolvedPath = hasBase
? removeBasePrefix(targetUrl.pathname, normalizedBase)
: targetUrl.pathname;
if (hasBase && await isBuiltTarget(distRoot, resolvedPath)) continue;
broken.push({ source, href, resolvedPath });
}
}
return {
filesScanned: htmlFiles.length,
linksChecked,
broken,
};
}