diff --git a/astro.config.mjs b/astro.config.mjs
index 8e29f25..b0f76ec 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -28,6 +28,9 @@ export default defineConfig({
},
favicon: '/favicon.ico',
customCss: ['./src/styles/incognito.css'],
+ components: {
+ Sidebar: './src/components/Sidebar.astro',
+ },
markdown: {
processedDirs: ['./src/content/shared-courses/'],
},
diff --git a/docs/live-course-recovery.tsv b/docs/live-course-recovery.tsv
index d05e3d5..6b44420 100644
--- a/docs/live-course-recovery.tsv
+++ b/docs/live-course-recovery.tsv
@@ -4,15 +4,15 @@ start https://msvincognito.nl/wiki/start represented src/content/docs/index.mdx
study https://msvincognito.nl/wiki/study represented src/content/docs/index.mdx to-be-studied/previous-wiki/2026-08-03/pages/study.txt Already represented by the selected export migration.
study:bachelor https://msvincognito.nl/wiki/study/bachelor represented src/content/docs/data-science-and-ai/index.md to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor.txt Already represented by the selected export migration.
study:bachelor:year_1 https://msvincognito.nl/wiki/study/bachelor/year_1 represented src/content/docs/data-science-and-ai/year-1/index.md to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1.txt Already represented by the selected export migration.
-study:bachelor:year_1:block_1:discrete_mathematics https://msvincognito.nl/wiki/study/bachelor/year_1/block_1/discrete_mathematics migrated src/content/docs/data-science-and-ai/year-1/block-1/discrete-mathematics.md to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_1/discrete_mathematics.txt Substantive public course page migrated from the live DokuWiki.
+study:bachelor:year_1:block_1:discrete_mathematics https://msvincognito.nl/wiki/study/bachelor/year_1/block_1/discrete_mathematics migrated src/content/docs/data-science-and-ai/year-1/block-1/discrete-mathematics.mdx to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_1/discrete_mathematics.txt Substantive public course page migrated from the live DokuWiki.
study:bachelor:year_1:block_1:introduction_to_data_science_and_artifical_intelligence https://msvincognito.nl/wiki/study/bachelor/year_1/block_1/introduction_to_data_science_and_artifical_intelligence migrated src/content/docs/data-science-and-ai/year-1/block-1/introduction-to-data-science-and-artifical-intelligence.md to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_1/introduction_to_data_science_and_artifical_intelligence.txt Substantive public course page migrated from the live DokuWiki.
-study:bachelor:year_1:block_1:procedural_programming https://msvincognito.nl/wiki/study/bachelor/year_1/block_1/procedural_programming migrated src/content/docs/data-science-and-ai/year-1/block-1/procedural-programming.md to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_1/procedural_programming.txt Substantive public course page migrated from the live DokuWiki.
-study:bachelor:year_1:block_2:calculus https://msvincognito.nl/wiki/study/bachelor/year_1/block_2/calculus migrated src/content/docs/data-science-and-ai/year-1/block-2/calculus.md to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_2/calculus.txt Substantive public course page migrated from the live DokuWiki.
-study:bachelor:year_1:block_2:logic https://msvincognito.nl/wiki/study/bachelor/year_1/block_2/logic migrated src/content/docs/data-science-and-ai/year-1/block-2/logic.md to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_2/logic.txt Substantive public course page migrated from the live DokuWiki.
-study:bachelor:year_1:block_2:objects_in_programming https://msvincognito.nl/wiki/study/bachelor/year_1/block_2/objects_in_programming migrated src/content/docs/data-science-and-ai/year-1/block-2/objects-in-programming.md to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_2/objects_in_programming.txt Substantive public course page migrated from the live DokuWiki.
+study:bachelor:year_1:block_1:procedural_programming https://msvincognito.nl/wiki/study/bachelor/year_1/block_1/procedural_programming migrated src/content/docs/data-science-and-ai/year-1/block-1/procedural-programming.mdx to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_1/procedural_programming.txt Substantive public course page migrated from the live DokuWiki.
+study:bachelor:year_1:block_2:calculus https://msvincognito.nl/wiki/study/bachelor/year_1/block_2/calculus migrated src/content/docs/data-science-and-ai/year-1/block-2/calculus.mdx to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_2/calculus.txt Substantive public course page migrated from the live DokuWiki.
+study:bachelor:year_1:block_2:logic https://msvincognito.nl/wiki/study/bachelor/year_1/block_2/logic migrated src/content/docs/data-science-and-ai/year-1/block-2/logic.mdx to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_2/logic.txt Substantive public course page migrated from the live DokuWiki.
+study:bachelor:year_1:block_2:objects_in_programming https://msvincognito.nl/wiki/study/bachelor/year_1/block_2/objects_in_programming migrated src/content/docs/data-science-and-ai/year-1/block-2/objects-in-programming.mdx to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_2/objects_in_programming.txt Substantive public course page migrated from the live DokuWiki.
study:bachelor:year_1:block_3:placeholder https://msvincognito.nl/wiki/study/bachelor/year_1/block_3/placeholder empty to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_3/placeholder.txt Explicit placeholder page with no course details.
-study:bachelor:year_1:block_4:data_structures_and_algorithms https://msvincognito.nl/wiki/study/bachelor/year_1/block_4/data_structures_and_algorithms migrated src/content/docs/data-science-and-ai/year-1/block-4/data-structures-and-algorithms.md to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_4/data_structures_and_algorithms.txt Substantive public course page migrated from the live DokuWiki.
-study:bachelor:year_1:block_4:linear_algebra https://msvincognito.nl/wiki/study/bachelor/year_1/block_4/linear_algebra migrated src/content/docs/data-science-and-ai/year-1/block-4/linear-algebra.md to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_4/linear_algebra.txt Substantive public course page migrated from the live DokuWiki.
+study:bachelor:year_1:block_4:data_structures_and_algorithms https://msvincognito.nl/wiki/study/bachelor/year_1/block_4/data_structures_and_algorithms migrated src/content/docs/data-science-and-ai/year-1/block-4/data-structures-and-algorithms.mdx to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_4/data_structures_and_algorithms.txt Substantive public course page migrated from the live DokuWiki.
+study:bachelor:year_1:block_4:linear_algebra https://msvincognito.nl/wiki/study/bachelor/year_1/block_4/linear_algebra migrated src/content/docs/data-science-and-ai/year-1/block-4/linear-algebra.mdx to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_4/linear_algebra.txt Substantive public course page migrated from the live DokuWiki.
study:bachelor:year_1:block_4:principles_of_data_science https://msvincognito.nl/wiki/study/bachelor/year_1/block_4/principles_of_data_science migrated src/content/docs/data-science-and-ai/year-1/block-4/principles-of-data-science.md to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_4/principles_of_data_science.txt Substantive public course page migrated from the live DokuWiki.
study:bachelor:year_1:block_5:computational_and_cognitive_neuroscience https://msvincognito.nl/wiki/study/bachelor/year_1/block_5/computational_and_cognitive_neuroscience migrated src/content/docs/data-science-and-ai/year-1/block-5/computational-and-cognitive-neuroscience.md to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_5/computational_and_cognitive_neuroscience.txt Substantive public course page migrated from the live DokuWiki.
study:bachelor:year_1:block_5:numerical_methods https://msvincognito.nl/wiki/study/bachelor/year_1/block_5/numerical_methods migrated src/content/docs/data-science-and-ai/year-1/block-5/numerical-methods.md to-be-studied/previous-wiki/2026-08-03/pages/study/bachelor/year_1/block_5/numerical_methods.txt Substantive public course page migrated from the live DokuWiki.
diff --git a/scripts/lib/previous-wiki.mjs b/scripts/lib/previous-wiki.mjs
index 81fa3da..e03baa2 100644
--- a/scripts/lib/previous-wiki.mjs
+++ b/scripts/lib/previous-wiki.mjs
@@ -4,7 +4,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
const PROGRAMMES = new Map([
- ['bachelor', 'bachelor'],
+ ['bachelor', 'data-science-and-ai'],
['master_ai', 'master-ai'],
['master_dsdm', 'master-dsdm'],
]);
@@ -42,7 +42,11 @@ export function destinationForPageId(pageId) {
return null;
}
const routeParts = [PROGRAMMES.get(parts[1]), ...parts.slice(2).map(slug)];
- return `src/content/docs/${routeParts.join('/')}.md`;
+ const sharedCourse = parts[1] === 'bachelor' && parts[2] === 'year_1' && new Set([
+ 'discrete_mathematics', 'procedural_programming', 'calculus', 'logic',
+ 'objects_in_programming', 'data_structures_and_algorithms', 'linear_algebra',
+ ]).has(parts.at(-1));
+ return `src/content/docs/${routeParts.join('/')}.${sharedCourse ? 'mdx' : 'md'}`;
}
export function classifyPreviousPage(pageId, representedPageIds = new Set(), source = '') {
@@ -61,7 +65,7 @@ function humanLabel(value) {
function markdownRouteForPageId(pageId) {
const destination = destinationForPageId(pageId);
return destination
- ? `/${destination.replace(/^src\/content\/docs\//, '').replace(/\.md$/, '')}/`
+ ? `/${destination.replace(/^src\/content\/docs\//, '').replace(/\.mdx?$/, '')}/`
: `https://msvincognito.nl/wiki/${pageId.replaceAll(':', '/')}`;
}
diff --git a/src/components/ProgrammeSwitch.astro b/src/components/ProgrammeSwitch.astro
new file mode 100644
index 0000000..6020ecc
--- /dev/null
+++ b/src/components/ProgrammeSwitch.astro
@@ -0,0 +1,15 @@
+---
+import { programmeForPathname, programmeSwitchTargets } from '../config/programme-navigation.mjs';
+
+const active = programmeForPathname(Astro.url.pathname.replace(import.meta.env.BASE_URL.replace(/\/$/, ''), ''));
+const targets = programmeSwitchTargets(Astro.url.pathname);
+const withBase = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\//, '')}`;
+---
+
+
diff --git a/src/components/Sidebar.astro b/src/components/Sidebar.astro
new file mode 100644
index 0000000..297c29d
--- /dev/null
+++ b/src/components/Sidebar.astro
@@ -0,0 +1,7 @@
+---
+import DefaultSidebar from '@astrojs/starlight/components/Sidebar.astro';
+import ProgrammeSwitch from './ProgrammeSwitch.astro';
+---
+
+
+
diff --git a/src/config/programme-navigation.mjs b/src/config/programme-navigation.mjs
new file mode 100644
index 0000000..ac9686e
--- /dev/null
+++ b/src/config/programme-navigation.mjs
@@ -0,0 +1,68 @@
+import { sidebar } from './sidebar.mjs';
+
+const shared = {
+ 'period-1/discrete-mathematics': 'block-1/discrete-mathematics',
+ 'period-1/procedural-programming': 'block-1/procedural-programming',
+ 'period-2/calculus': 'block-2/calculus',
+ 'period-2/logic': 'block-2/logic',
+ 'period-2/objects-in-programming': 'block-2/objects-in-programming',
+ 'period-4/data-structures-and-algorithms': 'block-4/data-structures-and-algorithms',
+ 'period-4/linear-algebra': 'block-4/linear-algebra',
+};
+
+const computerScience = {
+ label: 'Computer Science',
+ items: [
+ { slug: 'computer-science' },
+ { label: 'Year 1', items: [
+ { slug: 'computer-science/year-1' },
+ { label: 'Period 1', items: ['discrete-mathematics', 'introduction-to-computer-science', 'procedural-programming'].map((slug) => ({ slug: `computer-science/year-1/period-1/${slug}` })) },
+ { label: 'Period 2', items: ['calculus', 'logic', 'objects-in-programming'].map((slug) => ({ slug: `computer-science/year-1/period-2/${slug}` })) },
+ { label: 'Period 4', items: ['computer-architecture', 'data-structures-and-algorithms', 'linear-algebra'].map((slug) => ({ slug: `computer-science/year-1/period-4/${slug}` })) },
+ { label: 'Period 5', items: ['algorithmic-design', 'databases', 'statistics'].map((slug) => ({ slug: `computer-science/year-1/period-5/${slug}` })) },
+ ] },
+ ],
+};
+
+const compact = { label: 'Bachelor programmes', items: [{ slug: 'computer-science' }, { slug: 'data-science-and-ai' }] };
+
+export function programmeForPathname(pathname) {
+ if (pathname.startsWith('/computer-science')) return 'computer-science';
+ if (pathname.startsWith('/data-science-and-ai') || pathname.startsWith('/bachelor')) return 'data-science-and-ai';
+ return null;
+}
+
+export function programmeSwitchTargets(pathname) {
+ let computerScience = '/computer-science/';
+ let dataScience = '/data-science-and-ai/';
+ for (const [cs, ds] of Object.entries(shared)) {
+ if (pathname.includes(`/computer-science/year-1/${cs}`) || pathname.includes(`/data-science-and-ai/year-1/${ds}`)) {
+ computerScience = `/computer-science/year-1/${cs}/`;
+ dataScience = `/data-science-and-ai/year-1/${ds}/`;
+ break;
+ }
+ }
+ return { computerScience, dataScience };
+}
+
+export function sidebarForPathname(pathname) {
+ const programme = programmeForPathname(pathname);
+ const withoutProgrammes = sidebar.filter(({ label }) => label !== 'Computer Science' && label !== 'Data Science & AI');
+ const dataScience = sidebar.find(({ label }) => label === 'Data Science & AI');
+ const undergraduate = programme === 'computer-science' ? computerScience : programme === 'data-science-and-ai' ? dataScience : compact;
+ return [...withoutProgrammes.slice(0, 3), undergraduate, ...withoutProgrammes.slice(3)];
+}
+
+export function filterResolvedSidebar(entries, pathname) {
+ const programme = programmeForPathname(pathname);
+ return entries.filter((entry) => {
+ if (entry.label === 'Computer Science') return programme !== 'data-science-and-ai';
+ if (entry.label === 'Data Science & AI') return programme !== 'computer-science';
+ return true;
+ }).map((entry) => {
+ if (!programme && (entry.label === 'Computer Science' || entry.label === 'Data Science & AI') && 'entries' in entry) {
+ return { ...entry, entries: entry.entries.slice(0, 1) };
+ }
+ return entry;
+ });
+}
diff --git a/src/config/sidebar.mjs b/src/config/sidebar.mjs
index e736715..0f73a73 100644
--- a/src/config/sidebar.mjs
+++ b/src/config/sidebar.mjs
@@ -5,7 +5,36 @@ export const sidebar = [
{ label: 'About Incognito', link: '/about-incognito/' },
{ slug: 'previous-exams-and-documents' },
{
- label: 'Bachelor',
+ label: 'Computer Science',
+ items: [
+ { slug: 'computer-science' },
+ { label: 'Year 1', items: [
+ { slug: 'computer-science/year-1' },
+ { label: 'Period 1', items: [
+ { slug: 'computer-science/year-1/period-1/discrete-mathematics' },
+ { slug: 'computer-science/year-1/period-1/introduction-to-computer-science' },
+ { slug: 'computer-science/year-1/period-1/procedural-programming' },
+ ] },
+ { label: 'Period 2', items: [
+ { slug: 'computer-science/year-1/period-2/calculus' },
+ { slug: 'computer-science/year-1/period-2/logic' },
+ { slug: 'computer-science/year-1/period-2/objects-in-programming' },
+ ] },
+ { label: 'Period 4', items: [
+ { slug: 'computer-science/year-1/period-4/computer-architecture' },
+ { slug: 'computer-science/year-1/period-4/data-structures-and-algorithms' },
+ { slug: 'computer-science/year-1/period-4/linear-algebra' },
+ ] },
+ { label: 'Period 5', items: [
+ { slug: 'computer-science/year-1/period-5/algorithmic-design' },
+ { slug: 'computer-science/year-1/period-5/databases' },
+ { slug: 'computer-science/year-1/period-5/statistics' },
+ ] },
+ ] },
+ ],
+ },
+ {
+ label: 'Data Science & AI',
items: [
{ slug: 'data-science-and-ai' },
{
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..478107e
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,12 @@
+import { defineMiddleware } from 'astro:middleware';
+import { filterResolvedSidebar } from './config/programme-navigation.mjs';
+
+export const onRequest = defineMiddleware((context, next) => {
+ try {
+ const route = context.locals.starlightRoute;
+ route.sidebar = filterResolvedSidebar(route.sidebar, context.url.pathname);
+ } catch {
+ // Non-Starlight routes such as the generated 404 page have no route data.
+ }
+ return next();
+});
diff --git a/src/styles/incognito.css b/src/styles/incognito.css
index c122d18..03e5c0b 100644
--- a/src/styles/incognito.css
+++ b/src/styles/incognito.css
@@ -132,3 +132,47 @@
grid-template-columns: 1fr;
}
}
+
+.programme-switch {
+ padding: 0.75rem;
+ margin: 0 0.5rem 0.75rem;
+ border: 1px solid var(--sl-color-gray-5);
+ border-radius: 0.6rem;
+}
+
+.programme-switch__label {
+ display: block;
+ margin-bottom: 0.45rem;
+ color: var(--sl-color-gray-2);
+ font-size: var(--sl-text-xs);
+ font-weight: 600;
+}
+
+.programme-switch__links {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.35rem;
+}
+
+.programme-switch__links a {
+ display: flex;
+ min-height: 2.5rem;
+ align-items: center;
+ justify-content: center;
+ padding: 0.35rem;
+ border-radius: 0.4rem;
+ text-align: center;
+ line-height: 1.2;
+}
+
+.programme-switch__links a[aria-current='page'] {
+ color: var(--sl-color-text-accent);
+ background: var(--sl-color-accent-low);
+ font-weight: 700;
+ box-shadow: inset 0 0 0 1px var(--sl-color-accent);
+}
+
+.programme-switch__links a:focus-visible {
+ outline: 2px solid var(--sl-color-accent-high);
+ outline-offset: 2px;
+}
diff --git a/tests/previous-wiki.test.mjs b/tests/previous-wiki.test.mjs
index 4e2c8a0..472894a 100644
--- a/tests/previous-wiki.test.mjs
+++ b/tests/previous-wiki.test.mjs
@@ -50,7 +50,7 @@ test('classifies course pages separately from represented and excluded pages', (
test('maps in-scope course IDs to stable Starlight destinations', () => {
assert.equal(
destinationForPageId('study:bachelor:year_1:block_1:discrete_mathematics'),
- 'src/content/docs/data-science-and-ai/year-1/block-1/discrete-mathematics.md',
+ 'src/content/docs/data-science-and-ai/year-1/block-1/discrete-mathematics.mdx',
);
assert.equal(
destinationForPageId('study:master_ai:year_2:block_3:agents'),
@@ -155,12 +155,17 @@ test('all 54 substantive live course pages are published and registered', async
const recoveredSidebar = await readFile('src/config/recovered-course-sidebar.mjs', 'utf8');
for (const record of migrated) {
await access(record.destination);
- const content = await readFile(record.destination, 'utf8');
+ let content = await readFile(record.destination, 'utf8');
+ const sharedImport = content.match(/import SharedCourse from '([^']+)'/);
+ if (sharedImport) {
+ const { dirname, resolve } = await import('node:path');
+ content += await readFile(resolve(dirname(record.destination), sharedImport[1]), 'utf8');
+ }
assert.match(content, /:::caution\[Historical information\]/, record.destination);
assert.match(content, new RegExp(record.sourceUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), record.destination);
assert.equal(supplementalByDestination.get(record.destination)?.source, record.sourceUrl);
assert.equal(supplementalByDestination.get(record.destination)?.category, 'live-course-recovery');
- const slug = record.destination.replace(/^src\/content\/docs\//, '').replace(/\.md$/, '');
+ const slug = record.destination.replace(/^src\/content\/docs\//, '').replace(/\.mdx?$/, '');
assert.match(recoveredSidebar, new RegExp(`slug: '${slug}'`), slug);
}
const report = await readFile('docs/live-course-recovery.md', 'utf8');
diff --git a/tests/programme-navigation.test.mjs b/tests/programme-navigation.test.mjs
new file mode 100644
index 0000000..2b3dc4e
--- /dev/null
+++ b/tests/programme-navigation.test.mjs
@@ -0,0 +1,21 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { programmeForPathname, programmeSwitchTargets, sidebarForPathname } from '../src/config/programme-navigation.mjs';
+
+test('programme routes select the matching sidebar while retaining global groups', () => {
+ assert.equal(programmeForPathname('/computer-science/year-1/'), 'computer-science');
+ assert.equal(programmeForPathname('/data-science-and-ai/year-2/'), 'data-science-and-ai');
+ assert.equal(programmeForPathname('/useful-information/'), null);
+ for (const path of ['/computer-science/', '/data-science-and-ai/', '/useful-information/']) {
+ const text = JSON.stringify(sidebarForPathname(path));
+ assert.match(text, /Useful Information/);
+ assert.match(text, /Master AI/);
+ assert.match(text, /previous-exams-and-documents/);
+ }
+});
+
+test('shared course switches to its paired programme route', () => {
+ const targets = programmeSwitchTargets('/computer-science/year-1/period-2/calculus/');
+ assert.equal(targets.dataScience, '/data-science-and-ai/year-1/block-2/calculus/');
+ assert.equal(targets.computerScience, '/computer-science/year-1/period-2/calculus/');
+});
diff --git a/tests/programme-switch.test.mjs b/tests/programme-switch.test.mjs
new file mode 100644
index 0000000..bfb6c64
--- /dev/null
+++ b/tests/programme-switch.test.mjs
@@ -0,0 +1,12 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import test from 'node:test';
+
+test('Sidebar override renders an accessible no-JavaScript programme switch', async () => {
+ const component = await readFile('src/components/ProgrammeSwitch.astro', 'utf8');
+ const wrapper = await readFile('src/components/Sidebar.astro', 'utf8');
+ assert.match(component, /aria-label="Bachelor programme"/);
+ assert.match(component, /aria-current/);
+ assert.doesNotMatch(component, /client:|