118 lines
3.7 KiB
JavaScript
118 lines
3.7 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);
|
|
|
|
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,
|
|
};
|
|
}
|