test: verify migrated wiki content and links

This commit is contained in:
msa46 2026-08-02 17:57:41 +02:00
parent ebaa5eff92
commit c9dda6c6d6
7 changed files with 490 additions and 1 deletions

71
docs/migration-report.md Normal file
View file

@ -0,0 +1,71 @@
# DokuWiki migration report
This report accounts for the 29 selected DokuWiki source pages and their 28 unique Starlight destinations. `pages/start.txt` and `pages/study.txt` were intentionally combined on the landing page. Generic DokuWiki documentation was outside the selected source scope. Newer live-wiki captures remain research material under `to-be-studied/` and are not published.
## Source inventory
“Outdated” records whether the destination displays the standard warning that its migrated information may be outdated. “None” means that the source had no item in that category, not that it was skipped during review.
| Source | Destination | Mode | Outdated | Repaired or removed links | Missing assets | Significant spelling or grammar fixes |
| --- | --- | --- | --- | --- | --- | --- |
| `pages/start.txt` | `src/content/docs/index.mdx` | page | Yes, on migrated programme notes | Removed nonexistent Minutes and Manuals targets; replaced association and university destinations with working public links | None | Recast the welcome copy and normalized programme terminology |
| `pages/study.txt` | `src/content/docs/index.mdx` | merge | Yes | Replaced namespace navigation with landing-page cards and official links | None | Corrected punctuation, capitalization, and bachelor/master wording |
| `pages/study/msv_incognito.txt` | `src/content/docs/about-incognito.md` | page | No; concise current association overview | Replaced the old namespace link with the public Incognito site | None | Simplified an incomplete association description without adding historical claims |
| `pages/study/bachelor.txt` | `src/content/docs/bachelor/index.md` | page | Yes | Converted year namespace links to Starlight routes; removed DokuWiki editing controls | None | Corrected bachelor capitalization and sentence structure |
| `pages/study/bachelor/year_1.txt` | `src/content/docs/bachelor/year-1/index.md` | page | Yes | Converted project links to local routes; removed page-creation and index-menu controls | `study:dke-schedule.png` documented as absent | Corrected schedule wording and standardized Year 1/block capitalization |
| `pages/study/bachelor/year_1/project_1-1.txt` | `src/content/docs/bachelor/year-1/project-1-1.md` | page | Yes | Converted related-project links and removed editing controls | None | Corrected punctuation and clarified prerequisite prose without changing requirements |
| `pages/study/bachelor/year_1/project_1-2.txt` | `src/content/docs/bachelor/year-1/project-1-2.md` | page | Yes | Converted related-project links and removed editing controls | None | Corrected grammar, spacing, and capitalization |
| `pages/study/bachelor/year_2.txt` | `src/content/docs/bachelor/year-2/index.md` | page | Yes | Converted project links; removed DokuWiki controls and the malformed media-upload link | `study:dke-schedule.png` documented as absent | Corrected schedule wording and Year 2/block capitalization |
| `pages/study/bachelor/year_2/honours_programme.txt` | `src/content/docs/bachelor/year-2/honours-programme.md` | page | Yes | Removed empty DokuWiki navigation/edit controls | None | Preserved the empty page as an explicit awaiting-content placeholder |
| `pages/study/bachelor/year_2/project_2-1.txt` | `src/content/docs/bachelor/year-2/project-2-1.md` | page | Yes | Repaired the nested route to the Year 1 prerequisite; removed editing controls | None | Corrected grammar and standardized project naming |
| `pages/study/bachelor/year_2/project_2-2.txt` | `src/content/docs/bachelor/year-2/project-2-2.md` | page | Yes | Repaired the nested route to the Year 1 prerequisite; removed editing controls | None | Corrected punctuation and standardized project naming |
| `pages/study/bachelor/year_3.txt` | `src/content/docs/bachelor/year-3/index.md` | page | Yes | Converted thesis and project links; removed DokuWiki navigation controls | None | Corrected block references and the schedule explanation |
| `pages/study/bachelor/year_3/bachelors_thesis.txt` | `src/content/docs/bachelor/year-3/bachelors-thesis.md` | page | Yes | Converted related-study links and removed editing controls | None | Corrected possessive bachelors wording, punctuation, and sentence flow |
| `pages/study/bachelor/year_3/honours_programme.txt` | `src/content/docs/bachelor/year-3/honours-programme.md` | page | Yes | Removed empty DokuWiki navigation/edit controls | None | Preserved the source title and made the empty state explicit |
| `pages/study/bachelor/year_3/project_3-1.txt` | `src/content/docs/bachelor/year-3/project-3-1.md` | page | Yes | Converted prerequisite links and removed editing controls | None | Corrected grammar while retaining the stated prerequisites and reading |
| `pages/study/bachelor/year_3/study_abroad.txt` | `src/content/docs/bachelor/year-3/study-abroad.md` | page | Yes, with a 2022-specific warning | Retained useful external destinations as historical references and removed editing controls | None | Corrected grammar; did not silently modernize the dated eligibility claims |
| `pages/study/master_ai.txt` | `src/content/docs/master-ai/index.md` | page | Yes | Converted year links to final routes and removed namespace controls | None | Standardized masters-programme capitalization and punctuation |
| `pages/study/master_ai/year_1.txt` | `src/content/docs/master-ai/year-1/index.md` | page | Yes | Converted research-project links and removed editing controls | None | Corrected sentence structure and project naming |
| `pages/study/master_ai/year_1/project_mai_1.txt` | `src/content/docs/master-ai/year-1/research-project-1.md` | page | Yes | Repaired internal navigation to the programme hierarchy | None | Corrected punctuation and rendered the project name consistently |
| `pages/study/master_ai/year_1/project_mai_2.txt` | `src/content/docs/master-ai/year-1/research-project-2.md` | page | Yes | Repaired internal navigation to the programme hierarchy | None | Corrected punctuation and rendered the project name consistently |
| `pages/study/master_ai/year_2.txt` | `src/content/docs/master-ai/year-2/index.md` | page | Yes | Removed obsolete DokuWiki page-creation and navigation controls | None | Reworded incomplete prose as historical programme guidance |
| `pages/study/master_dsdm.txt` | `src/content/docs/master-dsdm/index.md` | page | Yes | Converted year links to final routes and removed namespace controls | None | Standardized DSDM and masters-programme capitalization |
| `pages/study/master_dsdm/year_1.txt` | `src/content/docs/master-dsdm/year-1/index.md` | page | Yes | Removed obsolete DokuWiki page-creation and navigation controls | None | Corrected punctuation and retained the source-era structure |
| `pages/study/master_dsdm/year_2.txt` | `src/content/docs/master-dsdm/year-2/index.md` | page | Yes | Removed obsolete DokuWiki page-creation and navigation controls | None | Corrected punctuation and retained the source-era structure |
| `pages/study/useful_information.txt` | `src/content/docs/useful-information/index.md` | page | Yes | Converted child-page links to final Starlight routes; removed DokuWiki controls | None | Corrected heading capitalization and introductory grammar |
| `pages/study/useful_information/pages/dke_locations.txt` | `src/content/docs/useful-information/dke-locations.md` | page | Yes | Kept usable map links as historical references and removed editing controls | None | Corrected sentence fragments, comma use, and room-list phrasing |
| `pages/study/useful_information/pages/handy_locations.txt` | `src/content/docs/useful-information/handy-locations.md` | page | Yes | Removed empty DokuWiki edit links | None | Corrected capitalization; marked the two empty sections as awaiting content |
| `pages/study/useful_information/pages/it_services.txt` | `src/content/docs/useful-information/it-services.md` | page | Yes, prominently historical | Retained old service URLs only as labeled historical references; linked to current official manuals where unambiguous | None | Corrected errors such as “Eletctronic” and “contact he,” plus punctuation and service-name casing |
| `pages/study/useful_information/pages/laptop_buy_advice.txt` | `src/content/docs/useful-information/laptop-buying-advice.md` | page | Yes, prominently historical | Removed DokuWiki controls; preserved relevant external references as historical | None | Corrected spacing in capacities, punctuation, and awkward phrasing while retaining old price/specification facts |
## Missing schedule asset
The Bachelor Year 1 and Bachelor Year 2 sources both embedded `study:dke-schedule.png`. No media files were present in the export, so a broken image was not published. Both `src/content/docs/bachelor/year-1/index.md` and `src/content/docs/bachelor/year-2/index.md` contain a visible missing-asset notice naming the expected identifier.
## Removed unresolved home-page targets
The old home page exposed Minutes and Manuals targets, but the selected export contained no corresponding source pages and therefore no valid destinations. These knowingly broken targets were removed. The useful association, programme, and official university destinations were retained or replaced with explicit working links.
## Removed DokuWiki controls
Editing, page-creation, upload, cache, and index-menu controls belonged to DokuWiki rather than to the public content. `NEWPAGE`, `indexmenu`, `~~NOCACHE~~`, edit links, registration/upload instructions, and namespace-only navigation were removed. Maintainers now edit Markdown or MDX through the repository workflow.
## Malformed Year 2 media-upload link
The Bachelor Year 2 source combined its missing schedule embed with a malformed media-manager/upload link. The control could neither resolve on the static site nor restore the absent file, so it was removed. The destination records the missing asset without exposing an obsolete or private editing URL.
## Historical Study Abroad guidance
The Study Abroad source makes time-sensitive claims tied to 2022, including eligibility and application guidance. Those claims remain for historical context, but the destination explicitly says they must be verified against current Maastricht University and programme rules.
## Historical IT services
The IT page names source-era services, URLs, and instructions, including MyUM, EleUM, Surfgroepen, the DKE file server, VPN steps, printing, and historical timetable navigation. These are not presented as current instructions. The destination uses a prominent historical warning, retains context where useful, and directs readers to current official ICT manuals when the successor is unambiguous.
## Historical laptop advice
Laptop prices, processor generations, RAM and storage capacities, operating-system assumptions, and the listed performance tiers reflect the source period. They were preserved rather than silently replaced with current purchasing advice. The page prominently labels the entire section historical and tells readers to verify current programme requirements, prices, and hardware support.
## Publication boundary
The 93-page newer-wiki research snapshot is stored only under `to-be-studied/`. It is not a migration source, is outside both `src/content/docs/` and `public/`, and must undergo a separate editorial review before any material is considered for publication.

