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

175 lines
5.2 KiB
TypeScript

import { readFile } from 'node:fs/promises';
import { glob } from 'node:fs/promises';
import { resolve } from 'node:path';
import {
type FileObject,
printErrors,
scanURLs,
validateFiles,
} from 'next-validate-link';
import GithubSlugger from 'github-slugger';
import {
source,
getReferenceSource,
examplesSource,
toolkitsSource,
} from '../lib/source';
type AnySource =
| typeof source
| Awaited<ReturnType<typeof getReferenceSource>>
| typeof examplesSource
| typeof toolkitsSource;
type PageOf = ReturnType<AnySource['getPages']>[number];
/**
* Extract heading anchors from raw MDX/markdown content.
* Falls back to this when data.toc is unavailable (outside Next.js runtime).
* Uses github-slugger to match rehype-slug's algorithm (handles Unicode, duplicate suffixes).
*/
function extractHeadingsFromContent(content: string): string[] {
const slugger = new GithubSlugger();
const headings: string[] = [];
let inCodeBlock = false;
for (const line of content.split('\n')) {
if (line.startsWith('```')) {
inCodeBlock = !inCodeBlock;
continue;
}
if (inCodeBlock) continue;
const match = line.match(/^#{1,6}\s+(.+)$/);
if (match) {
headings.push(slugger.slug(match[1]));
}
}
return headings;
}
/**
* Get headings for a page, trying data.toc first then falling back to raw content parsing.
*/
async function getHeadingsForPage(page: PageOf): Promise<string[]> {
if (page.data.toc?.length) {
return page.data.toc.map((item: { url: string }) => item.url.slice(1));
}
if ('getText' in page.data) {
try {
const content = await (page.data as { getText: (mode: string) => Promise<string> }).getText('raw');
return extractHeadingsFromContent(content);
} catch {
// fall through
}
}
if (page.absolutePath) {
try {
const content = await readFile(page.absolutePath, 'utf-8');
return extractHeadingsFromContent(content);
} catch {
// fall through
}
}
return [];
}
/**
* Build populate entries for a source, resolving headings asynchronously.
*/
async function buildPopulateEntries(src: AnySource) {
return Promise.all(
src.getPages().map(async (page: PageOf) => ({
value: { slug: page.slugs },
hashes: await getHeadingsForPage(page),
})),
);
}
async function getDynamicToolkitEntries() {
const raw = await readFile('public/data/toolkits.json', 'utf-8');
const toolkits: { slug: string }[] = JSON.parse(raw);
return toolkits.map((t) => ({ value: { slug: [t.slug] }, hashes: [] as string[] }));
}
async function checkLinks() {
const referenceSource = await getReferenceSource();
const [docsEntries, refEntries, exampleEntries, toolkitEntries, dynamicToolkitEntries] = await Promise.all([
buildPopulateEntries(source),
buildPopulateEntries(referenceSource),
buildPopulateEntries(examplesSource),
buildPopulateEntries(toolkitsSource),
getDynamicToolkitEntries(),
]);
const scanned = await scanURLs({
preset: 'next',
populate: {
// Keys must include (home) route group to match app directory structure
'(home)/docs/[[...slug]]': docsEntries,
'(home)/reference/[[...slug]]': refEntries,
'(home)/examples/[[...slug]]': exampleEntries,
'(home)/toolkits/[[...slug]]': [...toolkitEntries, ...dynamicToolkitEntries],
},
});
const errors = await validateFiles(await getFiles(), {
scanned,
markdown: {
components: {
Card: { attributes: ['href'] },
},
},
checkRelativePaths: 'as-url',
});
// Filter out API route URLs (these are valid but not detected as pages)
const ignoredUrls = ['/llms.txt', '/llms-full.txt'];
const filteredErrors = errors
.map((fileError) => ({
...fileError,
errors: fileError.errors.filter((e) => !ignoredUrls.includes(e.url)),
detected: fileError.detected.filter((d) => !ignoredUrls.includes(d[0] as string)),
}))
.filter((fileError) => fileError.errors.length > 0);
printErrors(filteredErrors, true);
if (filteredErrors.length > 0) {
process.exit(1);
}
}
async function getFiles(): Promise<FileObject[]> {
const referenceSource = await getReferenceSource();
const sources = [source, referenceSource, examplesSource, toolkitsSource];
const allFiles: FileObject[] = [];
for (const src of sources) {
const pages = src.getPages();
for (const page of pages) {
if (!page.absolutePath) continue;
if (!page.absolutePath.endsWith('.mdx') && !page.absolutePath.endsWith('.md')) continue;
// Skip OpenAPI-generated pages (they don't have getText)
if (!('getText' in page.data)) continue;
allFiles.push({
path: page.absolutePath,
content: await (page.data as { getText: (mode: string) => Promise<string> }).getText('raw'),
url: page.url,
data: page.data,
});
}
}
// Scan any .md files under content/ not already covered by Fumadocs sources
const coveredPaths = new Set(allFiles.map((f) => resolve(f.path)));
const extraMdFiles = await Array.fromAsync(glob('content/**/*.md'));
for (const filePath of extraMdFiles) {
if (coveredPaths.has(resolve(filePath))) continue;
const content = await readFile(filePath, 'utf-8');
allFiles.push({ path: filePath, content });
}
return allFiles;
}
void checkLinks();