Incognito-Wiki/scripts/lib/migration.mjs
msa46 bcb8dcfe2a
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
fix: resolve final Starlight migration review
2026-08-02 18:59:28 +02:00

140 lines
7 KiB
JavaScript

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/bachelor/index.md', 'page']],
['pages/study/bachelor/year_1.txt', ['src/content/docs/bachelor/year-1/index.md', 'page']],
['pages/study/bachelor/year_1/project_1-1.txt', ['src/content/docs/bachelor/year-1/project-1-1.md', 'page']],
['pages/study/bachelor/year_1/project_1-2.txt', ['src/content/docs/bachelor/year-1/project-1-2.md', 'page']],
['pages/study/bachelor/year_2.txt', ['src/content/docs/bachelor/year-2/index.md', 'page']],
['pages/study/bachelor/year_2/honours_programme.txt', ['src/content/docs/bachelor/year-2/honours-programme.md', 'page']],
['pages/study/bachelor/year_2/project_2-1.txt', ['src/content/docs/bachelor/year-2/project-2-1.md', 'page']],
['pages/study/bachelor/year_2/project_2-2.txt', ['src/content/docs/bachelor/year-2/project-2-2.md', 'page']],
['pages/study/bachelor/year_3.txt', ['src/content/docs/bachelor/year-3/index.md', 'page']],
['pages/study/bachelor/year_3/bachelors_thesis.txt', ['src/content/docs/bachelor/year-3/bachelors-thesis.md', 'page']],
['pages/study/bachelor/year_3/honours_programme.txt', ['src/content/docs/bachelor/year-3/honours-programme.md', 'page']],
['pages/study/bachelor/year_3/project_3-1.txt', ['src/content/docs/bachelor/year-3/project-3-1.md', 'page']],
['pages/study/bachelor/year_3/study_abroad.txt', ['src/content/docs/bachelor/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,
};
}