View file

@ -10,8 +10,11 @@
"preview": "astro preview",
"check": "astro check && node --test",
"check:migration": "node scripts/check-migration.mjs",
"audit:content": "node scripts/audit-content.mjs",
"check:links": "node scripts/check-internal-links.mjs dist",
"capture:live-wiki": "node scripts/capture-live-wiki.mjs",
"test": "node --test"
"test": "node --test",
"verify": "npm run check && npm run audit:content && npm run build && npm run check:links"
},
"dependencies": {
"@astrojs/starlight": "0.41.6",

186
scripts/audit-content.mjs Normal file
View file

@ -0,0 +1,186 @@
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;
});
}

View file

@ -0,0 +1,18 @@
import { resolve } from 'node:path';
import { checkInternalLinks } from './lib/internal-links.mjs';
const distRoot = resolve(process.argv[2] || 'dist');
const base = process.env.BASE || '/';
const result = await checkInternalLinks({ distRoot, base });
console.log(`Checked ${result.linksChecked} internal links across ${result.filesScanned} built HTML files.`);
for (const { source, href, resolvedPath } of result.broken) {
console.error(`Broken internal link in ${source}: ${href} -> ${resolvedPath}`);
}
if (result.broken.length > 0) {
console.error(`Internal-link check failed: ${result.broken.length} broken link(s).`);
process.exitCode = 1;
} else {
console.log('Found 0 broken internal links.');
}

