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, }; }