import { access, readFile, readdir } from 'node:fs/promises'; import { join, relative, resolve, sep } from 'node:path'; import { pathToFileURL } from 'node:url'; import { load as loadYaml } from 'js-yaml'; import { checkDestinations, loadManifest, validateManifest } from './lib/migration.mjs'; const DOCS_PREFIX = 'src/content/docs/'; const FORBIDDEN_TOKENS = ['[[', '{{', 'NEWPAGE>', 'indexmenu>', '~~NOCACHE~~']; const OUTDATED_NOTICE = 'This information originated in the previous wiki and may be outdated.'; const PAGE_NOTICE = ':::note[Awaiting content]\nThis page is awaiting content.\n:::'; const SECTION_NOTICE = ':::note[Awaiting content]\nThis section is awaiting content.\n:::'; const EMPTY_PAGE_NOTICES = new Map([ ['data-science-and-ai/year-2/honours-programme.md', PAGE_NOTICE], ['data-science-and-ai/year-3/honours-programme.md', PAGE_NOTICE], ['useful-information/handy-locations.md', SECTION_NOTICE], ]); export class ContentAuditError extends Error { constructor(issues) { super(`Content audit failed with ${issues.length} issue(s):\n${issues.map((issue) => `- ${issue}`).join('\n')}`); this.name = 'ContentAuditError'; this.issues = issues; } } async function listContentFiles(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 listContentFiles(root, path)); else if (entry.isFile() && /\.mdx?$/.test(entry.name)) files.push(path); } return files.sort(); } async function pathExists(path) { try { await access(path); return true; } catch { return false; } } async function containsNamedDirectory(root, name) { if (!await pathExists(root)) return false; const entries = await readdir(root, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; if (entry.name === name || await containsNamedDirectory(join(root, entry.name), name)) return true; } return false; } function frontmatterIssue(content) { const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); if (!match) return 'is missing YAML frontmatter'; let frontmatter; try { frontmatter = loadYaml(match[1]); } catch (error) { return `has malformed YAML frontmatter: ${error.reason ?? error.message}`; } for (const field of ['title', 'description']) { const value = frontmatter && typeof frontmatter === 'object' ? frontmatter[field] : undefined; if (typeof value !== 'string' || !value.trim()) return `is missing non-empty ${field} frontmatter`; } return null; } function reportRows(report) { return [...report.matchAll(/^\|\s*`(pages\/[^`]+)`\s*\|\s*`([^`]+)`\s*\|\s*(page|merge)\s*\|/gm)] .map(([, source, destination, mode]) => ({ source, destination, mode })); } function validateSupplemental(supplemental) { if (!Array.isArray(supplemental)) throw new TypeError('supplemental content registry must be an array'); const destinations = new Set(); for (const [index, entry] of supplemental.entries()) { if (!entry || typeof entry !== 'object') throw new TypeError(`supplemental entry ${index} must be an object`); for (const field of ['destination', 'source', 'category']) { if (typeof entry[field] !== 'string' || !entry[field].trim()) { throw new TypeError(`supplemental entry ${index} is missing non-empty ${field}`); } } if (!entry.destination.startsWith(DOCS_PREFIX) || !/\.mdx?$/.test(entry.destination)) { throw new TypeError(`supplemental destination must be a Markdown file under ${DOCS_PREFIX}: ${entry.destination}`); } if (destinations.has(entry.destination)) throw new TypeError(`duplicate supplemental destination: ${entry.destination}`); destinations.add(entry.destination); } return destinations; } function projectRootFor(docsRoot) { const normalized = resolve(docsRoot); const projectRoot = resolve(normalized, '..', '..', '..'); if (resolve(projectRoot, 'src', 'content', 'docs') !== normalized) { throw new TypeError(`docsRoot must identify src/content/docs: ${docsRoot}`); } return projectRoot; } export async function auditContent({ docsRoot, manifest, supplemental = [] }) { const absoluteDocsRoot = resolve(docsRoot); const projectRoot = projectRootFor(absoluteDocsRoot); const issues = []; let entries; let supplementalDestinationSet; try { ({ entries } = validateManifest(manifest)); supplementalDestinationSet = validateSupplemental(supplemental); } catch (error) { throw new ContentAuditError([error.message]); } const sourceCount = new Set(entries.map(({ source }) => source)).size; const destinations = [...new Set(entries.map(({ destination }) => destination))]; const destinationCount = destinations.length; if (sourceCount !== 29) issues.push(`expected 29 unique sources, found ${sourceCount}`); if (destinationCount !== 28) issues.push(`expected 28 unique destinations, found ${destinationCount}`); const destinationResult = await checkDestinations({ entries }, projectRoot); for (const destination of destinationResult.missing) issues.push(`missing manifest destination: ${destination}`); for (const destination of destinationResult.notFiles) issues.push(`manifest destination is not a regular file: ${destination}`); const contentFiles = await listContentFiles(absoluteDocsRoot); const contentByDestination = new Map(); for (const path of contentFiles) { const localPath = relative(absoluteDocsRoot, path).split(sep).join('/'); const destination = `${DOCS_PREFIX}${localPath}`; const content = await readFile(path, 'utf8'); contentByDestination.set(destination, content); const frontmatter = frontmatterIssue(content); if (frontmatter) issues.push(`${destination} ${frontmatter}`); for (const token of FORBIDDEN_TOKENS) { if (content.includes(token)) issues.push(`${destination} contains forbidden DokuWiki token ${JSON.stringify(token)}`); } } const manifestDestinationSet = new Set(destinations); const publishedDestinationSet = new Set(contentByDestination.keys()); for (const destination of manifestDestinationSet) { if (!publishedDestinationSet.has(destination)) { issues.push(`manifest destination is absent from published content: ${destination}`); } } for (const destination of publishedDestinationSet) { if (!manifestDestinationSet.has(destination) && !supplementalDestinationSet.has(destination)) { issues.push(`published content file is absent from manifest: ${destination}`); } } for (const destination of supplementalDestinationSet) { if (!publishedDestinationSet.has(destination)) { issues.push(`supplemental destination is absent from published content: ${destination}`); } if (manifestDestinationSet.has(destination)) { issues.push(`supplemental destination duplicates a migration-manifest destination: ${destination}`); } } for (const [localPath, notice] of EMPTY_PAGE_NOTICES) { const content = contentByDestination.get(`${DOCS_PREFIX}${localPath}`) ?? ''; if (!content.includes(notice)) issues.push(`${DOCS_PREFIX}${localPath} is missing the exact awaiting-content notice`); } const reportPath = join(projectRoot, 'docs', 'migration-report.md'); let report = ''; try { report = await readFile(reportPath, 'utf8'); } catch { issues.push('docs/migration-report.md is missing'); } if (report) { if (report.includes('pages/wiki/')) issues.push('migration report includes an excluded pages/wiki/ source'); const rows = reportRows(report); if (rows.length !== 29) issues.push(`migration report must contain 29 source rows, found ${rows.length}`); const rowBySource = new Map(rows.map((row) => [row.source, row])); for (const entry of entries) { const row = rowBySource.get(entry.source); if (!row) issues.push(`migration report is missing source row: ${entry.source}`); else if (row.destination !== entry.destination || row.mode !== entry.mode) { issues.push(`migration report mapping differs from manifest: ${entry.source}`); } } } if (entries.some(({ source }) => source.startsWith('pages/wiki/'))) { issues.push('manifest includes an excluded pages/wiki/ source'); } if (await containsNamedDirectory(absoluteDocsRoot, 'to-be-studied')) { issues.push('to-be-studied must not appear under src/content/docs'); } if (await containsNamedDirectory(join(projectRoot, 'public'), 'to-be-studied')) { issues.push('to-be-studied must not appear under public'); } if (issues.length > 0) throw new ContentAuditError(issues); return { sourceCount, destinationCount, pageCount: contentFiles.length, supplementalCount: supplementalDestinationSet.size, outdatedNotice: OUTDATED_NOTICE, }; } async function main() { const manifest = await loadManifest('docs/migration-manifest.json'); const supplemental = JSON.parse(await readFile('docs/supplemental-content.json', 'utf8')); const result = await auditContent({ docsRoot: 'src/content/docs', manifest, supplemental }); console.log(`Content audit passed: ${result.sourceCount} sources, ${result.destinationCount} unique destinations, ${result.pageCount} published pages.`); } const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''; if (invokedPath === import.meta.url) { main().catch((error) => { console.error(error.message); process.exitCode = 1; }); }