From 8c471fc0fa608deff2e61c30544d1f127c3ea327 Mon Sep 17 00:00:00 2001 From: msa46 Date: Sat, 15 Aug 2026 21:04:38 +0200 Subject: [PATCH] feat: add route-aware programme navigation --- astro.config.mjs | 3 ++ docs/live-course-recovery.tsv | 14 +++--- scripts/lib/previous-wiki.mjs | 10 ++-- src/components/ProgrammeSwitch.astro | 15 ++++++ src/components/Sidebar.astro | 7 +++ src/config/programme-navigation.mjs | 68 ++++++++++++++++++++++++++++ src/config/sidebar.mjs | 31 ++++++++++++- src/middleware.ts | 12 +++++ src/styles/incognito.css | 44 ++++++++++++++++++ tests/previous-wiki.test.mjs | 11 +++-- tests/programme-navigation.test.mjs | 21 +++++++++ tests/programme-switch.test.mjs | 12 +++++ 12 files changed, 234 insertions(+), 14 deletions(-) create mode 100644 src/components/ProgrammeSwitch.astro create mode 100644 src/components/Sidebar.astro create mode 100644 src/config/programme-navigation.mjs create mode 100644 src/middleware.ts create mode 100644 tests/programme-navigation.test.mjs create mode 100644 tests/programme-switch.test.mjs 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:|