Files
2026-07-13 12:38:36 +08:00

287 lines
7.8 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import { workspaceRoot } from '@nx/devkit';
// Links to pages hosted outside of both astro-docs and nx-dev sites
const ignoredLinks = ['/contact', '/blog'];
// These are more so until we cut over and can modify production file links
const filesToIgnore = [
'/executors/index.html',
'/generators/index.html',
'/migrations/index.html',
'/nx-commands/index.html',
];
const distDir = path.join(workspaceRoot, 'astro-docs', 'dist');
const sitemapIndexPath = path.join(distDir, 'sitemap-index.xml');
const sitemapFallbackPath = path.join(distDir, 'sitemap-0.xml');
const nxDevSitemapPath = path.join(
workspaceRoot,
'nx-dev',
'nx-dev',
'public',
'sitemap-0.xml'
);
if (!fs.existsSync(distDir)) {
console.error(
`Dist directory does not exist at path Have you ran the build?: ${distDir}`
);
process.exit(1);
}
if (!fs.existsSync(sitemapIndexPath) && !fs.existsSync(sitemapFallbackPath)) {
console.error(
`No sitemap found at ${sitemapIndexPath} or ${sitemapFallbackPath}. Have you ran the build?`
);
process.exit(1);
}
function findHtmlFiles(dir: string, files: string[] = []) {
try {
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
findHtmlFiles(fullPath, files);
} else if (item.endsWith('.html')) {
files.push(fullPath);
}
}
} catch (err: any) {
console.error(`Error reading directory ${dir}:`, err);
}
return files;
}
function extractInternalLinks(htmlContent: string, filePath: string) {
const links = new Set<string>();
// NOTE: surely regex parsing html won't blow up in my face
// Regex to match href attributes in anchor tags
const hrefRegex = /<a[^>]+href=["']([^"']+)["'][^>]*>/gi;
let match;
while ((match = hrefRegex.exec(htmlContent)) !== null) {
const href = match[1];
if (
// Skip external links
href.startsWith('http') ||
// skip anchor links
href.startsWith('#') ||
// skip any mailto links
href.startsWith('mailto')
) {
continue;
}
try {
const cleanLink = new URL(href, 'http://localhost');
links.add(cleanLink.pathname);
} catch (error) {
console.error(`Unable to parse link for validation: ${href}`, error);
process.exit(1);
}
}
return links;
}
function parseSitemap(sitemapContent: string) {
const routes = new Set<string>();
// Extract all <loc> URLs from the sitemap
const locRegex = /<loc>([^<]+)<\/loc>/gi;
let match;
while ((match = locRegex.exec(sitemapContent)) !== null) {
const url = match[1];
const parsedUrl = new URL(url);
routes.add(parsedUrl.pathname);
}
return routes;
}
/**
* Parses sitemap-index.xml to discover all sitemap files,
* then merges routes from all of them.
* Falls back to sitemap-0.xml if sitemap-index.xml doesn't exist.
*/
function loadAllSitemapRoutes(): Set<string> {
const allRoutes = new Set<string>();
// Load astro-docs sitemap routes
if (fs.existsSync(sitemapIndexPath)) {
const indexContent = fs.readFileSync(sitemapIndexPath, 'utf-8');
const locRegex = /<loc>([^<]+)<\/loc>/gi;
const sitemapUrls: string[] = [];
let match;
while ((match = locRegex.exec(indexContent)) !== null) {
sitemapUrls.push(match[1]);
}
for (const sitemapUrl of sitemapUrls) {
const parsedUrl = new URL(sitemapUrl);
const filename = path.basename(parsedUrl.pathname);
const sitemapFilePath = path.join(distDir, filename);
if (!fs.existsSync(sitemapFilePath)) {
console.warn(
`Sitemap file referenced in index not found: ${sitemapFilePath}`
);
continue;
}
const sitemapContent = fs.readFileSync(sitemapFilePath, 'utf-8');
const routes = parseSitemap(sitemapContent);
for (const route of routes) {
allRoutes.add(route);
}
}
} else {
// Fallback to sitemap-0.xml
const sitemapContent = fs.readFileSync(sitemapFallbackPath, 'utf-8');
for (const route of parseSitemap(sitemapContent)) {
allRoutes.add(route);
}
}
// Load nx-dev (Next.js) sitemap routes for cross-site validation
if (fs.existsSync(nxDevSitemapPath)) {
const nxDevSitemapContent = fs.readFileSync(nxDevSitemapPath, 'utf-8');
const nxDevRoutes = parseSitemap(nxDevSitemapContent);
console.log(
`Found ${nxDevRoutes.size} routes in nx-dev sitemap (${nxDevSitemapPath})`
);
for (const route of nxDevRoutes) {
allRoutes.add(route);
}
} else {
console.warn(
`nx-dev sitemap not found at ${nxDevSitemapPath}. Run "nx build nx-dev" to enable cross-site link validation against nx-dev routes.`
);
}
return allRoutes;
}
function toFriendlyName(file: string) {
// TODO: resolve to the actual markdown file if possile?
return path.relative(workspaceRoot, file);
}
function validateLinks() {
const linksToFiles = new Map<string, string[]>();
console.log('🔍 Starting link validation...\n');
console.log('📁 Finding HTML files in dist directory...');
const htmlFiles = findHtmlFiles(distDir);
console.log(`Found ${htmlFiles.length} HTML files\n`);
console.log('🔗 Extracting internal links...');
for (const file of htmlFiles) {
if (filesToIgnore.some((f) => file.includes(f))) {
console.log(`Skipping file since matching manual exclude list: ${file}`);
continue;
}
try {
const content = fs.readFileSync(file, 'utf-8');
const links = extractInternalLinks(content, file);
links.forEach((link) => {
const existing = linksToFiles.get(link);
if (existing) {
existing.push(file);
linksToFiles.set(link, existing);
} else {
linksToFiles.set(link, [file]);
}
});
} catch (err) {
console.error(`Error reading file ${file}:`, err);
process.exit(1);
}
}
const filteredLinks = Array.from(linksToFiles.keys()).filter(
(href) => !ignoredLinks.some((il) => href.includes(il))
);
const actualLinksUsed = new Set(filteredLinks);
console.log(
`Extracted ${actualLinksUsed.size} total internal links from ${htmlFiles.length} files\n`
);
console.log('📍 Parsing sitemap for valid routes...');
const availableInternalRoutes = loadAllSitemapRoutes();
console.log(
`Found ${availableInternalRoutes.size} unique routes in sitemap\n`
);
console.log('✅ Validating astro internal links...\n');
// Find links that exist in actualLinksUsed but not in availableInternalRoutes
const brokenLinks: Set<string> = new Set(
[...actualLinksUsed].filter((link) => !availableInternalRoutes.has(link))
);
let hasBrokenLinks = false;
if (brokenLinks.size > 0) {
hasBrokenLinks = true;
console.log(`Found ${brokenLinks.size} broken links:\n`);
const filesWithErrors = new Map<string, string[]>();
brokenLinks.forEach((link) => {
const files = linksToFiles.get(link);
if (!files) {
throw new Error(
`Unable to find file where link was parsed from: ${link}`
);
}
files.forEach((f) => {
const existing = filesWithErrors.get(f);
if (existing) {
existing.push(link);
filesWithErrors.set(f, existing);
} else {
filesWithErrors.set(f, [link]);
}
});
});
for (const [file, badLinks] of filesWithErrors) {
console.log(
`\n❌ ${toFriendlyName(file)} has ${badLinks.length} broken links:`
);
badLinks.forEach((link) => console.log(`\t- ${link}`));
}
console.log(
`\n🔎 Check the above output to resolve the ${brokenLinks.size} broken links in each respecitve source (.mdoc, .astro, and/or content collection generation`
);
}
if (hasBrokenLinks) {
process.exit(1);
}
process.exit(0);
}
validateLinks();