Files
fluxer/fluxer_docs/scripts/VerifyDocsSidebar.ts
T

188 lines
5.9 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {readFile} from 'node:fs/promises';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {DOCS_ROOT, listMarkdownFiles, slugifyHeading} from './DocsSource.ts';
const ASTRO_CONFIG = fileURLToPath(new URL('../astro.config.ts', import.meta.url));
const SIDEBAR_START = 'sidebar: [';
const QUOTED_ENTRY = /'([^']+)'/gu;
const LINK_ENTRY = /link:\s*'([^']+)'/gu;
function slugOf(file: string): string {
const relative = path.relative(DOCS_ROOT, file);
const withoutExtension = relative.replace(/\.(mdx|md)$/u, '');
if (withoutExtension === 'index') {
return '';
}
return withoutExtension.replace(/\/index$/u, '');
}
function sidebarBlock(source: string): string {
const start = source.indexOf(SIDEBAR_START);
if (start === -1) {
throw new Error('sidebar block not found in astro.config.ts');
}
let depth = 0;
for (let cursor = start + SIDEBAR_START.length - 1; cursor < source.length; cursor += 1) {
const character = source[cursor];
if (character === '[') {
depth += 1;
}
if (character === ']') {
depth -= 1;
if (depth === 0) {
return source.slice(start, cursor + 1);
}
}
}
throw new Error('sidebar block is unbalanced');
}
const config = await readFile(ASTRO_CONFIG, 'utf8');
const block = sidebarBlock(config);
const labels = new Set<string>();
for (const match of block.matchAll(/label:\s*'([^']+)'/gu)) {
labels.add(match[1]);
}
const linkSlugs = new Set<string>();
for (const match of block.matchAll(LINK_ENTRY)) {
linkSlugs.add(match[1].replace(/^\//u, '').replace(/\/$/u, ''));
}
const referenced = new Set<string>(linkSlugs);
for (const match of block.matchAll(QUOTED_ENTRY)) {
const value = match[1];
if (labels.has(value)) {
continue;
}
if (value.startsWith('/')) {
continue;
}
if (!value.includes('/') && !value.match(/^[a-z0-9-]+$/u)) {
continue;
}
referenced.add(value);
}
const files = await listMarkdownFiles(DOCS_ROOT);
const actual = new Set(files.map(slugOf));
const missingPages = [...referenced].filter((slug) => !actual.has(slug)).sort();
const orphanPages = [...actual].filter((slug) => !referenced.has(slug)).sort();
console.log(`sidebar entries: ${referenced.size.toString()}`);
console.log(`content pages: ${actual.size.toString()}`);
let failures = 0;
if (missingPages.length > 0) {
console.log('');
console.log(`sidebar references a page that does not exist: ${missingPages.length.toString()}`);
for (const slug of missingPages) {
console.log(` ${slug}`);
}
failures += missingPages.length;
}
if (orphanPages.length > 0) {
console.log('');
console.log(`page exists but is not in the sidebar: ${orphanPages.length.toString()}`);
for (const slug of orphanPages) {
console.log(` ${slug === '' ? '(index)' : slug}`);
}
failures += orphanPages.length;
}
const INTERNAL_LINK = /\]\((\/[^)\s#]*)(#[^)\s]*)?\)/gu;
const SAME_PAGE_LINK = /\]\((#[^)\s]*)\)/gu;
const HEADING = /^#{2,6}\s+(.+?)\s*$/u;
const EXPLICIT_ANCHOR = /<[a-z]+\s+id=["']([^"']+)["']/gu;
const dangling: Array<string> = [];
const anchorable = new Set(actual);
const anchorsByPage = new Map<string, Set<string>>();
const ambiguousByPage = new Map<string, Set<string>>();
for (const file of files) {
const slug = slugOf(file);
const source = await readFile(file, 'utf8');
const anchors = new Set<string>();
const counts = new Map<string, number>();
for (const line of source.split('\n')) {
const heading = line.match(HEADING);
if (heading != null) {
const generated = slugifyHeading(heading[1]);
anchors.add(generated);
counts.set(generated, (counts.get(generated) ?? 0) + 1);
}
}
for (const explicit of source.matchAll(EXPLICIT_ANCHOR)) {
anchors.add(explicit[1]);
}
anchorsByPage.set(slug, anchors);
const ambiguous = new Set<string>();
for (const [generated, count] of counts) {
if (count > 1 && !source.includes(`id="${generated}"`)) {
ambiguous.add(generated);
}
}
ambiguousByPage.set(slug, ambiguous);
}
for (const file of files) {
const relative = path.relative(DOCS_ROOT, file);
const selfSlug = slugOf(file);
const source = await readFile(file, 'utf8');
for (const match of source.matchAll(INTERNAL_LINK)) {
const target = match[1].replace(/^\//u, '').replace(/\/$/u, '');
if (target.length === 0) {
if (match[2] != null) {
const fragment = match[2].slice(1);
const rootAnchors = anchorsByPage.get('');
if (rootAnchors != null && fragment.length > 0 && !rootAnchors.has(fragment)) {
dangling.push(`${relative} -> /#${fragment} (anchor)`);
}
}
continue;
}
if (!anchorable.has(target)) {
dangling.push(`${relative} -> /${target}/`);
continue;
}
if (match[2] != null) {
const fragment = match[2].slice(1);
const anchors = anchorsByPage.get(target);
if (fragment.length > 0 && anchors != null && !anchors.has(fragment)) {
dangling.push(`${relative} -> /${target}/#${fragment} (anchor)`);
} else if (fragment.length > 0 && (ambiguousByPage.get(target)?.has(fragment) ?? false)) {
dangling.push(`${relative} -> /${target}/#${fragment} (that heading repeats on the target page)`);
}
}
}
for (const match of source.matchAll(SAME_PAGE_LINK)) {
const fragment = match[1].slice(1);
const anchors = anchorsByPage.get(selfSlug);
if (fragment.length > 0 && anchors != null && !anchors.has(fragment)) {
dangling.push(`${relative} -> #${fragment} (same-page anchor)`);
} else if (fragment.length > 0 && (ambiguousByPage.get(selfSlug)?.has(fragment) ?? false)) {
dangling.push(`${relative} -> #${fragment} (that heading repeats on this page)`);
}
}
}
if (dangling.length > 0) {
console.log('');
console.log(`links to a page that does not exist: ${dangling.length.toString()}`);
for (const entry of [...new Set(dangling)].sort()) {
console.log(` ${entry}`);
}
failures += dangling.length;
}
if (failures > 0) {
console.error(`FAIL: ${failures.toString()} structure problems`);
process.exit(1);
}
console.log('OK: sidebar, content and internal links are in sync');