View file

@ -0,0 +1,109 @@
import { access, readFile, readdir, stat } from 'node:fs/promises';
import { join, relative, sep } from 'node:path';
import { parseHTML } from 'linkedom';
function normalizeBase(base) {
const value = `/${String(base || '/').replace(/^\/+|\/+$/g, '')}/`;
return value === '//' ? '/' : value;
}
async function listHtmlFiles(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 listHtmlFiles(root, path));
else if (entry.isFile() && entry.name.endsWith('.html')) files.push(path);
}
return files.sort();
}
function sourceRoute(source, distRoot) {
const path = relative(distRoot, source).split(sep).join('/');
if (path === 'index.html') return '/';
if (path.endsWith('/index.html')) return `/${path.slice(0, -'index.html'.length)}`;
return `/${path}`;
}
function isIgnoredHref(href) {
return href === ''
|| href.startsWith('#')
|| href.startsWith('//')
|| /^[a-z][a-z\d+.-]*:/i.test(href);
}
function hasBasePrefix(pathname, base) {
return base === '/' || pathname === base.slice(0, -1) || pathname.startsWith(base);
}
function removeBasePrefix(pathname, base) {
if (base === '/') return pathname;
if (pathname === base.slice(0, -1)) return '/';
return `/${pathname.slice(base.length)}`;
}
async function isBuiltTarget(distRoot, route) {
let decoded;
try {
decoded = decodeURIComponent(route);
} catch {
return false;
}
const relativeTarget = decoded.replace(/^\/+/, '');
const exact = join(distRoot, relativeTarget);
const candidates = decoded.endsWith('/')
? [join(exact, 'index.html')]
: [exact, join(exact, 'index.html')];
for (const candidate of candidates) {
try {
await access(candidate);
if ((await stat(candidate)).isFile()) return true;
} catch {
// Try the directory-index form before reporting the route.
}
}
return false;
}
export async function checkInternalLinks({ distRoot, base }) {
const normalizedBase = normalizeBase(base);
const htmlFiles = await listHtmlFiles(distRoot);
const broken = [];
let linksChecked = 0;
for (const sourcePath of htmlFiles) {
const source = relative(distRoot, sourcePath).split(sep).join('/');
const route = sourceRoute(sourcePath, distRoot);
const pageUrl = new URL(`${normalizedBase.replace(/\/$/, '')}${route}`, 'https://built.invalid');
const html = await readFile(sourcePath, 'utf8');
const { document } = parseHTML(html);
for (const element of document.querySelectorAll('[href]')) {
const href = element.getAttribute('href')?.trim() ?? '';
if (isIgnoredHref(href)) continue;
const targetUrl = new URL(href, pageUrl);
if (targetUrl.origin !== pageUrl.origin) continue;
if (/(?:^|\/)\_astro(?:\/|$)/.test(targetUrl.pathname)) continue;
linksChecked += 1;
const hasBase = hasBasePrefix(targetUrl.pathname, normalizedBase);
const resolvedPath = hasBase
? removeBasePrefix(targetUrl.pathname, normalizedBase)
: targetUrl.pathname;
if (hasBase && await isBuiltTarget(distRoot, resolvedPath)) continue;
broken.push({ source, href, resolvedPath });
}
}
return {
filesScanned: htmlFiles.length,
linksChecked,
broken,
};
}

View file

@ -1,6 +1,20 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { auditContent } from '../scripts/audit-content.mjs';
import { loadManifest } from '../scripts/lib/migration.mjs';
test('the full migration audit accounts for every source and destination', async () => {
const manifest = await loadManifest('docs/migration-manifest.json');
const result = await auditContent({
docsRoot: 'src/content/docs',
manifest,
});
assert.equal(result.sourceCount, 29);
assert.equal(result.destinationCount, 28);
assert.equal(result.pageCount, 28);
});
const forbidden = [/\[\[/, /\{\{/, /~~NOCACHE~~/, /NEWPAGE>/, /indexmenu>/];

View file

@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { checkInternalLinks } from '../scripts/lib/internal-links.mjs';
async function withBuiltSite(run) {
const root = await mkdtemp(join(tmpdir(), 'incognito-links-'));
try {
await run(root);
} finally {
await rm(root, { recursive: true, force: true });
}
}
test('reports a missing built internal route', async () => {
await withBuiltSite(async (root) => {
await writeFile(join(root, 'index.html'), '<a href="/missing/">Missing</a>');
const result = await checkInternalLinks({ distRoot: root, base: '/' });
assert.deepEqual(result.broken.map(({ href }) => href), ['/missing/']);
});
});
test('accepts an existing directory index route', async () => {
await withBuiltSite(async (root) => {
await mkdir(join(root, 'about'), { recursive: true });
await writeFile(join(root, 'index.html'), '<a href="/about/">About</a>');
await writeFile(join(root, 'about/index.html'), '<h1>About</h1>');
const result = await checkInternalLinks({ distRoot: root, base: '/' });
assert.equal(result.broken.length, 0);
});
});
test('maps the root route to the built root index', async () => {
await withBuiltSite(async (root) => {
await writeFile(join(root, 'index.html'), '<a href="/">Home</a>');
const result = await checkInternalLinks({ distRoot: root, base: '/' });
assert.equal(result.broken.length, 0);
});
});
test('removes the configured base prefix before resolving built routes', async () => {
await withBuiltSite(async (root) => {
await mkdir(join(root, 'about'), { recursive: true });
await writeFile(join(root, 'index.html'), [
'<a href="/wiki/about/">About</a>',
'<a href="/about/">Missing base prefix</a>',
].join(''));
await writeFile(join(root, 'about/index.html'), '<h1>About</h1>');
const result = await checkInternalLinks({ distRoot: root, base: '/wiki/' });
assert.deepEqual(result.broken.map(({ href }) => href), ['/about/']);
assert.equal(result.broken[0].resolvedPath, '/about/');
});
});
test('resolves relative routes from the source HTML route', async () => {
await withBuiltSite(async (root) => {
await mkdir(join(root, 'guide', 'install'), { recursive: true });
await mkdir(join(root, 'guide', 'about'), { recursive: true });
await writeFile(join(root, 'guide/install/index.html'), '<a href="../about/">About</a>');
await writeFile(join(root, 'guide/about/index.html'), '<h1>About</h1>');
const result = await checkInternalLinks({ distRoot: root, base: '/' });
assert.equal(result.broken.length, 0);
});
});
test('ignores fragments, remote protocols, and Astro-generated assets', async () => {
await withBuiltSite(async (root) => {
await writeFile(join(root, 'index.html'), [
'<a href="#section">Section</a>',
'<a href="mailto:info@example.com">Email</a>',
'<a href="tel:+31000000000">Call</a>',
'<a href="https://example.com/elsewhere">Remote</a>',
'<link href="/_astro/site.css" rel="stylesheet">',
].join(''));
const result = await checkInternalLinks({ distRoot: root, base: '/' });
assert.equal(result.broken.length, 0);
});
});