Incognito-Wiki/scripts/lib/internal-links.mjs

109 lines
3.3 KiB
JavaScript

import { access, readFile, readdir, stat } from 'node:fs/promises';
import { join, relative, 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 relativeTarget = decoded.replace(/^\/+/, '');
const exact = join(distRoot, relativeTarget);
const candidates = decoded.endsWith('/')
? [join(exact, 'index.html')]
: [exact, join(exact, 'index.html')];
for (const candidate of candidates) {
try {
await access(candidate);
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);
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,
};
}