Incognito-Wiki/scripts/audit-content.mjs

186 lines
7.5 KiB
JavaScript

import { access, readFile, readdir } from 'node:fs/promises';
import { join, relative, resolve, sep } from 'node:path';
import { pathToFileURL } from 'node:url';
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([
['bachelor/year-2/honours-programme.md', PAGE_NOTICE],
['bachelor/year-3/honours-programme.md', PAGE_NOTICE],
['useful-information/handy-locations.md', SECTION_NOTICE],
]);
const SCHEDULE_DESTINATIONS = [
'src/content/docs/bachelor/year-1/index.md',
'src/content/docs/bachelor/year-2/index.md',
];
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';
for (const field of ['title', 'description']) {
const fieldMatch = match[1].match(new RegExp(`^${field}:\\s*(.+?)\\s*$`, 'm'));
if (!fieldMatch || /^(['"]{2})$/.test(fieldMatch[1])) 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 projectRootFor(docsRoot) {
const normalized = resolve(docsRoot);
const suffix = ['src', 'content', 'docs'].join(sep);
if (!normalized.endsWith(suffix)) {
throw new TypeError(`docsRoot must identify src/content/docs: ${docsRoot}`);
}
return resolve(normalized, '..', '..', '..');
}
export async function auditContent({ docsRoot, manifest }) {
const absoluteDocsRoot = resolve(docsRoot);
const projectRoot = projectRootFor(absoluteDocsRoot);
const issues = [];
let entries;
try {
({ entries } = validateManifest(manifest));
} 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}`);
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)}`);
}
}
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`);
}
for (const destination of SCHEDULE_DESTINATIONS) {
const content = contentByDestination.get(destination) ?? '';
if (!content.includes('study:dke-schedule.png')) issues.push(`${destination} does not document missing study:dke-schedule.png`);
}
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}`);
}
}
for (const destination of SCHEDULE_DESTINATIONS) {
if (!report.includes(destination)) issues.push(`migration report does not identify schedule destination ${destination}`);
}
if (!report.includes('study:dke-schedule.png')) issues.push('migration report does not document study:dke-schedule.png');
}
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,
outdatedNotice: OUTDATED_NOTICE,
};
}
async function main() {
const manifest = await loadManifest('docs/migration-manifest.json');
const result = await auditContent({ docsRoot: 'src/content/docs', manifest });
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;
});
}