import { readFile, stat } from 'node:fs/promises'; import { isAbsolute, relative, resolve, sep } from 'node:path'; const SOURCE_COUNT = 29; const DOCS_ROOT = 'src/content/docs/'; const CANONICAL_ENTRIES = new Map([ ['pages/start.txt', ['src/content/docs/index.mdx', 'page']], ['pages/study.txt', ['src/content/docs/index.mdx', 'merge']], ['pages/study/msv_incognito.txt', ['src/content/docs/about-incognito.md', 'page']], ['pages/study/bachelor.txt', ['src/content/docs/data-science-and-ai/index.md', 'page']], ['pages/study/bachelor/year_1.txt', ['src/content/docs/data-science-and-ai/year-1/index.md', 'page']], ['pages/study/bachelor/year_1/project_1-1.txt', ['src/content/docs/data-science-and-ai/year-1/project-1-1.md', 'page']], ['pages/study/bachelor/year_1/project_1-2.txt', ['src/content/docs/data-science-and-ai/year-1/project-1-2.md', 'page']], ['pages/study/bachelor/year_2.txt', ['src/content/docs/data-science-and-ai/year-2/index.md', 'page']], ['pages/study/bachelor/year_2/honours_programme.txt', ['src/content/docs/data-science-and-ai/year-2/honours-programme.md', 'page']], ['pages/study/bachelor/year_2/project_2-1.txt', ['src/content/docs/data-science-and-ai/year-2/project-2-1.md', 'page']], ['pages/study/bachelor/year_2/project_2-2.txt', ['src/content/docs/data-science-and-ai/year-2/project-2-2.md', 'page']], ['pages/study/bachelor/year_3.txt', ['src/content/docs/data-science-and-ai/year-3/index.md', 'page']], ['pages/study/bachelor/year_3/bachelors_thesis.txt', ['src/content/docs/data-science-and-ai/year-3/bachelors-thesis.md', 'page']], ['pages/study/bachelor/year_3/honours_programme.txt', ['src/content/docs/data-science-and-ai/year-3/honours-programme.md', 'page']], ['pages/study/bachelor/year_3/project_3-1.txt', ['src/content/docs/data-science-and-ai/year-3/project-3-1.md', 'page']], ['pages/study/bachelor/year_3/study_abroad.txt', ['src/content/docs/data-science-and-ai/year-3/study-abroad.md', 'page']], ['pages/study/master_ai.txt', ['src/content/docs/master-ai/index.md', 'page']], ['pages/study/master_ai/year_1.txt', ['src/content/docs/master-ai/year-1/index.md', 'page']], ['pages/study/master_ai/year_1/project_mai_1.txt', ['src/content/docs/master-ai/year-1/research-project-1.md', 'page']], ['pages/study/master_ai/year_1/project_mai_2.txt', ['src/content/docs/master-ai/year-1/research-project-2.md', 'page']], ['pages/study/master_ai/year_2.txt', ['src/content/docs/master-ai/year-2/index.md', 'page']], ['pages/study/master_dsdm.txt', ['src/content/docs/master-dsdm/index.md', 'page']], ['pages/study/master_dsdm/year_1.txt', ['src/content/docs/master-dsdm/year-1/index.md', 'page']], ['pages/study/master_dsdm/year_2.txt', ['src/content/docs/master-dsdm/year-2/index.md', 'page']], ['pages/study/useful_information.txt', ['src/content/docs/useful-information/index.md', 'page']], ['pages/study/useful_information/pages/dke_locations.txt', ['src/content/docs/useful-information/dke-locations.md', 'page']], ['pages/study/useful_information/pages/handy_locations.txt', ['src/content/docs/useful-information/handy-locations.md', 'page']], ['pages/study/useful_information/pages/it_services.txt', ['src/content/docs/useful-information/it-services.md', 'page']], ['pages/study/useful_information/pages/laptop_buy_advice.txt', ['src/content/docs/useful-information/laptop-buying-advice.md', 'page']], ]); function normalizeSeparators(path) { return path.replaceAll('\\', '/'); } function hasParentTraversal(path) { return normalizeSeparators(path).split('/').includes('..'); } function isContained(root, candidate) { const path = relative(root, candidate); return path === '' || (!isAbsolute(path) && path !== '..' && !path.startsWith(`..${sep}`)); } export async function loadManifest(path) { return JSON.parse(await readFile(path, 'utf8')); } export function validateManifest(manifest) { if (!Array.isArray(manifest?.entries)) { throw new TypeError('Migration manifest entries must be an array.'); } if (manifest.entries.length !== SOURCE_COUNT) { throw new Error(`Migration manifest must contain exactly ${SOURCE_COUNT} entries.`); } const sources = new Set(); const destinations = new Set(); for (const entry of manifest.entries) { if (!entry || typeof entry !== 'object') { throw new TypeError('Each migration manifest entry must be an object.'); } const { source, destination, mode } = entry; if (typeof source !== 'string' || typeof destination !== 'string') { throw new TypeError('Each migration manifest entry needs string source and destination paths.'); } if (!['page', 'merge'].includes(mode)) { throw new TypeError('Each migration manifest entry mode must be page or merge.'); } const normalizedSource = normalizeSeparators(source); const normalizedDestination = normalizeSeparators(destination); const virtualProjectRoot = resolve(sep, 'migration-project'); const virtualDocsRoot = resolve(virtualProjectRoot, 'src/content/docs'); const resolvedDestination = resolve(virtualProjectRoot, normalizedDestination); if (normalizedSource.startsWith('pages/wiki/')) { throw new Error(`Generic DokuWiki source is excluded: ${source}`); } if (hasParentTraversal(source) || !(normalizedSource === 'pages/start.txt' || normalizedSource === 'pages/study.txt' || normalizedSource.startsWith('pages/study/'))) { throw new Error(`Source is outside the selected migration scope: ${source}`); } if (hasParentTraversal(destination) || !normalizedDestination.startsWith(DOCS_ROOT) || !isContained(virtualDocsRoot, resolvedDestination)) { throw new Error(`Destination is outside ${DOCS_ROOT}: ${destination}`); } if (sources.has(source)) { throw new Error(`Duplicate migration source: ${source}`); } if (destinations.has(destination) && mode !== 'merge') { throw new Error(`Duplicate destination requires a later merge entry: ${destination}`); } sources.add(source); destinations.add(destination); } for (const { source, destination, mode } of manifest.entries) { const [canonicalDestination, canonicalMode] = CANONICAL_ENTRIES.get(source) ?? []; if (canonicalDestination !== destination || canonicalMode !== mode) { throw new Error(`Entry differs from the canonical migration contract: ${source} -> ${destination} (${mode}).`); } } return { entries: manifest.entries }; } export async function checkDestinations(manifest, root) { const destinations = [...new Set(manifest.entries.map(({ destination }) => destination))]; const missing = []; const notFiles = []; const projectRoot = resolve(root); const docsRoot = resolve(projectRoot, 'src/content/docs'); for (const destination of destinations) { const candidate = resolve(projectRoot, normalizeSeparators(destination)); if (!isContained(docsRoot, candidate)) { throw new Error(`Destination resolves outside ${DOCS_ROOT}: ${destination}`); } try { const details = await stat(candidate); if (!details.isFile()) notFiles.push(destination); } catch { missing.push(destination); } } return { total: manifest.entries.length, missing, notFiles, }; }