fix: verify programme routes across deployment bases
This commit is contained in:
parent
8c471fc0fa
commit
1d4d832b6c
11 changed files with 67 additions and 6 deletions
|
|
@ -1,7 +1,9 @@
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
import starlight from '@astrojs/starlight';
|
import starlight from '@astrojs/starlight';
|
||||||
|
import { unified } from '@astrojs/markdown-remark';
|
||||||
import { sidebar } from './src/config/sidebar.mjs';
|
import { sidebar } from './src/config/sidebar.mjs';
|
||||||
import { legacyBachelorRedirects } from './src/config/legacy-bachelor-redirects.mjs';
|
import { legacyBachelorRedirects } from './src/config/legacy-bachelor-redirects.mjs';
|
||||||
|
import { baseAwareLinks } from './src/config/base-aware-markdown.mjs';
|
||||||
|
|
||||||
const site = process.env.SITE || 'http://localhost:4321';
|
const site = process.env.SITE || 'http://localhost:4321';
|
||||||
const base = process.env.BASE || '/';
|
const base = process.env.BASE || '/';
|
||||||
|
|
@ -13,7 +15,10 @@ const matomoConsentScript = `${normalizedBase}/matomo-consent.js`;
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site,
|
site,
|
||||||
base,
|
base,
|
||||||
redirects: legacyBachelorRedirects,
|
redirects: Object.fromEntries(Object.entries(legacyBachelorRedirects).map(([from, to]) => [from, `${normalizedBase}${to}`])),
|
||||||
|
markdown: {
|
||||||
|
processor: unified({ remarkPlugins: [[baseAwareLinks, { base }]] }),
|
||||||
|
},
|
||||||
integrations: [
|
integrations: [
|
||||||
starlight({
|
starlight({
|
||||||
title: 'Incognito Wiki',
|
title: 'Incognito Wiki',
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,9 @@
|
||||||
- Preserve every old `/bachelor/...` URL with an explicit permanent redirect to the exact new route.
|
- Preserve every old `/bachelor/...` URL with an explicit permanent redirect to the exact new route.
|
||||||
- Import only these substantive Computer Science pages from `d5d6730`:
|
- Import only these substantive Computer Science pages from `d5d6730`:
|
||||||
- `computer-science/year-1/period-1/introduction-to-computer-science.md`
|
- `computer-science/year-1/period-1/introduction-to-computer-science.md`
|
||||||
- `computer-science/year-1/period-2/computer-architecture.md`
|
- `computer-science/year-1/period-4/computer-architecture.md`
|
||||||
- `computer-science/year-1/period-4/algorithmic-design.md`
|
- `computer-science/year-1/period-5/algorithmic-design.md`
|
||||||
- `computer-science/year-1/period-4/databases.md`
|
- `computer-science/year-1/period-5/databases.md`
|
||||||
- `computer-science/year-1/period-5/statistics.md`
|
- `computer-science/year-1/period-5/statistics.md`
|
||||||
- Do not import the malformed `computer-science/course-description.md`, any `Empty Page` placeholder, or the branch deletion of `previous-exams-and-documents.md`.
|
- Do not import the malformed `computer-science/course-description.md`, any `Empty Page` placeholder, or the branch deletion of `previous-exams-and-documents.md`.
|
||||||
- Store the body of each byte-identical shared course once, outside `src/content/docs`, and expose it through two programme wrappers:
|
- Store the body of each byte-identical shared course once, outside `src/content/docs`, and expose it through two programme wrappers:
|
||||||
|
|
|
||||||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "incognito-wiki",
|
"name": "incognito-wiki",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@astrojs/markdown-remark": "7.2.2",
|
||||||
"@astrojs/starlight": "0.41.6",
|
"@astrojs/starlight": "0.41.6",
|
||||||
"astro": "7.1.6",
|
"astro": "7.1.6",
|
||||||
"sharp": "0.35.3"
|
"sharp": "0.35.3"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": { "node": ">=22.12.0" },
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
|
|
@ -18,6 +20,7 @@
|
||||||
"verify": "npm run check && npm run audit:content && npm run build && npm run check:rendered && npm run check:links"
|
"verify": "npm run check && npm run audit:content && npm run build && npm run check:rendered && npm run check:links"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@astrojs/markdown-remark": "7.2.2",
|
||||||
"@astrojs/starlight": "0.41.6",
|
"@astrojs/starlight": "0.41.6",
|
||||||
"astro": "7.1.6",
|
"astro": "7.1.6",
|
||||||
"sharp": "0.35.3"
|
"sharp": "0.35.3"
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ export async function checkInternalLinks({ distRoot, base }) {
|
||||||
const pageUrl = new URL(`${normalizedBase.replace(/\/$/, '')}${route}`, 'https://built.invalid');
|
const pageUrl = new URL(`${normalizedBase.replace(/\/$/, '')}${route}`, 'https://built.invalid');
|
||||||
const html = await readFile(sourcePath, 'utf8');
|
const html = await readFile(sourcePath, 'utf8');
|
||||||
const { document } = parseHTML(html);
|
const { document } = parseHTML(html);
|
||||||
|
if (document.querySelector('meta[http-equiv="refresh"]')) continue;
|
||||||
|
|
||||||
for (const element of document.querySelectorAll('[href]')) {
|
for (const element of document.querySelectorAll('[href]')) {
|
||||||
const href = element.getAttribute('href')?.trim() ?? '';
|
const href = element.getAttribute('href')?.trim() ?? '';
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export async function checkRenderedOutput({ distRoot }) {
|
||||||
for (const file of htmlFiles) {
|
for (const file of htmlFiles) {
|
||||||
const path = relative(absoluteDistRoot, file).split(sep).join('/');
|
const path = relative(absoluteDistRoot, file).split(sep).join('/');
|
||||||
const { document } = parseHTML(await readFile(file, 'utf8'));
|
const { document } = parseHTML(await readFile(file, 'utf8'));
|
||||||
|
if (document.querySelector('meta[http-equiv="refresh"]')) continue;
|
||||||
const h1Count = document.querySelectorAll('h1').length;
|
const h1Count = document.querySelectorAll('h1').length;
|
||||||
if (h1Count !== 1) headingIssues.push({ file: path, h1Count });
|
if (h1Count !== 1) headingIssues.push({ file: path, h1Count });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { programmeForPathname, programmeSwitchTargets } from '../config/programm
|
||||||
|
|
||||||
const active = programmeForPathname(Astro.url.pathname.replace(import.meta.env.BASE_URL.replace(/\/$/, ''), ''));
|
const active = programmeForPathname(Astro.url.pathname.replace(import.meta.env.BASE_URL.replace(/\/$/, ''), ''));
|
||||||
const targets = programmeSwitchTargets(Astro.url.pathname);
|
const targets = programmeSwitchTargets(Astro.url.pathname);
|
||||||
const withBase = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\//, '')}`;
|
const withBase = (path: string) => `${import.meta.env.BASE_URL.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;
|
||||||
---
|
---
|
||||||
|
|
||||||
<nav class="programme-switch" aria-label="Bachelor programme">
|
<nav class="programme-switch" aria-label="Bachelor programme">
|
||||||
|
|
|
||||||
19
src/config/base-aware-markdown.mjs
Normal file
19
src/config/base-aware-markdown.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
export function baseAwareLinks({ base = '/' } = {}) {
|
||||||
|
const prefix = base === '/' ? '' : `/${base.replace(/^\/+|\/+$/g, '')}`;
|
||||||
|
return function transform(tree) {
|
||||||
|
if (!prefix) return tree;
|
||||||
|
const visit = (node) => {
|
||||||
|
if ((node.type === 'link' || node.type === 'image')
|
||||||
|
&& typeof node.url === 'string'
|
||||||
|
&& node.url.startsWith('/')
|
||||||
|
&& !node.url.startsWith('//')
|
||||||
|
&& node.url !== prefix
|
||||||
|
&& !node.url.startsWith(`${prefix}/`)) {
|
||||||
|
node.url = `${prefix}${node.url}`;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.children)) node.children.forEach(visit);
|
||||||
|
};
|
||||||
|
visit(tree);
|
||||||
|
return tree;
|
||||||
|
};
|
||||||
|
}
|
||||||
15
tests/base-aware-markdown.test.mjs
Normal file
15
tests/base-aware-markdown.test.mjs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { baseAwareLinks } from '../src/config/base-aware-markdown.mjs';
|
||||||
|
|
||||||
|
test('prefixes root-relative Markdown links for subpath builds', () => {
|
||||||
|
const tree = { type: 'root', children: [
|
||||||
|
{ type: 'link', url: '/previous-exams-and-documents/' },
|
||||||
|
{ type: 'link', url: 'https://example.com/' },
|
||||||
|
{ type: 'image', url: '/media/file.pdf' },
|
||||||
|
] };
|
||||||
|
baseAwareLinks({ base: '/wiki' })(tree);
|
||||||
|
assert.deepEqual(tree.children.map(({ url }) => url), [
|
||||||
|
'/wiki/previous-exams-and-documents/', 'https://example.com/', '/wiki/media/file.pdf',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
@ -98,6 +98,14 @@ test('ignores fragments, remote protocols, and Astro-generated assets', async ()
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ignores links inside generated redirect documents', async () => {
|
||||||
|
await withBuiltSite(async (root) => {
|
||||||
|
await writeFile(join(root, 'index.html'), '<meta http-equiv="refresh" content="0;url=/target/"><a href="/target/">Redirect</a>');
|
||||||
|
const result = await checkInternalLinks({ distRoot: root, base: '/wiki/' });
|
||||||
|
assert.equal(result.broken.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
for (const { name, href } of [
|
for (const { name, href } of [
|
||||||
{ name: 'encoded slash', href: '/..%2Fmanual.pdf' },
|
{ name: 'encoded slash', href: '/..%2Fmanual.pdf' },
|
||||||
{ name: 'encoded backslash', href: '/..%5Cmanual.pdf' },
|
{ name: 'encoded backslash', href: '/..%5Cmanual.pdf' },
|
||||||
|
|
|
||||||
|
|
@ -44,3 +44,11 @@ test('reports any to-be-studied path that reaches built output', async () => {
|
||||||
assert.deepEqual(result.unpublishedPaths, ['to-be-studied/leak.txt']);
|
assert.deepEqual(result.unpublishedPaths, ['to-be-studied/leak.txt']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ignores Astro redirect documents when checking content headings', async () => {
|
||||||
|
await withRenderedSite(async (root) => {
|
||||||
|
await writeFile(join(root, 'index.html'), '<meta http-equiv="refresh" content="0;url=/new/"><a href="/new/">Redirect</a>');
|
||||||
|
const result = await checkRenderedOutput({ distRoot: root });
|
||||||
|
assert.deepEqual(result.headingIssues, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue