227 lines
9.4 KiB
JavaScript
227 lines
9.4 KiB
JavaScript
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;
|
|
});
|
|
}
|