Compare commits

...

10 commits

Author SHA1 Message Date
msa46
bcb8dcfe2a fix: resolve final Starlight migration review
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
2026-08-02 18:59:28 +02:00
msa46
583cf83e65 docs: add deployment and maintainer workflow 2026-08-02 18:15:40 +02:00
msa46
c9dda6c6d6 test: verify migrated wiki content and links 2026-08-02 17:57:41 +02:00
msa46
ebaa5eff92 fix: harden live wiki capture boundaries 2026-08-02 17:48:35 +02:00
msa46
4e06340aaa docs: archive newer wiki content for comparison 2026-08-02 17:27:32 +02:00
msa46
e3891d18af content: migrate useful student information 2026-08-02 17:02:48 +02:00
msa46
717db68a5d content: migrate Master DSDM section 2026-08-02 16:53:42 +02:00
msa46
6ad007af92 content: migrate Master AI section 2026-08-02 16:50:14 +02:00
msa46
4a64bc61b7 content: migrate Bachelor year three 2026-08-02 16:45:42 +02:00
msa46
4828a327d7 fix: correct Year 2 prerequisite links 2026-08-02 16:41:31 +02:00
147 changed files with 5083 additions and 42 deletions

2
.env.example Normal file
View file

@ -0,0 +1,2 @@
SITE=https://wiki.example.org
BASE=/

38
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,38 @@
# Optional GitHub-mirror deployment. Forgejo ignores this GitHub-specific
# workflow, and the repository does not rely on it for validation or deployment.
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build:
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- name: Check out the mirror
uses: actions/checkout@v7
- name: Build and upload the Pages artifact
uses: withastro/action@v6
with:
node-version: 22.22.3
env:
SITE: ${{ vars.SITE_URL }}
BASE: ${{ vars.BASE_PATH }}
deploy:
permissions:
pages: write
id-token: write
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5

73
README.md Normal file
View file

@ -0,0 +1,73 @@
# Incognito Wiki
This repository contains the public Incognito student wiki, built as a static site with Astro and Starlight. Forgejo hosts the authoritative private repository. Local commands are the source of truth: the project does not depend on Forgejo CI.
## Local development
Use Node.js 22.12 or newer and the npm version bundled with Node. Hosted builds are pinned to Node.js 22.22.3. Install dependencies from `package.json` with:
```sh
npm install
```
Use the checked-in lockfile for a reproducible clean install with `npm ci`.
- `npm run dev` starts the local development server.
- `npm run check` runs Astro validation and the Node test suite.
- `npm run verify` runs all checks, the content audit, a production build, and the internal-link check.
## Maintaining content
Published pages live in `src/content/docs/`. Add or edit Markdown and MDX there. Navigation is explicit, so add, remove, or move the matching entry manually in `src/config/sidebar.mjs` whenever a published page changes.
Use these notice conventions exactly for migrated source material:
Historical or potentially outdated information:
```md
:::caution[Historical information]
This information originated in the previous wiki and may be outdated.
:::
```
An intentionally empty page:
```md
:::note[Awaiting content]
This page is awaiting content.
:::
```
A source asset that was referenced but not exported (replace the identifier only):
```md
:::danger[Missing source asset]
The previous wiki referenced `source:asset-name.ext`, but that file was not included in the export.
:::
```
The `to-be-studied/` directory is research material, not publication content. Never move it or its contents into `src/content/docs/` or `public/` without a separate editorial review.
## Reviewing the migration
Read `docs/migration-report.md` when reviewing migrated content. Its source inventory maps every selected DokuWiki source to its destination and records outdated notices, repaired or removed links, missing assets, and significant copy edits. Review the dedicated summaries after the table for unresolved or time-sensitive material, and update the relevant row and summary whenever a migration decision changes.
## Deployment
`SITE` is the public origin and `BASE` is the path prefix. They are public build settings, not secrets; `.env.example` documents local defaults. Keep credentials and tokens out of these values and out of the repository.
For a custom domain hosted at its root:
```sh
SITE=https://wiki.example.org BASE=/ npm run build
```
For repository-subpath hosting such as GitHub Pages:
```sh
SITE=https://example.github.io BASE=/Incognito-Wiki npm run build
```
Netlify reads `netlify.toml`, runs `npm run build`, and publishes `dist`. Configure `SITE` and `BASE` as environment values for the selected production hostname and path.
The removable workflow at `.github/workflows/deploy.yml` optionally deploys the GitHub mirror to Pages after a push to `main` or a manual dispatch. Set the public repository variables `SITE_URL` and `BASE_PATH` on GitHub. The workflow is GitHub-specific; Forgejo ignores it, and removing the workflow or `netlify.toml` does not affect local development or verification.

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

@ -0,0 +1,75 @@
# 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. Present-day association references use the current Department of Advanced Computing Sciences name; legacy department and programme names remain only in clearly historical material.
## Source inventory
“Outdated” records whether the destination displays the standard warning that its migrated information may be outdated. Link handling distinguishes converted source links, converted plain-text references, newly verified links, genuine removals, and sources with no content links. “None” means that the source had no item in that category, not that it was skipped during review.
| Source | Destination | Mode | Outdated | Link handling | Missing assets | Significant spelling or grammar fixes |
| --- | --- | --- | --- | --- | --- | --- |
| `pages/start.txt` | `src/content/docs/index.mdx` | page | Yes, on migrated programme notes | **Converted source links:** Bachelor, Master AI, Master DSDM, Useful Information, the Incognito site, and ICT manuals. **Newly verified link:** the current DACS page replaced the source-era DKE department URL. **Genuinely removed links:** unresolved Minutes, Event Manuals, and Function Manuals targets; redundant study/year navigation was omitted. | None | Recast the welcome copy and normalized programme terminology |
| `pages/study.txt` | `src/content/docs/index.mdx` | merge | Yes | **Converted source links:** Bachelor, Master AI, Master DSDM, and the YouTube embed. **Genuinely removed links:** redundant year navigation was omitted. | None | Condensed the source-era university and programme overview, including omission of its dated growth/ranking sentence; corrected punctuation, capitalization, and bachelor/master wording |
| `pages/study/msv_incognito.txt` | `src/content/docs/about-incognito.md` | page | No | **Converted source link:** the MSV Incognito website. No links were repaired or removed. | None | Combined the source website link with the association-description sentence from `pages/start.txt`; the present-day introduction uses the current DACS name |
| `pages/study/bachelor.txt` | `src/content/docs/bachelor/index.md` | page | Yes | **Converted source link:** Maastricht University education website. **Removed source links:** redundant DokuWiki year-navigation links, now covered by site navigation. | None | Corrected bachelor capitalization and sentence structure; disclosed that promised summaries and old exams were absent from the export |
| `pages/study/bachelor/year_1.txt` | `src/content/docs/bachelor/year-1/index.md` | page | Yes | **Newly verified links:** the two migrated project pages, derived from the exported child-page inventory. No source content links required repair or removal. | `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 | **None.** The source and destination contain no content links. | 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 | **None.** The source and destination contain no content links. | None | Corrected grammar, spacing, and capitalization |
| `pages/study/bachelor/year_2.txt` | `src/content/docs/bachelor/year-2/index.md` | page | Yes | **Newly verified links:** the two migrated project pages and honours placeholder. **Genuinely removed link:** the malformed DokuWiki media-manager/upload URL. | `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 | **None.** The source and destination contain no content links. | None | Replaced the source's “Under Construction” text with the standard awaiting-content placeholder |
| `pages/study/bachelor/year_2/project_2-1.txt` | `src/content/docs/bachelor/year-2/project-2-1.md` | page | Yes | **Converted plain-text reference:** Project 1-1 became a verified local link. The Project 3-1 reference remains text. No source link was repaired. | 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 | **Converted plain-text reference:** Project 1-2 became a verified local link. No source link was repaired. | None | Corrected punctuation and standardized project naming |
| `pages/study/bachelor/year_3.txt` | `src/content/docs/bachelor/year-3/index.md` | page | Yes | **Newly verified links:** the four migrated child pages, derived from the exported child-page inventory. No source content links required repair or removal. | 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 | **None.** The source and destination contain no content links. | 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 | **None.** The source and destination contain no content links. | None | Preserved the source title and made its under-construction state an explicit awaiting-content placeholder |
| `pages/study/bachelor/year_3/project_3-1.txt` | `src/content/docs/bachelor/year-3/project-3-1.md` | page | Yes | **Converted plain-text reference:** Project 2-1 became a verified local link. No source link was repaired. | 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 | **Newly verified link:** the official UM study-abroad page. No source links existed. | 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 source link:** Maastricht University education website. **Removed source links:** redundant DokuWiki year-navigation links, now covered by site navigation. | None | Standardized masters-programme capitalization and punctuation; disclosed that promised summaries and old exams were absent from the export |
| `pages/study/master_ai/year_1.txt` | `src/content/docs/master-ai/year-1/index.md` | page | Yes | **Newly verified links:** the two migrated research-project pages, derived from the exported child-page inventory. No source content links required repair or removal. | 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 | **None.** The source and destination contain no content links. | 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 | **None.** The source and destination contain no content links. | 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 | **None.** The source and destination contain no content links. | None | Corrected capitalization and hyphenation and framed the two schedule statements historically |
| `pages/study/master_dsdm.txt` | `src/content/docs/master-dsdm/index.md` | page | Yes | **Converted source link:** Maastricht University education website. **Removed source links:** redundant DokuWiki year-navigation links, now covered by site navigation. | None | Standardized DSDM and masters-programme capitalization; disclosed that promised summaries and old exams were absent from the export |
| `pages/study/master_dsdm/year_1.txt` | `src/content/docs/master-dsdm/year-1/index.md` | page | Yes | **None.** The source and destination contain no content links. | 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 | **None.** The source and destination contain no content links. | None | Corrected punctuation and retained the source-era structure |
| `pages/study/useful_information.txt` | `src/content/docs/useful-information/index.md` | page | Yes | **Converted source link:** Maastricht University education website. **Newly verified links:** the four migrated child pages, derived from the exported child-page inventory. | None | Corrected heading capitalization and introductory grammar; scoped contribution guidance to repository maintainers |
| `pages/study/useful_information/pages/dke_locations.txt` | `src/content/docs/useful-information/dke-locations.md` | page | Yes | **Converted source links:** the three Google Maps URLs. No links were repaired or removed. | 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 | **None.** The source and destination contain no content 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 | **Converted source links:** ICT manuals, Inner City map, Student Portal, Student Desktop Anywhere, and MyPrint. **Removed hyperlinking:** legacy EleUM and VPN URLs were retained as non-clickable historical code. No successor URL was added. | 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 | **None.** The source and destination contain no content links. | 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. Source programme links became local navigation, the Incognito and ICT links were converted, and the source-era DKE department URL was replaced with the newly verified current DACS page.
## Source-era summaries and exams claim
The Bachelor, Master AI, and Master DSDM overview sources said course summaries and old exams could be found in corresponding folders. Neither the selected export nor the published inventory contains those materials. The destinations preserve the statement only as a source-era claim and explicitly disclose the absence instead of promising unavailable content.
## 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. The official UM study-abroad page is a newly verified destination link; it did not originate in the source.
## Historical IT services
The IT page names source-era services, URLs, and instructions, including MyUM, EleUM, Student Desktop Anywhere, VPN steps, printing, and historical timetable navigation. These are not presented as current instructions. The destination uses a prominent historical warning, converts useful source links, and retains the legacy EleUM and VPN URLs only as non-clickable historical code. No successor URL was added.
## 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.

6
netlify.toml Normal file
View file

@ -0,0 +1,6 @@
[build]
command = "npm run build"
publish = "dist"
[build.environment]
NODE_VERSION = "22.22.3"

246
package-lock.json generated
View file

@ -14,6 +14,9 @@
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "0.9.10", "@astrojs/check": "0.9.10",
"js-yaml": "4.3.1",
"linkedom": "0.18.13",
"turndown": "7.2.4",
"typescript": "6.0.2" "typescript": "6.0.2"
}, },
"engines": { "engines": {
@ -1791,6 +1794,13 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
@ -3134,6 +3144,13 @@
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/cssom": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
"integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -4124,6 +4141,39 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-cache-semantics": { "node_modules/http-cache-semantics": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@ -4547,6 +4597,181 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/linkedom": {
"version": "0.18.13",
"resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz",
"integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==",
"dev": true,
"license": "ISC",
"dependencies": {
"css-select": "^7.0.0",
"cssom": "^0.5.0",
"html-escaper": "^3.0.3",
"htmlparser2": "^10.1.0",
"uhyphen": "^0.2.0"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"canvas": ">= 2"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/linkedom/node_modules/boolbase": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
"integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/linkedom/node_modules/css-select": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
"integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^2.0.0",
"css-what": "^8.0.0",
"domhandler": "^6.0.1",
"domutils": "^4.0.2",
"nth-check": "^3.0.1"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/linkedom/node_modules/css-what": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
"integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/linkedom/node_modules/dom-serializer": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
"dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/linkedom/node_modules/domelementtype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/linkedom/node_modules/domhandler": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^3.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/linkedom/node_modules/domutils": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^3.0.0",
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/linkedom/node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/linkedom/node_modules/nth-check": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
"integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^2.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/longest-streak": { "node_modules/longest-streak": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@ -6859,6 +7084,20 @@
"license": "0BSD", "license": "0BSD",
"optional": true "optional": true
}, },
"node_modules/turndown": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
},
"engines": {
"node": ">=18",
"npm": ">=9"
}
},
"node_modules/typesafe-path": { "node_modules/typesafe-path": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz", "resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz",
@ -6896,6 +7135,13 @@
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/uhyphen": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
"integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
"dev": true,
"license": "ISC"
},
"node_modules/ultrahtml": { "node_modules/ultrahtml": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.7.0.tgz", "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.7.0.tgz",

View file

@ -10,7 +10,12 @@
"preview": "astro preview", "preview": "astro preview",
"check": "astro check && node --test", "check": "astro check && node --test",
"check:migration": "node scripts/check-migration.mjs", "check:migration": "node scripts/check-migration.mjs",
"test": "node --test" "audit:content": "node scripts/audit-content.mjs",
"check:links": "node scripts/check-internal-links.mjs dist",
"check:rendered": "node scripts/check-rendered-output.mjs dist",
"capture:live-wiki": "node scripts/capture-live-wiki.mjs",
"test": "node --test",
"verify": "npm run check && npm run audit:content && npm run build && npm run check:rendered && npm run check:links"
}, },
"dependencies": { "dependencies": {
"@astrojs/starlight": "0.41.6", "@astrojs/starlight": "0.41.6",
@ -19,6 +24,9 @@
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "0.9.10", "@astrojs/check": "0.9.10",
"js-yaml": "4.3.1",
"linkedom": "0.18.13",
"turndown": "7.2.4",
"typescript": "6.0.2" "typescript": "6.0.2"
} }
} }

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

@ -0,0 +1,209 @@
import { access, readFile, readdir } from 'node:fs/promises';
import { join, relative, resolve, sep } from 'node:path';
import { pathToFileURL } from 'node:url';
import { load as loadYaml } from 'js-yaml';
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';
let frontmatter;
try {
frontmatter = loadYaml(match[1]);
} catch (error) {
return `has malformed YAML frontmatter: ${error.reason ?? error.message}`;
}
for (const field of ['title', 'description']) {
const value = frontmatter && typeof frontmatter === 'object' ? frontmatter[field] : undefined;
if (typeof value !== 'string' || !value.trim()) 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 projectRoot = resolve(normalized, '..', '..', '..');
if (resolve(projectRoot, 'src', 'content', 'docs') !== normalized) {
throw new TypeError(`docsRoot must identify src/content/docs: ${docsRoot}`);
}
return projectRoot;
}
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}`);
for (const destination of destinationResult.notFiles) issues.push(`manifest destination is not a regular file: ${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)}`);
}
}
const manifestDestinationSet = new Set(destinations);
const publishedDestinationSet = new Set(contentByDestination.keys());
for (const destination of manifestDestinationSet) {
if (!publishedDestinationSet.has(destination)) {
issues.push(`manifest destination is absent from published content: ${destination}`);
}
}
for (const destination of publishedDestinationSet) {
if (!manifestDestinationSet.has(destination)) {
issues.push(`published content file is absent from manifest: ${destination}`);
}
}
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,47 @@
#!/usr/bin/env node
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { crawlWiki } from './lib/live-wiki.mjs';
const CAPTURE_DATE = '2026-08-02';
const SCRIPT_PATH = fileURLToPath(import.meta.url);
const PROJECT_ROOT = path.resolve(path.dirname(SCRIPT_PATH), '..');
export async function runCapture({
crawlImpl = crawlWiki,
logError = console.error,
logInfo = console.log,
} = {}) {
const manifest = await crawlImpl({
capturedAt: CAPTURE_DATE,
manifestPath: path.join(PROJECT_ROOT, 'to-be-studied', 'manifest.json'),
outputRoot: path.join(PROJECT_ROOT, 'to-be-studied', 'live-wiki', CAPTURE_DATE),
});
const pageCount = manifest.pages.length;
const failureCount = manifest.failures.length;
if (failureCount > 0) {
logError(
`Captured ${pageCount} ${pluralize('page', pageCount)} with ${failureCount} ${pluralize('failure', failureCount)}; inspect to-be-studied/manifest.json.`,
);
return 1;
}
logInfo(`Captured ${pageCount} ${pluralize('page', pageCount)} with no failures.`);
return 0;
}
function pluralize(noun, count) {
return count === 1 ? noun : `${noun}s`;
}
if (process.argv[1] && path.resolve(process.argv[1]) === SCRIPT_PATH) {
try {
process.exitCode = await runCapture();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
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

@ -10,8 +10,11 @@ console.log(`Migration manifest accounts for ${result.total} source pages.`);
for (const destination of result.missing) { for (const destination of result.missing) {
console.log(`Missing destination: ${destination}`); console.log(`Missing destination: ${destination}`);
} }
for (const destination of result.notFiles) {
console.log(`Destination is not a regular file: ${destination}`);
}
if (result.missing.length > 0 && !allowMissing) { if ((result.missing.length > 0 && !allowMissing) || result.notFiles.length > 0) {
console.error(`Migration check failed: ${result.missing.length} destination file(s) are missing.`); console.error(`Migration check failed: ${result.missing.length} destination file(s) are missing and ${result.notFiles.length} destination path(s) are not regular files.`);
process.exitCode = 1; process.exitCode = 1;
} }

View file

@ -0,0 +1,26 @@
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { checkRenderedOutput } from './lib/rendered-output.mjs';
async function main() {
const distRoot = process.argv[2] ?? 'dist';
const result = await checkRenderedOutput({ distRoot });
const issues = [
...result.headingIssues.map(({ file, h1Count }) => `${file} contains ${h1Count} H1 headings; expected exactly 1`),
...result.unpublishedPaths.map((path) => `unpublished to-be-studied path reached built output: ${path}`),
];
if (issues.length > 0) {
throw new Error(`Rendered-output check failed with ${issues.length} issue(s):\n${issues.map((issue) => `- ${issue}`).join('\n')}`);
}
console.log(`Rendered-output check passed: ${result.htmlFilesChecked} published pages, exactly one H1 each, no to-be-studied paths.`);
}
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,118 @@
import { readFile, readdir, stat } from 'node:fs/promises';
import { isAbsolute, join, relative, resolve, 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 normalized = decoded.replaceAll('\\', '/');
if (normalized.split('/').includes('..')) return false;
const absoluteDistRoot = resolve(distRoot);
const relativeTarget = normalized.replace(/^\/+/, '');
const exact = resolve(absoluteDistRoot, relativeTarget);
const candidates = normalized.endsWith('/')
? [join(exact, 'index.html')]
: [exact, join(exact, 'index.html')];
for (const candidate of candidates) {
const candidateRelativePath = relative(absoluteDistRoot, candidate);
if (isAbsolute(candidateRelativePath)
|| candidateRelativePath === '..'
|| candidateRelativePath.startsWith(`..${sep}`)) {
continue;
}
try {
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,
};
}

467
scripts/lib/live-wiki.mjs Normal file
View file

@ -0,0 +1,467 @@
import { createHash } from 'node:crypto';
import { access, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { parseHTML } from 'linkedom';
import TurndownService from 'turndown';
export const LIVE_WIKI_BASE_URL = 'https://wiki.msvincognito.nl/';
const ASSET_EXTENSION = /\.(?:avif|bmp|css|csv|docx?|eot|gif|ico|jpe?g|js|json|map|mp3|mp4|ogg|otf|pdf|png|pptx?|rar|rss|svg|tar|tiff?|tsv|txt|wav|webm|webmanifest|webp|woff2?|xlsx?|xml|zip)$/i;
const NON_CONTENT_SELECTOR = [
'script',
'style',
'nav',
'form',
'button',
'noscript',
'iframe',
'dialog',
'[role="navigation"]',
'[role="contentinfo"]',
].join(',');
class PageLimitError extends Error {}
class SoftNotFoundError extends Error {}
export function normalizeWikiUrl(input, relativeTo = LIVE_WIKI_BASE_URL) {
let url;
try {
url = new URL(input, relativeTo);
} catch {
return null;
}
const baseUrl = new URL(LIVE_WIKI_BASE_URL);
if (url.origin !== baseUrl.origin || !['http:', 'https:'].includes(url.protocol)) {
return null;
}
url.search = '';
url.hash = '';
url.pathname = url.pathname.replace(/\/{2,}/g, '/');
if (url.pathname !== '/') {
url.pathname = url.pathname.replace(/\/+$/, '');
}
if (ASSET_EXTENSION.test(url.pathname)) {
return null;
}
return url.href;
}
export function routeToCapturePath(input) {
const normalizedUrl = normalizeWikiUrl(input);
if (!normalizedUrl) {
throw new TypeError(`Cannot map non-content URL to a capture path: ${input}`);
}
const { pathname } = new URL(normalizedUrl);
if (pathname === '/') {
return 'index.md';
}
const route = pathname
.split('/')
.filter(Boolean)
.map((segment) => validatePathSegment(segment))
.join('/');
return `${route}.md`;
}
export function extractPage(html, url) {
const { document } = parseHTML(html);
const content = document.querySelector('main')
?? document.querySelector('article')
?? document.body;
if (!content) {
throw new Error(`No readable content root found at ${url}`);
}
const links = new Set();
const currentUrl = normalizeWikiUrl(url);
for (const anchor of content.querySelectorAll('a[href]')) {
const normalizedLink = normalizeWikiUrl(anchor.getAttribute('href'), url);
if (normalizedLink && normalizedLink !== currentUrl) {
links.add(normalizedLink);
anchor.setAttribute('href', normalizedLink);
}
}
const heading = content.querySelector('h1, h2, h3');
const title = cleanText(heading?.textContent)
|| cleanText(document.querySelector('title')?.textContent)
|| titleFromUrl(url);
for (const element of content.querySelectorAll(NON_CONTENT_SELECTOR)) {
element.remove();
}
removeLiveWikiChrome(content);
const turndown = new TurndownService({
bulletListMarker: '-',
codeBlockStyle: 'fenced',
emDelimiter: '*',
headingStyle: 'atx',
});
turndown.addRule('lineBreakWithoutTrailingWhitespace', {
filter: 'br',
replacement: () => '<br>\n',
});
const markdown = `${turndown.turndown(content).trim().replace(/[ \t]+$/gm, '')}\n`;
return {
title,
markdown,
links: [...links].sort(),
};
}
export async function crawlWiki({
capturedAt = new Date().toISOString().slice(0, 10),
delayMs = 150,
fetchImpl = globalThis.fetch,
manifestPath,
maxPages = 500,
maxRedirects = 5,
outputRoot,
startUrls = [LIVE_WIKI_BASE_URL],
waitImpl = wait,
} = {}) {
if (!outputRoot || !manifestPath) {
throw new TypeError('crawlWiki requires outputRoot and manifestPath.');
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(capturedAt)) {
throw new TypeError('capturedAt must use YYYY-MM-DD format.');
}
if (!Number.isInteger(maxPages) || maxPages < 1) {
throw new TypeError('maxPages must be a positive integer.');
}
if (!Number.isInteger(maxRedirects) || maxRedirects < 0) {
throw new TypeError('maxRedirects must be a non-negative integer.');
}
const queue = [];
const discovered = new Set();
for (const startUrl of startUrls) {
discover(startUrl, LIVE_WIKI_BASE_URL, discovered, queue, maxPages);
}
const pages = [];
const failures = [];
const capturedUrls = new Set();
const captureDestinations = new Map();
let requestCount = 0;
await mkdir(path.dirname(outputRoot), { recursive: true });
const stagingRoot = await mkdtemp(path.join(
path.dirname(outputRoot),
`.${path.basename(outputRoot)}-staging-`,
));
const stagedOutputRoot = path.join(stagingRoot, 'snapshot');
const stagedManifestPath = path.join(stagingRoot, 'manifest.json');
await mkdir(stagedOutputRoot, { recursive: true });
try {
while (queue.length > 0) {
queue.sort();
const url = queue.shift();
if (capturedUrls.has(url)) {
continue;
}
let destination;
let destinationKey;
let destinationOwner;
try {
const fetched = await fetchWithAuthorizedRedirects(url, {
fetchImpl,
maxRedirects,
pace: async () => {
if (requestCount > 0) {
await waitImpl(delayMs);
}
requestCount += 1;
},
});
const { response, url: finalUrl } = fetched;
if (!response) {
throw new Error('No response');
}
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`.trim());
}
if (capturedUrls.has(finalUrl)) {
continue;
}
const contentType = response.headers.get('content-type') ?? '';
if (contentType && !/\b(?:text\/html|application\/xhtml\+xml)\b/i.test(contentType)) {
throw new Error(`Unexpected content type: ${contentType}`);
}
const page = extractPage(await response.text(), finalUrl);
if (page.title === '404' && /This page could not be found\./i.test(page.markdown)) {
throw new SoftNotFoundError('Soft 404: This page could not be found.');
}
for (const link of page.links) {
discover(link, finalUrl, discovered, queue, maxPages);
}
const capturePath = routeToCapturePath(finalUrl);
destinationKey = capturePath.normalize('NFC').toLowerCase();
const existingUrl = captureDestinations.get(destinationKey);
if (existingUrl && existingUrl !== finalUrl) {
throw new Error(`Capture destination collision: ${finalUrl} and ${existingUrl} both map to ${capturePath}`);
}
captureDestinations.set(destinationKey, finalUrl);
destinationOwner = finalUrl;
destination = path.join(stagedOutputRoot, ...capturePath.split('/'));
const finalDestination = path.join(outputRoot, ...capturePath.split('/'));
const content = `> Source: ${finalUrl}\n> Captured: ${capturedAt}\n\n${page.markdown}`;
await mkdir(path.dirname(destination), { recursive: true });
await writeFile(destination, content, 'utf8');
pages.push({
url: finalUrl,
title: page.title,
path: toPosixPath(path.relative(path.dirname(manifestPath), finalDestination)),
contentHash: createHash('sha256').update(content).digest('hex'),
});
capturedUrls.add(finalUrl);
} catch (error) {
if (error instanceof PageLimitError) {
throw error;
}
if (destination) {
await rm(destination, { force: true });
}
if (destinationKey && captureDestinations.get(destinationKey) === destinationOwner) {
captureDestinations.delete(destinationKey);
}
if (error instanceof SoftNotFoundError) {
continue;
}
failures.push({
url,
error: error instanceof Error ? error.message : String(error),
});
}
}
const manifest = {
capturedAt,
baseUrl: LIVE_WIKI_BASE_URL,
pages: pages.sort(compareByUrl),
failures: failures.sort(compareByUrl),
};
await writeFile(stagedManifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
await publishCapture({
manifestPath,
outputRoot,
stagedManifestPath,
stagedOutputRoot,
stagingRoot,
});
return manifest;
} finally {
await rm(stagingRoot, { recursive: true, force: true });
}
}
async function publishCapture({
manifestPath,
outputRoot,
stagedManifestPath,
stagedOutputRoot,
stagingRoot,
}) {
const previousOutput = path.join(stagingRoot, 'previous-snapshot');
const previousManifest = path.join(stagingRoot, 'previous-manifest.json');
let movedPreviousOutput = false;
let movedPreviousManifest = false;
let publishedOutput = false;
let publishedManifest = false;
await mkdir(path.dirname(outputRoot), { recursive: true });
await mkdir(path.dirname(manifestPath), { recursive: true });
try {
if (await pathExists(outputRoot)) {
await rename(outputRoot, previousOutput);
movedPreviousOutput = true;
}
if (await pathExists(manifestPath)) {
await rename(manifestPath, previousManifest);
movedPreviousManifest = true;
}
await rename(stagedOutputRoot, outputRoot);
publishedOutput = true;
await rename(stagedManifestPath, manifestPath);
publishedManifest = true;
} catch (error) {
if (publishedManifest) {
await rm(manifestPath, { force: true });
}
if (publishedOutput) {
await rm(outputRoot, { recursive: true, force: true });
}
if (movedPreviousOutput) {
await rename(previousOutput, outputRoot);
}
if (movedPreviousManifest) {
await rename(previousManifest, manifestPath);
}
throw error;
}
}
async function fetchWithAuthorizedRedirects(startUrl, {
fetchImpl,
maxRedirects,
pace,
}) {
let currentUrl = startUrl;
const visited = new Set([currentUrl]);
for (let redirects = 0; ; redirects += 1) {
await pace();
const response = await fetchImpl(currentUrl, {
headers: {
accept: 'text/html,application/xhtml+xml',
'user-agent': 'Incognito-Wiki research capture (+https://msvincognito.nl/)',
},
redirect: 'manual',
});
if (!response) {
return { response, url: currentUrl };
}
if (![301, 302, 303, 307, 308].includes(response.status)) {
const responseUrl = response.url
? normalizeWikiUrl(response.url, currentUrl)
: currentUrl;
if (!responseUrl) {
throw new Error(`Final response URL is not authorized: ${response.url}`);
}
return { response, url: responseUrl };
}
const location = response.headers.get('location');
if (!location) {
throw new Error(`HTTP ${response.status} redirect is missing a Location header`);
}
const redirectUrl = normalizeWikiUrl(location, currentUrl);
if (!redirectUrl) {
throw new Error(`Redirect target is not an authorized content URL: ${location}`);
}
if (redirects >= maxRedirects) {
throw new Error(`Redirect limit of ${maxRedirects} exceeded at ${redirectUrl}`);
}
if (visited.has(redirectUrl)) {
throw new Error(`Redirect loop detected at ${redirectUrl}`);
}
visited.add(redirectUrl);
currentUrl = redirectUrl;
}
}
async function pathExists(candidate) {
try {
await access(candidate);
return true;
} catch (error) {
if (error?.code === 'ENOENT') {
return false;
}
throw error;
}
}
function cleanText(value = '') {
return value.replace(/\s+/g, ' ').trim();
}
function removeLiveWikiChrome(content) {
for (const header of content.querySelectorAll('header[class]')) {
const classes = new Set(header.classList);
if (classes.has('sticky') && classes.has('top-0') && classes.has('z-40')) {
header.remove();
}
}
for (const element of content.querySelectorAll('[class]')) {
const classes = new Set(element.classList);
const text = cleanText(element.textContent);
const previousNextPanel = element.querySelector('.border-t.pt-6');
const isPreviousNextNavigation = classes.has('mt-16')
&& previousNextPanel?.querySelector('a[href]');
const isRepeatedPageFooter = classes.has('hidden')
&& classes.has('text-sm')
&& classes.has('lg:block')
&& /Star on GitHub|Create Issues/.test(text);
const isGlobalSiteFooter = element.localName === 'footer'
&& classes.has('text-muted-foreground')
&& classes.has('py-6')
&& classes.has('md:px-8')
&& classes.has('md:py-0')
&& /Copyright © \d{4} MSV Incognito/.test(text);
if (isPreviousNextNavigation || isRepeatedPageFooter || isGlobalSiteFooter) {
element.remove();
}
}
}
function validatePathSegment(segment) {
if (!/^[A-Za-z0-9._-]+$/.test(segment) || segment === '.' || segment === '..') {
throw new Error(`Unsafe live-wiki route segment: ${segment}`);
}
return segment;
}
function titleFromUrl(url) {
const { pathname } = new URL(url);
const lastSegment = pathname.split('/').filter(Boolean).at(-1) ?? 'Home';
return decodeURIComponent(lastSegment)
.replace(/[-_]+/g, ' ')
.replace(/\b\p{Letter}/gu, (letter) => letter.toUpperCase());
}
function discover(input, relativeTo, discovered, queue, maxPages) {
const url = normalizeWikiUrl(input, relativeTo);
if (!url || discovered.has(url)) {
return;
}
discovered.add(url);
if (discovered.size > maxPages) {
throw new PageLimitError(`Discovered more than ${maxPages} content pages; refusing to truncate the crawl.`);
}
queue.push(url);
}
function compareByUrl(left, right) {
return left.url.localeCompare(right.url);
}
function toPosixPath(input) {
return input.split(path.sep).join('/');
}
function wait(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

View file

@ -1,12 +1,51 @@
import { readFile } from 'node:fs/promises'; import { readFile, stat } from 'node:fs/promises';
import { access } from 'node:fs/promises'; import { isAbsolute, relative, resolve, sep } from 'node:path';
import { join } from 'node:path';
const SOURCE_COUNT = 29; const SOURCE_COUNT = 29;
const DOCS_ROOT = 'src/content/docs/'; 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) { function hasParentTraversal(path) {
return path.split('/').includes('..'); 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) { export async function loadManifest(path) {
@ -37,13 +76,19 @@ export function validateManifest(manifest) {
if (!['page', 'merge'].includes(mode)) { if (!['page', 'merge'].includes(mode)) {
throw new TypeError('Each migration manifest entry mode must be page or merge.'); throw new TypeError('Each migration manifest entry mode must be page or merge.');
} }
if (source.startsWith('pages/wiki/')) { 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}`); throw new Error(`Generic DokuWiki source is excluded: ${source}`);
} }
if (hasParentTraversal(source) || !(source === 'pages/start.txt' || source === 'pages/study.txt' || source.startsWith('pages/study/'))) { 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}`); throw new Error(`Source is outside the selected migration scope: ${source}`);
} }
if (hasParentTraversal(destination) || !destination.startsWith(DOCS_ROOT)) { if (hasParentTraversal(destination) || !normalizedDestination.startsWith(DOCS_ROOT) || !isContained(virtualDocsRoot, resolvedDestination)) {
throw new Error(`Destination is outside ${DOCS_ROOT}: ${destination}`); throw new Error(`Destination is outside ${DOCS_ROOT}: ${destination}`);
} }
if (sources.has(source)) { if (sources.has(source)) {
@ -57,16 +102,31 @@ export function validateManifest(manifest) {
destinations.add(destination); 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 }; return { entries: manifest.entries };
} }
export async function checkDestinations(manifest, root) { export async function checkDestinations(manifest, root) {
const destinations = [...new Set(manifest.entries.map(({ destination }) => destination))]; const destinations = [...new Set(manifest.entries.map(({ destination }) => destination))];
const missing = []; const missing = [];
const notFiles = [];
const projectRoot = resolve(root);
const docsRoot = resolve(projectRoot, 'src/content/docs');
for (const destination of destinations) { 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 { try {
await access(join(root, destination)); const details = await stat(candidate);
if (!details.isFile()) notFiles.push(destination);
} catch { } catch {
missing.push(destination); missing.push(destination);
} }
@ -75,5 +135,6 @@ export async function checkDestinations(manifest, root) {
return { return {
total: manifest.entries.length, total: manifest.entries.length,
missing, missing,
notFiles,
}; };
} }

View file

@ -0,0 +1,38 @@
import { readFile, readdir } from 'node:fs/promises';
import { join, relative, resolve, sep } from 'node:path';
import { parseHTML } from 'linkedom';
async function listFiles(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 listFiles(root, path));
else if (entry.isFile()) files.push(path);
}
return files.sort();
}
export async function checkRenderedOutput({ distRoot }) {
const absoluteDistRoot = resolve(distRoot);
const files = await listFiles(absoluteDistRoot);
const relativeFiles = files.map((file) => relative(absoluteDistRoot, file).split(sep).join('/'));
const htmlFiles = files.filter((file) => {
const path = relative(absoluteDistRoot, file).split(sep).join('/');
return path.endsWith('.html') && path !== '404.html';
});
const headingIssues = [];
for (const file of htmlFiles) {
const path = relative(absoluteDistRoot, file).split(sep).join('/');
const { document } = parseHTML(await readFile(file, 'utf8'));
const h1Count = document.querySelectorAll('h1').length;
if (h1Count !== 1) headingIssues.push({ file: path, h1Count });
}
return {
htmlFilesChecked: htmlFiles.length,
headingIssues,
unpublishedPaths: relativeFiles.filter((path) => path.split('/').includes('to-be-studied')),
};
}

View file

@ -22,9 +22,58 @@ export const sidebar = [
{ slug: 'bachelor/year-2/honours-programme' }, { slug: 'bachelor/year-2/honours-programme' },
], ],
}, },
{
label: 'Year 3',
items: [
{ slug: 'bachelor/year-3' },
{ slug: 'bachelor/year-3/bachelors-thesis' },
{ slug: 'bachelor/year-3/project-3-1' },
{ slug: 'bachelor/year-3/study-abroad' },
{ slug: 'bachelor/year-3/honours-programme' },
],
},
],
},
{
label: 'Master AI',
items: [
{ slug: 'master-ai' },
{
label: 'Year 1',
items: [
{ slug: 'master-ai/year-1' },
{ slug: 'master-ai/year-1/research-project-1' },
{ slug: 'master-ai/year-1/research-project-2' },
],
},
{
label: 'Year 2',
items: [{ slug: 'master-ai/year-2' }],
},
],
},
{
label: 'Master DSDM',
items: [
{ slug: 'master-dsdm' },
{
label: 'Year 1',
items: [{ slug: 'master-dsdm/year-1' }],
},
{
label: 'Year 2',
items: [{ slug: 'master-dsdm/year-2' }],
},
],
},
{
label: 'Useful Information',
items: [
{ slug: 'useful-information' },
{ slug: 'useful-information/dke-locations' },
{ slug: 'useful-information/handy-locations' },
{ slug: 'useful-information/it-services' },
{ slug: 'useful-information/laptop-buying-advice' },
], ],
}, },
{ label: 'Master AI', items: [] },
{ label: 'Master DSDM', items: [] },
{ label: 'Useful Information', items: [] },
]; ];

View file

@ -3,8 +3,6 @@ title: About Incognito
description: Learn more about MSV Incognito, the study association behind this wiki. description: Learn more about MSV Incognito, the study association behind this wiki.
--- ---
# MSV Incognito MSV Incognito is the study association for the Department of Advanced Computing Sciences at Maastricht University.
MSV Incognito is the study association for the Department of Data Science & Artificial Intelligence at Maastricht University.
Visit the [MSV Incognito website](https://msvincognito.nl/) to learn more about the association, its activities, and opportunities to become active in one of its committees. Visit the [MSV Incognito website](https://msvincognito.nl/) to learn more about the association, its activities, and opportunities to become active in one of its committees.

View file

@ -3,12 +3,10 @@ title: Data Science and Artificial Intelligence
description: Information about the bachelor's programme in Data Science and Artificial Intelligence at Maastricht University. description: Information about the bachelor's programme in Data Science and Artificial Intelligence at Maastricht University.
--- ---
# Data Science and Artificial Intelligence
:::caution[Historical information] :::caution[Historical information]
This information originated in the previous wiki and may be outdated. This information originated in the previous wiki and may be outdated.
::: :::
The following pages contain information about the bachelor's programme of the Department of Data Science and Artificial Intelligence at Maastricht University. Course summaries and old exams can be found in their respective folders. The previous wiki described these pages as information about the bachelor's programme of the Department of Data Science and Artificial Intelligence at Maastricht University. It also said that course summaries and old exams could be found in corresponding folders. Those summaries and exams were not present in the export and are not published here.
For the most up-to-date overview of the courses taught, visit the [Maastricht University education website](https://www.maastrichtuniversity.nl/education). For the most up-to-date overview of the courses taught, visit the [Maastricht University education website](https://www.maastrichtuniversity.nl/education).

View file

@ -3,8 +3,6 @@ title: Year 1
description: Historical schedule context and project information for the first year of the bachelor's programme. description: Historical schedule context and project information for the first year of the bachelor's programme.
--- ---
# Year 1
:::caution[Historical information] :::caution[Historical information]
This information originated in the previous wiki and may be outdated. This information originated in the previous wiki and may be outdated.
::: :::

View file

@ -3,8 +3,6 @@ title: Project 1-1
description: Historical course description and prerequisites for Project 1-1. description: Historical course description and prerequisites for Project 1-1.
--- ---
# Project 1-1
:::caution[Historical information] :::caution[Historical information]
This information originated in the previous wiki and may be outdated. This information originated in the previous wiki and may be outdated.
::: :::

View file

@ -3,8 +3,6 @@ title: Project 1-2
description: Historical course description, prerequisites, and recommended reading for Project 1-2. description: Historical course description, prerequisites, and recommended reading for Project 1-2.
--- ---
# Project 1-2
:::caution[Historical information] :::caution[Historical information]
This information originated in the previous wiki and may be outdated. This information originated in the previous wiki and may be outdated.
::: :::

View file

@ -3,8 +3,6 @@ title: Honours Programme
description: Historical placeholder for the Year 2 honours programme page. description: Historical placeholder for the Year 2 honours programme page.
--- ---
# Honours Programme
:::caution[Historical information] :::caution[Historical information]
This information originated in the previous wiki and may be outdated. This information originated in the previous wiki and may be outdated.
::: :::

View file

@ -3,8 +3,6 @@ title: Year 2
description: Historical schedule context and project information for the second year of the bachelor's programme. description: Historical schedule context and project information for the second year of the bachelor's programme.
--- ---
# Year 2
:::caution[Historical information] :::caution[Historical information]
This information originated in the previous wiki and may be outdated. This information originated in the previous wiki and may be outdated.
::: :::

View file

@ -3,8 +3,6 @@ title: Project 2-1
description: Historical course description, prerequisites, and recommended reading for Project 2-1. description: Historical course description, prerequisites, and recommended reading for Project 2-1.
--- ---
# Project 2-1
:::caution[Historical information] :::caution[Historical information]
This information originated in the previous wiki and may be outdated. This information originated in the previous wiki and may be outdated.
::: :::
@ -23,7 +21,7 @@ The project is accompanied by so-called skills classes. These classes help stude
## Prerequisites ## Prerequisites
A pass for [Project 1-1](../year-1/project-1-1/). Furthermore, a pass for at least two of the following three courses: A pass for [Project 1-1](../../year-1/project-1-1/). Furthermore, a pass for at least two of the following three courses:
- Introduction to Computer Science 1 - Introduction to Computer Science 1
- Introduction to Computer Science 2 - Introduction to Computer Science 2

View file

@ -3,8 +3,6 @@ title: Project 2-2
description: Historical course description, prerequisites, and recommended reading for Project 2-2. description: Historical course description, prerequisites, and recommended reading for Project 2-2.
--- ---
# Project 2-2
:::caution[Historical information] :::caution[Historical information]
This information originated in the previous wiki and may be outdated. This information originated in the previous wiki and may be outdated.
::: :::
@ -23,7 +21,7 @@ The project is accompanied by so-called skills classes. These classes help stude
## Prerequisites ## Prerequisites
A pass for [Project 1-2](../year-1/project-1-2/). Furthermore, a pass for at least two of the following three courses: A pass for [Project 1-2](../../year-1/project-1-2/). Furthermore, a pass for at least two of the following three courses:
- Introduction to Computer Science 1 - Introduction to Computer Science 1
- Introduction to Computer Science 2 - Introduction to Computer Science 2

View file

@ -0,0 +1,22 @@
---
title: Bachelor's Thesis
description: Historical course description, prerequisites, and recommended reading for the bachelor's thesis.
---
:::caution[Historical information]
This information originated in the previous wiki and may be outdated.
:::
## Full course description
At the end of the Data Science & Knowledge Engineering bachelor's programme, each student writes an individual thesis. The thesis is designed as a scientific paper of 8 to 10 pages using a standard LaTeX layout. It is accompanied by any relevant attachments and software.
Students present the thesis in a conference. They are free to choose any related research question to investigate and are guided throughout the entire period by a thesis supervisor.
## Prerequisites
None.
## Recommended reading
None.

View file

@ -0,0 +1,12 @@
---
title: DKE Honours Programme — KE@Work (3-1)
description: Historical placeholder for the Year 3 honours programme page.
---
:::caution[Historical information]
This information originated in the previous wiki and may be outdated.
:::
:::note[Awaiting content]
This page is awaiting content.
:::

View file

@ -0,0 +1,19 @@
---
title: Year 3
description: Historical schedule context and course information for the third year of the bachelor's programme.
---
:::caution[Historical information]
This information originated in the previous wiki and may be outdated.
:::
## Schedule
The Year 3 schedule differed from Years 1 and 2 because the thesis started in Block 4. In Blocks 5 and 6, students worked on the thesis full-time.
## Course pages
- [Bachelor's Thesis](./bachelors-thesis/)
- [Project 3-1](./project-3-1/)
- [Study Abroad](./study-abroad/)
- [DKE Honours Programme — KE@Work (3-1)](./honours-programme/)

View file

@ -0,0 +1,28 @@
---
title: Project 3-1
description: Historical course description, prerequisites, and recommended reading for Project 3-1.
---
:::caution[Historical information]
This information originated in the previous wiki and may be outdated.
:::
## Full course description
The project is carried out by small groups of students and guided by the teachers of the courses from the first period. It relates strongly to the content of the subjects from the first period. The project is split into three phases.
In the first phase, students become familiar with the project assignment and lay a foundation for the next phases. This often involves implementing a basic environment and conducting research related to the project assignment. The first phase concludes with a presentation.
In the second phase, students have obtained a basic platform to work with. In this phase, more advanced concepts are implemented, and a plan is made for phase three. The second phase concludes with a presentation.
Phase three consists of three full-time weeks in which students work together with their project group on the core of the assignment. Before starting this phase, students are expected to have already implemented an environment to work with and obtained a plan for this phase. The third phase concludes with a presentation. Additionally, during the third phase, a scientific paper must be written about the project results.
The project is accompanied by so-called skills classes. These classes help students develop competences that are useful when working on a project assignment with a group. The skills classes vary in topic from technical concepts, such as working with LaTeX, to social concepts, such as leadership, presentation skills, and scientific writing.
## Prerequisites
A pass for [Project 2-1](../../year-2/project-2-1/).
## Recommended reading
None.

View file

@ -0,0 +1,33 @@
---
title: Study Abroad
description: Historical study-abroad guidance for the third year of the bachelor's programme.
---
:::caution[Historical information]
This information originated in the previous wiki and may be outdated.
:::
:::caution[2022 information — verify before relying on it]
The cities, GPA threshold, fee arrangements, and selection process below were documented in 2022. They must be verified against current Maastricht University exchange information. For a current UM study-abroad landing page, see [Study abroad at the Department of Advanced Computing Sciences](https://www.maastrichtuniversity.nl/education/exchange/faculty-science-and-engineering/study-abroad).
:::
In the third year, students had the opportunity to study the first semester at a university abroad. They could choose a partner university, where no additional fees were required, or a university of their choice. For a university of their choice, students had to make contact themselves and pay tuition fees to both UM and the other university.
## Motivation letter
Students were asked to write a detailed motivation letter of one to two pages explaining their educational and personal reasons for participating in the Semester Abroad programme. The letter was to address the following questions:
- Why is participating in a semester-abroad programme important to you?
- How would this programme complement your academic experience to date?
- What are your expectations for personal growth?
- Which academic areas or subjects are you interested in?
## 2022 locations and selection
In 2022, the available cities were Reykjavik, Rome, and Copenhagen. When applying for an exchange, students had to indicate their top three locations. The university selected placements based on grades and the motivation letter. Generally, a GPA of at least 7.5 was required, although exceptions could sometimes be made.
## Choosing a location
Students who went on exchange often considered their own location the best one. The exchange experience was described as valuable, so the final location was presented as less important than the overall experience. When making a choice, consider expenses in the other country, the courses at the university, student life, and the city itself.
The previous wiki stated that students who had gone on exchange were asked to write about their experiences to help others make this choice.

View file

@ -7,9 +7,7 @@ editUrl: false
import { CardGrid, LinkButton, LinkCard } from '@astrojs/starlight/components'; import { CardGrid, LinkButton, LinkCard } from '@astrojs/starlight/components';
# Welcome to the knowledge base of MSV Incognito MSV Incognito is the study association for the Department of Advanced Computing Sciences at Maastricht University. This wiki brings together study information and course resources for the programmes we support.
MSV Incognito is the study association for the Department of Data Science & Artificial Intelligence at Maastricht University. This wiki brings together study information and course resources for the programmes we support.
<p> <p>
<LinkButton href="https://msvincognito.nl/" icon="external">Visit MSV Incognito</LinkButton> <LinkButton href="https://msvincognito.nl/" icon="external">Visit MSV Incognito</LinkButton>
@ -27,7 +25,9 @@ MSV Incognito is the study association for the Department of Data Science & Arti
## Historical programme notes ## Historical programme notes
This information originated in the previous wiki and may be outdated. For current curricula, consult the [official Maastricht University programme pages](https://www.maastrichtuniversity.nl/about-um/faculties/humanities-and-sciences/department-data-science-knowledge-engineering). :::caution[Historical information]
This information originated in the previous wiki and may be outdated. For current curricula, consult the [Maastricht University education website](https://www.maastrichtuniversity.nl/education).
:::
### Maastricht University ### Maastricht University
@ -54,5 +54,5 @@ The bachelors programme includes Java and Matlab as primary languages, with P
## Useful official links ## Useful official links
- [MSV Incognito website](https://msvincognito.nl/) - [MSV Incognito website](https://msvincognito.nl/)
- [Maastricht University department website](https://www.maastrichtuniversity.nl/about-um/faculties/humanities-and-sciences/department-data-science-knowledge-engineering) - [Department of Advanced Computing Sciences](https://www.maastrichtuniversity.nl/research/department-advanced-computing-sciences)
- [Maastricht University ICT manuals](https://www.maastrichtuniversity.nl/support/ict-services/manuals-and-information) - [Maastricht University ICT manuals](https://www.maastrichtuniversity.nl/support/ict-services/manuals-and-information)

View file

@ -0,0 +1,12 @@
---
title: Artificial Intelligence
description: Historical information about the master's programme in Artificial Intelligence at Maastricht University.
---
:::caution[Historical information]
This information originated in the previous wiki and may be outdated.
:::
The previous wiki described these pages as information about the master's programme in Artificial Intelligence at the Department of Data Science and Knowledge Engineering at Maastricht University. It also said that course summaries and old exams could be found in corresponding folders. Those summaries and exams were not present in the export and are not published here.
For the most up-to-date overview of the courses taught, visit the [Maastricht University education website](https://www.maastrichtuniversity.nl/education).

View file

@ -0,0 +1,37 @@
---
title: Year 1
description: Historical course and research-project information for the first year of the master's programme in Artificial Intelligence.
---
:::caution[Historical information]
This information originated in the previous wiki and may be outdated.
:::
## Schedule
Year 1 has two courses per block.
## Core courses
:::note[Historical course list]
The following course list originated in the previous wiki and may have changed.
:::
- Block 1: Foundations of Agents and Intelligent Search & Games.
- Block 2: Multi-Agent Systems and Advanced Concepts in Machine Learning.
- Block 4: Autonomous Robotic Systems.
## Electives
:::note[Historical course list]
The following elective list originated in the previous wiki and may have changed.
:::
- Block 4: one elective from Algorithms for Big Data, Dynamic Game Theory, and Building and Mining Knowledge Graphs.
- Block 5: two electives from Information Retrieval and Text Mining, Deep Learning, Planning and Scheduling, and Computer Vision.
## Research projects
- [Research Project MAI 1](./research-project-1/)
- [Research Project MAI 2](./research-project-2/)

View file

@ -0,0 +1,23 @@
---
title: Research Project MAI 1
description: Historical description, prerequisites, and recommended reading for Research Project MAI 1.
---
:::caution[Historical information]
This information originated in the previous wiki and may be outdated.
:::
## Full course description
The research project takes place in the three blocks of the first semester of the first year of the master's programme. The emphasis in the first two blocks is on exploration and modelling, and in block 3 on implementation and experimenting. The subject of the project is closely related to the courses in the semester.
The research project is performed in small groups of 35 students. During the project, students practise team-based research and a range of scientific skills. The project results in a project presentation, a project report, and possibly a product. At the end of each block, a project presentation takes place. Examination: project presentation, report, and product.
## Prerequisites
No prerequisites.
## Recommended reading
None.

View file

@ -0,0 +1,23 @@
---
title: Research Project MAI 2
description: Historical description, prerequisites, and recommended reading for Research Project MAI 2.
---
:::caution[Historical information]
This information originated in the previous wiki and may be outdated.
:::
## Full course description
The research project takes place in the three blocks of the second semester of the first year of the master's programme. The emphasis in the first two blocks is on exploration and modelling, and in block 6 on implementation and experimenting. The subject of the project is closely related to the courses in the semester.
The research project is performed in small groups of 35 students. During the project, students practise team-based research and a range of scientific skills. The project results in a project presentation, a project report, and possibly a product. At the end of each block, a project presentation takes place. Examination: project presentation, report, and product.
## Prerequisites
No prerequisites.
## Recommended reading
None.

View file

@ -0,0 +1,19 @@
---
title: Year 2
description: Historical elective and thesis information for the second year of the master's programme in Artificial Intelligence.
---
:::caution[Historical information]
This information originated in the previous wiki and may be outdated.
:::
## Schedule
:::note[Historical course list]
The following study structure originated in the previous wiki and may have changed.
:::
The first semester of Year 2 consists of 30 credits of electives.
The second semester of Year 2 consists of a 30-credit master's thesis.

View file

@ -0,0 +1,12 @@
---
title: Data Science for Decision Making
description: Historical information about the master's programme in Data Science for Decision Making at Maastricht University.
---
:::caution[Historical information]
This information originated in the previous wiki and may be outdated.
:::
The previous wiki described these pages as information about the master's programme in Data Science for Decision Making at the Department of Data Science and Knowledge Engineering at Maastricht University. It also said that course summaries and old exams could be found in corresponding folders. Those summaries and exams were not present in the export and are not published here.
For the most up-to-date overview of the courses taught, visit the [Maastricht University education website](https://www.maastrichtuniversity.nl/education).

View file

@ -0,0 +1,30 @@
---
title: Year 1
description: Historical course, elective, and project information for the first year of the master's programme in Data Science for Decision Making.
---
:::caution[Historical information]
This information originated in the previous wiki and may be outdated.
:::
## Schedule
Year 1 has two courses per block in Blocks 1, 2, 4, and 5. There are four core courses, four electives, and two semester projects.
## Core courses
- Block 1: Data Mining.
- Block 2: Model Identification and Data Fitting.
- Block 4: Algorithms for Big Data.
- Block 5: Planning and Scheduling.
## Electives
- Block 1: Signal and Image Processing, Mathematical Optimization, and Stochastic Decision-Making.
- Block 2: Advanced Concepts in Machine Learning and Applications of Image and Video Processing.
- Block 4: Building and Mining Knowledge Graphs, Information Security, and Dynamic Game Theory.
- Block 5: Symbolic Computation and Control, Deep Learning, Information Retrieval and Text Mining, and Computer Vision.
## Semester projects
The first year includes two semester projects.

View file

@ -0,0 +1,14 @@
---
title: Year 2
description: Historical elective and thesis information for the second year of the master's programme in Data Science for Decision Making.
---
:::caution[Historical information]
This information originated in the previous wiki and may be outdated.
:::
## Schedule
The first semester of Year 2 consists of 30 credits of electives.
The second semester of Year 2 consists of a 30-credit master's thesis.

View file

@ -0,0 +1,32 @@
---
title: Locations of DKE
description: Historical location information for the Department of Data Science and Knowledge Engineering at Maastricht University.
---
:::caution[Historical information]
This information originated in the previous wiki and may be outdated. Verify your current schedule and location with Maastricht University.
:::
DKE used several locations. If you see abbreviations on your schedule and do not know where to go, this list may help.
The previous wiki stated that all courses were in Paul-Henri Spaaklaan 1, 6229 EN Maastricht. Verify the current location before travelling.
## Historical locations
The locations below are retained as historical references.
### BOU8-10
If the schedule lists BOU8-10, or only numbers such as 0.015, 1.001, or 2.009, the room was at Bouillonstraat 8-10. [View the historical location on Google Maps](https://www.google.nl/maps/place/Bouillonstraat+8,+6211+LH+Maastricht/).
### Tapijn Building Z
Building Z was a building at the Tapijnkazerne. It contained one lecture hall. [View the historical location on Google Maps](https://www.google.nl/maps/place/Maastricht+University+-+Department+of+Knowledge+Engineering/@50.8435509,5.6865808,19.79z).
### SSK39
SSK39 was at Sint Servaasklooster 39, 6211 TE Maastricht. [View the historical location on Google Maps](https://www.google.nl/maps/place/Sint+Servaasklooster+39,+6211+TE+Maastricht).
## Other locations
Lectures or exams were occasionally held in different buildings. If you encounter an abbreviation, please add it to this list.

View file

@ -0,0 +1,24 @@
---
title: Handy Locations
description: Historical suggestions for useful student locations in Maastricht.
---
:::caution[Historical information]
This information originated in the previous wiki and may be outdated.
:::
## Study locations
The University Libraries are a good starting point. The previous wiki noted that there was one in Randwyck and one in the city centre.
## Food locations
:::note[Awaiting content]
This section is awaiting content.
:::
## Other locations
:::note[Awaiting content]
This section is awaiting content.
:::

View file

@ -0,0 +1,21 @@
---
title: Useful Information
description: Historical student information for the Department of Data Science and Knowledge Engineering at Maastricht University.
---
:::caution[Historical information]
This information originated in the previous wiki and may be outdated.
:::
The following pages contain useful information for students of the Department of Data Science and Knowledge Engineering at Maastricht University, including information about IT services and where to find the Rules and Regulations.
For the most up-to-date overview of our studies, visit the [Maastricht University education website](https://www.maastrichtuniversity.nl/education).
Repository maintainers can add useful information to the most relevant Markdown page and update the manual sidebar. They should add a new page only when no existing page fits.
## Useful pages
- [Locations of DKE](./dke-locations/)
- [Handy Locations](./handy-locations/)
- [IT Services](./it-services/)
- [Laptop Buying Advice](./laptop-buying-advice/)

View file

@ -0,0 +1,96 @@
---
title: IT Services
description: Historical IT service guidance for Maastricht University students.
---
:::caution[Historical information]
This information originated in the previous wiki and may be outdated. The instructions below are retained for reference only. Verify EleUM, `unimaas.nl` accounts, MyUM navigation, 20162017 timetable information, VPN access, printing, service-desk hours, and every contact detail with Maastricht University before relying on them.
:::
The previous wiki asked students to keep this page updated. If an old how-to does not work, consult current Maastricht University ICT documentation and notify Incognito.
## Manuals for UM ICT services
The previous wiki linked to [manuals for UM ICT services](https://www.maastrichtuniversity.nl/support/ict-services/manuals). Verify that it covers the service you need.
## Problems with UM ICT services
The following historical service-desk information must be verified before use.
The ICTS Service Desk was open Monday to Friday from 09:00 to 12:30 and from 13:00 to 16:30.
ICTS could help with:
- Login problems with University services such as eduroam, the Student Portal, EleUM, MyTimetable, VPN, and remote access.
- Being unable to get on Wi-Fi (although it could not help if the issue was with your device).
- Software issues with software provided by ICTS.
- In general, services provided by ICTS.
ICTS could not help with:
- Problems with Word, PowerPoint, or similar software.
- Lost files.
- Defective devices.
- Other problems with software, hardware, or services that were not provided by ICTS.
Historical contact details — verify before use:
- Phone: +31 43 388 55 55
- Email: `servicedesk-icts@maastrichtuniversity.nl`
The ICTS Service Desk Inner City was said to be on the right when entering the inner-city library. [View the historical location on Google Maps](https://www.google.nl/maps/place/Grote+Looiersstraat+17,+6211+JH+Maastricht/).
The previous wiki also stated that an ICTS Service Desk Randwyck location could be found somewhere, but that DKE students did not visit Randwyck often.
## Getting on the University Wi-Fi (eduroam)
The historical instructions below must be verified against current UM guidance:
1. Select eduroam.
2. Enter your username with `@unimaas.nl` added, for example `i********@unimaas.nl`.
3. Enter your password.
4. Connect.
If this did not work, the previous wiki advised checking the following settings:
- EAP method: PEAP
- Phase 2 authentication: MSCHAPv2
**Warning:** Do not use the University network for illegal purposes. By using the network, you accept the rules and regulations in effect. The previous wiki gave torrent downloads as an example that could lead to account suspension. Verify the current rules and consequences with UM.
## Where can I find my schedules and other information?
Most course information was said to be available on EleUM, the Electronic Learning Environment from Maastricht University, through the Student Portal. The historical navigation was:
1. Go to the [Student Portal](https://studentportal.maastrichtuniversity.nl/) (previously `https://eleum.unimaas.nl`).
2. Go to Organisations.
3. Go to DKE Students.
4. Go to Schedules.
If a schedule was not available, the previous wiki advised waiting and contacting the Education Office. Both the navigation and contact route must be verified.
The statement that DKE would begin using `timetable.maastrichtuniversity.nl` in 20162017, with calendar export, is historical and must be verified.
## Where can I find my grades?
The previous wiki instructed students to find grades and ECTS in `myum.unimaas.nl` through **Courses → Academic Work**. This MyUM navigation is historical and must be verified.
## How do I enrol in my courses?
The previous wiki instructed students to enrol through `myum.unimaas.nl` using **Courses → Course Registration**, then selecting their study and academic period. It advised contacting Bureau Onderwijs (the Education Office) at `bo-dke@maastrichtuniversity.nl` for help. This navigation and contact detail must be verified.
For exam-only registration, such as after failing an exam in the previous year, the previous wiki instructed students to use **MyUM → Exams → Exam Registration** and gave the same Education Office contact. Verify both before use.
## Remote use of University computers
The previous wiki described Student Desktop Anywhere for accessing personal files on the University network or software not installed on your personal computer, such as MATLAB and LaTeX. It linked to [Student Desktop Anywhere](https://maastrichtuniversity.nl/anywhere). Verify the services availability before use.
## Get on the University network via a VPN connection
The previous wiki described Cisco AnyConnect and gave Google Scholar as an example of a service with more results from a University IP address. Its historical VPN URL was `https://vpn.maastrichtuniversity.nl`; verify it before use.
**Warning:** Do not use the University network for illegal purposes. By using the network, you accept the rules and regulations in effect. The previous wiki gave torrent downloads as an example that could lead to account suspension. Verify the current rules and consequences with UM.
## Print to University printers from your own laptop
The previous wiki directed students to [its printing URL](https://myprint.maastrichtuniversity.nl/) for printing instructions and to add money to a printing account. Verify the current process before use.

View file

@ -0,0 +1,47 @@
---
title: Laptop Buying Advice
description: Historical laptop-buying guidance for DKE students.
---
:::caution[Historical information]
This information originated in the previous wiki and may be outdated. Prices, Windows versions, hardware capacities, and processor availability below are historical and must be verified before making a purchase.
:::
MSV Incognito often received questions from prospective students about the hardware required to follow a programme in our studies. To streamline things, we created this short page.
## Is a laptop required?
Having a laptop is required for computer labs in certain courses. It is also very handy for group projects. You will need a laptop to write, compile, and run Java, Python, and C code. This will, of course, take longer on low-end laptops.
Guidelines from the previous wiki:
- No Chromebook. The laptop must run Windows, Linux, or macOS. This guide only covers Windows laptops, because that is what companies sell and new MacBooks are generally powerful enough anyway.
- AMD Ryzen or modern Intel processors are both fine. Please note that AMD Ryzen CPUs were only recently becoming available in laptops when this guidance was written.
- A 15.6-inch laptop is the most commonly used size. A 14-inch laptop is still fine, depending on your preferences, but anything smaller can make it difficult to type a lot of code (keyboard size) and read code (screen size and resolution). Seventeen-inch laptops are rather large to transport. However, this is down to personal preference.
- Make sure you get a laptop with the keyboard layout you are used to: probably QWERTY for Dutch students, QWERTZ for Germans, AZERTY for French and some Belgian students.
## Laptop tiers
To be blunt: everything that could run Windows 10 or a recent Linux variant properly should suffice. In theory, you could manage your studies on a laptop with a low-power Intel or AMD processor, 2 GB RAM, and 16 GB flash memory. But that would lead to annoyances and is not something we recommend. On the other hand, everything would also run on a laptop with a high-end multi-core AMD or Intel processor, 32 GB RAM, and a 1 TB SSD. Depending on your budget, you could even add a dedicated graphics card.
The prices below are historical indications. Many other factors, such as screen quality, battery life, and build quality, influence the price. There are many websites that provide quality reviews to inform you about specific laptop models. Consider finding the right laptop as a small precursor to doing academic research for the projects during your studies.
### Approximately €200
This is the “it runs” category: the territory of an Intel Atom or Celeron processor, 2 GB RAM, and 16 GB storage. We do not recommend this, but as long as it runs Windows or Linux, it is technically possible to finish the tasks required for the study.
### Approximately €400
This territory is suitable for a student with a limited budget. Laptops with a recent Intel Core i3 or AMD Ryzen 3, 4 GB RAM, and a 128 GB SSD were available.
### Approximately €600
The best price-to-performance ratio was around this price range. A recent Intel Core i5 or AMD Ryzen 5, 8 GB RAM, and a 256 GB SSD was a good laptop configuration and would run anything required for your studies without problems.
### Approximately €800
If you had a bigger budget, a laptop with a recent Intel Core i7 or AMD Ryzen 7, 8 GB RAM, and a 256 GB SSD would give a minor speed boost over the previous tier. This was also the range where laptops gained something resembling gaming-capable graphics cards.
### Gaming
If you wanted your laptop to run games beyond League of Legends or other light games, the options generally started around €900. There were laptops in that category with an AMD or Nvidia GPU capable of running most games. Medium- and high-end gaming laptops were €1,000€3,000. However, that is outside the scope of this page, since it is not needed for your studies.

View file

@ -1,6 +1,283 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises'; import { cp, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { auditContent } from '../scripts/audit-content.mjs';
import { loadManifest } from '../scripts/lib/migration.mjs';
async function withAuditFixture(run) {
const root = await mkdtemp(join(tmpdir(), 'incognito-audit-'));
const docsRoot = join(root, 'src/content/docs');
try {
await mkdir(join(root, 'src/content'), { recursive: true });
await cp('src/content/docs', docsRoot, { recursive: true });
await mkdir(join(root, 'docs'), { recursive: true });
await cp('docs/migration-report.md', join(root, 'docs/migration-report.md'));
await mkdir(join(root, 'public'), { recursive: true });
const manifest = structuredClone(await loadManifest('docs/migration-manifest.json'));
await run({ root, docsRoot, manifest });
} finally {
await rm(root, { recursive: true, force: true });
}
}
async function expectAuditFailure(mutate, pattern) {
await withAuditFixture(async ({ root, docsRoot, manifest }) => {
await mutate({ root, docsRoot, manifest });
await assert.rejects(
auditContent({ docsRoot, manifest }),
pattern,
);
});
}
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);
});
test('published source pages leave the sole H1 to Starlight frontmatter rendering', async () => {
const manifest = await loadManifest('docs/migration-manifest.json');
const destinations = [...new Set(manifest.entries.map(({ destination }) => destination))];
for (const destination of destinations) {
const content = await readFile(destination, 'utf8');
assert.doesNotMatch(content, /^#\s+/m, `${destination} contains a body H1`);
}
});
test('present-day association introductions use the current DACS name', async () => {
for (const destination of [
'src/content/docs/index.mdx',
'src/content/docs/about-incognito.md',
]) {
const content = await readFile(destination, 'utf8');
assert.match(content, /Department of Advanced Computing Sciences/);
assert.doesNotMatch(content, /study association for the Department of Data Science & Artificial Intelligence/);
}
});
test('programme overviews disclose that promised summaries and exams were absent from the export', async () => {
for (const destination of [
'src/content/docs/bachelor/index.md',
'src/content/docs/master-ai/index.md',
'src/content/docs/master-dsdm/index.md',
]) {
const content = await readFile(destination, 'utf8');
assert.match(content, /not present in the export/i);
assert.doesNotMatch(content, /Course summaries and old exams can be found in their respective folders\./);
}
});
test('the home historical warning uses the standard Starlight caution', async () => {
const content = await readFile('src/content/docs/index.mdx', 'utf8');
assert.match(content, /:::caution\[Historical information\]\nThis information originated in the previous wiki and may be outdated\.[\s\S]*?\n:::/);
});
test('Useful Information scopes contribution guidance to repository maintainers', async () => {
const content = await readFile('src/content/docs/useful-information/index.md', 'utf8');
assert.match(content, /Repository maintainers/);
assert.doesNotMatch(content, /please create a new page/i);
});
test('DSDM year pages rely on their page-level caution without redundant inner notes', async () => {
for (const destination of [
'src/content/docs/master-dsdm/year-1/index.md',
'src/content/docs/master-dsdm/year-2/index.md',
]) {
const content = await readFile(destination, 'utf8');
assert.doesNotMatch(content, /:::note\[Historical (?:course list|study structure)\]/);
}
});
test('all 29 migration-report rows classify link handling truthfully', async () => {
const report = await readFile('docs/migration-report.md', 'utf8');
const expectedPrefixes = new Map([
['pages/start.txt', '**Converted source links:**'],
['pages/study.txt', '**Converted source links:**'],
['pages/study/msv_incognito.txt', '**Converted source link:**'],
['pages/study/bachelor.txt', '**Converted source link:**'],
['pages/study/bachelor/year_1.txt', '**Newly verified links:**'],
['pages/study/bachelor/year_1/project_1-1.txt', '**None.**'],
['pages/study/bachelor/year_1/project_1-2.txt', '**None.**'],
['pages/study/bachelor/year_2.txt', '**Newly verified links:**'],
['pages/study/bachelor/year_2/honours_programme.txt', '**None.**'],
['pages/study/bachelor/year_2/project_2-1.txt', '**Converted plain-text reference:**'],
['pages/study/bachelor/year_2/project_2-2.txt', '**Converted plain-text reference:**'],
['pages/study/bachelor/year_3.txt', '**Newly verified links:**'],
['pages/study/bachelor/year_3/bachelors_thesis.txt', '**None.**'],
['pages/study/bachelor/year_3/honours_programme.txt', '**None.**'],
['pages/study/bachelor/year_3/project_3-1.txt', '**Converted plain-text reference:**'],
['pages/study/bachelor/year_3/study_abroad.txt', '**Newly verified link:**'],
['pages/study/master_ai.txt', '**Converted source link:**'],
['pages/study/master_ai/year_1.txt', '**Newly verified links:**'],
['pages/study/master_ai/year_1/project_mai_1.txt', '**None.**'],
['pages/study/master_ai/year_1/project_mai_2.txt', '**None.**'],
['pages/study/master_ai/year_2.txt', '**None.**'],
['pages/study/master_dsdm.txt', '**Converted source link:**'],
['pages/study/master_dsdm/year_1.txt', '**None.**'],
['pages/study/master_dsdm/year_2.txt', '**None.**'],
['pages/study/useful_information.txt', '**Converted source link:**'],
['pages/study/useful_information/pages/dke_locations.txt', '**Converted source links:**'],
['pages/study/useful_information/pages/handy_locations.txt', '**None.**'],
['pages/study/useful_information/pages/it_services.txt', '**Converted source links:**'],
['pages/study/useful_information/pages/laptop_buy_advice.txt', '**None.**'],
]);
const rows = report.split('\n').filter((line) => line.startsWith('| `pages/'));
assert.equal(rows.length, expectedPrefixes.size);
for (const row of rows) {
const cells = row.split(' | ');
const source = cells[0].match(/^\| `([^`]+)`$/)?.[1];
assert.ok(source, `could not parse report source from ${row}`);
assert.ok(expectedPrefixes.has(source), `unexpected report source ${source}`);
assert.ok(
cells[4].startsWith(expectedPrefixes.get(source)),
`${source} link handling must start with ${expectedPrefixes.get(source)}`,
);
}
assert.match(report, /\| Link handling \|/);
assert.doesNotMatch(report, /Surfgroepen|DKE file server|old namespace link/i);
assert.match(
report,
/pages\/study\/bachelor\/year_3\/study_abroad\.txt[^\n]*\*\*Newly verified link:\*\* the official UM study-abroad page/,
);
});
test('audit rejects a published page with missing frontmatter', async () => {
await expectAuditFailure(async ({ docsRoot }) => {
await writeFile(join(docsRoot, 'about-incognito.md'), 'Missing frontmatter.\n');
}, /missing YAML frontmatter/);
});
test('audit rejects frontmatter with an empty required field', async () => {
await expectAuditFailure(async ({ docsRoot }) => {
await writeFile(join(docsRoot, 'about-incognito.md'), [
'---',
'title:',
'description: Still described.',
'---',
'',
'Body.',
].join('\n'));
}, /missing non-empty title frontmatter/);
});
test('audit rejects syntactically malformed YAML frontmatter', async () => {
await expectAuditFailure(async ({ docsRoot }) => {
await writeFile(join(docsRoot, 'about-incognito.md'), [
'---',
'title: "unterminated',
'description: Still described.',
'---',
'',
'Body.',
].join('\n'));
}, /malformed YAML frontmatter/);
});
test('audit rejects forbidden DokuWiki syntax in published content', async () => {
await expectAuditFailure(async ({ docsRoot }) => {
const page = join(docsRoot, 'about-incognito.md');
await writeFile(page, `${await readFile(page, 'utf8')}\n[[forbidden:link]]\n`);
}, /contains forbidden DokuWiki token/);
});
test('audit rejects a missing published document', async () => {
await expectAuditFailure(async ({ docsRoot }) => {
await rm(join(docsRoot, 'about-incognito.md'));
}, /missing manifest destination: src\/content\/docs\/about-incognito\.md/);
});
test('audit rejects an extra Markdown document outside the manifest destination set', async () => {
await expectAuditFailure(async ({ docsRoot }) => {
await writeFile(join(docsRoot, 'extra.md'), [
'---',
'title: Extra',
'description: Not in the migration contract.',
'---',
].join('\n'));
}, /published content file is absent from manifest: src\/content\/docs\/extra\.md/);
});
test('audit rejects a directory where the manifest requires a regular file', async () => {
await expectAuditFailure(async ({ docsRoot }) => {
const destination = join(docsRoot, 'about-incognito.md');
await rm(destination);
await mkdir(destination);
}, /manifest destination is not a regular file: src\/content\/docs\/about-incognito\.md/);
});
test('audit rejects coordinated manifest and report drift from the canonical sources', async () => {
await expectAuditFailure(async ({ root, manifest }) => {
const original = 'pages/study/msv_incognito.txt';
const fabricated = 'pages/study/fabricated.txt';
manifest.entries[2].source = fabricated;
const reportPath = join(root, 'docs/migration-report.md');
const report = await readFile(reportPath, 'utf8');
await writeFile(reportPath, report.replace(original, fabricated));
}, /canonical migration contract/);
});
test('audit rejects coordinated destination, report, and published-file drift', async () => {
await expectAuditFailure(async ({ root, docsRoot, manifest }) => {
const original = 'src/content/docs/about-incognito.md';
const drifted = 'src/content/docs/association.md';
manifest.entries[2].destination = drifted;
await rename(
join(docsRoot, 'about-incognito.md'),
join(docsRoot, 'association.md'),
);
const reportPath = join(root, 'docs/migration-report.md');
const report = await readFile(reportPath, 'utf8');
await writeFile(reportPath, report.replace(original, drifted));
}, /canonical migration contract/);
});
test('audit rejects a report row whose mapping differs from the manifest', async () => {
await expectAuditFailure(async ({ root }) => {
const reportPath = join(root, 'docs/migration-report.md');
const report = await readFile(reportPath, 'utf8');
await writeFile(
reportPath,
report.replace(
'`src/content/docs/about-incognito.md` | page',
'`src/content/docs/not-about-incognito.md` | page',
),
);
}, /migration report mapping differs from manifest: pages\/study\/msv_incognito\.txt/);
});
test('audit rejects docs roots that merely end with a false src/content/docs suffix', async () => {
const root = await mkdtemp(join(tmpdir(), 'incognito-false-root-'));
try {
const docsRoot = join(root, 'not-src/content/docs');
await mkdir(docsRoot, { recursive: true });
const manifest = await loadManifest('docs/migration-manifest.json');
await assert.rejects(
auditContent({ docsRoot, manifest }),
/docsRoot must identify src\/content\/docs/,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test('audit rejects to-be-studied directories on either publication path', async () => {
await expectAuditFailure(async ({ root, docsRoot }) => {
await mkdir(join(docsRoot, 'to-be-studied'), { recursive: true });
await mkdir(join(root, 'public/to-be-studied'), { recursive: true });
}, /to-be-studied must not appear under src\/content\/docs/);
});
const forbidden = [/\[\[/, /\{\{/, /~~NOCACHE~~/, /NEWPAGE>/, /indexmenu>/]; const forbidden = [/\[\[/, /\{\{/, /~~NOCACHE~~/, /NEWPAGE>/, /indexmenu>/];
@ -13,6 +290,24 @@ for (const file of [
'src/content/docs/bachelor/year-2/honours-programme.md', 'src/content/docs/bachelor/year-2/honours-programme.md',
'src/content/docs/bachelor/year-2/project-2-1.md', 'src/content/docs/bachelor/year-2/project-2-1.md',
'src/content/docs/bachelor/year-2/project-2-2.md', 'src/content/docs/bachelor/year-2/project-2-2.md',
'src/content/docs/bachelor/year-3/index.md',
'src/content/docs/bachelor/year-3/bachelors-thesis.md',
'src/content/docs/bachelor/year-3/honours-programme.md',
'src/content/docs/bachelor/year-3/project-3-1.md',
'src/content/docs/bachelor/year-3/study-abroad.md',
'src/content/docs/master-ai/index.md',
'src/content/docs/master-ai/year-1/index.md',
'src/content/docs/master-ai/year-1/research-project-1.md',
'src/content/docs/master-ai/year-1/research-project-2.md',
'src/content/docs/master-ai/year-2/index.md',
'src/content/docs/master-dsdm/index.md',
'src/content/docs/master-dsdm/year-1/index.md',
'src/content/docs/master-dsdm/year-2/index.md',
'src/content/docs/useful-information/index.md',
'src/content/docs/useful-information/dke-locations.md',
'src/content/docs/useful-information/handy-locations.md',
'src/content/docs/useful-information/it-services.md',
'src/content/docs/useful-information/laptop-buying-advice.md',
]) { ]) {
test(`${file} contains clean migrated Markdown`, async () => { test(`${file} contains clean migrated Markdown`, async () => {
const content = await readFile(file, 'utf8'); const content = await readFile(file, 'utf8');
@ -22,6 +317,26 @@ for (const file of [
}); });
} }
for (const file of [
'src/content/docs/useful-information/it-services.md',
'src/content/docs/useful-information/laptop-buying-advice.md',
]) {
test(`${file} prominently identifies historical guidance`, async () => {
const content = await readFile(file, 'utf8');
assert.match(content, /:::caution\[Historical information\]/);
assert.match(content, /This information originated in the previous wiki and may be outdated\./);
});
}
test('Handy Locations retains its awaiting-content notices', async () => {
const content = await readFile(
'src/content/docs/useful-information/handy-locations.md',
'utf8',
);
assert.match(content, /:::note\[Awaiting content\]/);
assert.match(content, /This section is awaiting content\./);
});
test('Year 2 honours programme retains its awaiting-content notice', async () => { test('Year 2 honours programme retains its awaiting-content notice', async () => {
const content = await readFile( const content = await readFile(
'src/content/docs/bachelor/year-2/honours-programme.md', 'src/content/docs/bachelor/year-2/honours-programme.md',
@ -30,3 +345,40 @@ test('Year 2 honours programme retains its awaiting-content notice', async () =>
assert.match(content, /:::note\[Awaiting content\]/); assert.match(content, /:::note\[Awaiting content\]/);
assert.match(content, /This page is awaiting content\./); assert.match(content, /This page is awaiting content\./);
}); });
test('Year 3 honours programme retains its awaiting-content notice', async () => {
const content = await readFile(
'src/content/docs/bachelor/year-3/honours-programme.md',
'utf8',
);
assert.match(content, /:::note\[Awaiting content\]/);
assert.match(content, /This page is awaiting content\./);
});
test('Year 3 study abroad page identifies 2022 advice as historical', async () => {
const content = await readFile(
'src/content/docs/bachelor/year-3/study-abroad.md',
'utf8',
);
assert.match(content, /2022/);
assert.match(content, /must be verified/i);
});
for (const { file, correctHref, shallowHref } of [
{
file: 'src/content/docs/bachelor/year-2/project-2-1.md',
correctHref: '../../year-1/project-1-1/',
shallowHref: '../year-1/project-1-1/',
},
{
file: 'src/content/docs/bachelor/year-2/project-2-2.md',
correctHref: '../../year-1/project-1-2/',
shallowHref: '../year-1/project-1-2/',
},
]) {
test(`${file} links to its Year 1 prerequisite from the nested route`, async () => {
const content = await readFile(file, 'utf8');
assert.match(content, new RegExp(`\\]\\(${correctHref.replaceAll('/', '\\/')}\\)`));
assert.doesNotMatch(content, new RegExp(`\\]\\(${shallowHref.replaceAll('/', '\\/')}\\)`));
});
}

View file

@ -0,0 +1,120 @@
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('accepts an existing exact-file link', async () => {
await withBuiltSite(async (root) => {
await writeFile(join(root, 'index.html'), '<a href="/manual.pdf">Manual</a>');
await writeFile(join(root, 'manual.pdf'), 'manual');
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);
});
});
for (const { name, href } of [
{ name: 'encoded slash', href: '/..%2Fmanual.pdf' },
{ name: 'encoded backslash', href: '/..%5Cmanual.pdf' },
]) {
test(`rejects ${name} traversal even when the escaped target exists`, async () => {
const siteRoot = await mkdtemp(join(tmpdir(), 'incognito-links-escape-'));
const distRoot = join(siteRoot, 'dist');
try {
await mkdir(distRoot);
await writeFile(join(siteRoot, 'manual.pdf'), 'outside dist');
await writeFile(join(distRoot, 'index.html'), `<a href="${href}">Escaped</a>`);
const result = await checkInternalLinks({ distRoot, base: '/' });
assert.deepEqual(result.broken.map((broken) => broken.href), [href]);
} finally {
await rm(siteRoot, { recursive: true, force: true });
}
});
}

452
tests/live-wiki.test.mjs Normal file
View file

@ -0,0 +1,452 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
crawlWiki,
extractPage,
normalizeWikiUrl,
routeToCapturePath,
} from '../scripts/lib/live-wiki.mjs';
import { runCapture } from '../scripts/capture-live-wiki.mjs';
test('normalizes only same-origin content routes', () => {
assert.equal(
normalizeWikiUrl('/useful-guides/description?draft=1#details'),
'https://wiki.msvincognito.nl/useful-guides/description',
);
assert.equal(
normalizeWikiUrl('https://wiki.msvincognito.nl/useful-guides/description/'),
'https://wiki.msvincognito.nl/useful-guides/description',
);
assert.equal(normalizeWikiUrl('https://example.com/page'), null);
assert.equal(normalizeWikiUrl('/assets/diagram.png'), null);
assert.equal(normalizeWikiUrl('mailto:board@msvincognito.nl'), null);
});
test('maps routes to readable Markdown files', () => {
assert.equal(
routeToCapturePath('https://wiki.msvincognito.nl/useful-guides/description'),
'useful-guides/description.md',
);
assert.equal(routeToCapturePath('https://wiki.msvincognito.nl/'), 'index.md');
assert.throws(
() => routeToCapturePath('https://wiki.msvincognito.nl/foo%20bar'),
/Unsafe live-wiki route segment: foo%20bar/,
);
});
test('extracts the main article and internal links', () => {
const html = `
<html>
<body>
<nav><a href="/ignored">Repeated navigation</a></nav>
<main>
<h1>Guide</h1>
<p>Text<br>Second line</p>
<a href="/next?from=guide#part">Next</a>
<a href="https://example.com/external">External</a>
<form><input name="secret"><button>Submit</button></form>
<script>throw new Error('not content')</script>
</main>
<footer>Repeated footer</footer>
</body>
</html>
`;
const page = extractPage(html, 'https://wiki.msvincognito.nl/guide');
assert.equal(page.title, 'Guide');
assert.match(page.markdown, /^# Guide/m);
assert.match(page.markdown, /Text/);
assert.doesNotMatch(page.markdown, /[ \t]+$/m);
assert.doesNotMatch(page.markdown, /Submit|not content|Repeated footer/);
assert.deepEqual(page.links, ['https://wiki.msvincognito.nl/next']);
});
test('falls back from main to article and then body', () => {
const articlePage = extractPage(
'<article><h2>Article title</h2><p>Article text</p></article>',
'https://wiki.msvincognito.nl/article',
);
const bodyPage = extractPage(
'<html><head><title>Body title</title></head><body><header class="sticky top-0 z-40">Site chrome</header><p>Body text</p></body></html>',
'https://wiki.msvincognito.nl/body',
);
assert.equal(articlePage.title, 'Article title');
assert.match(articlePage.markdown, /Article text/);
assert.equal(bodyPage.title, 'Body title');
assert.match(bodyPage.markdown, /Body text/);
assert.doesNotMatch(bodyPage.markdown, /Site chrome/);
});
test('removes the live wiki previous-next panel and repeated page footer', () => {
const page = extractPage(`
<main>
<div>
<h1>Course</h1>
<p>Useful article content.</p>
<div class="mt-16"><div class="border-t pt-6 lg:flex"><a href="/previous">Previous</a></div></div>
</div>
<div class="hidden text-sm lg:block">
<a href="https://github.com/example">Star on GitHub</a>
</div>
<footer class="text-muted-foreground py-6 md:px-8 md:py-0">
<p>Copyright © 2025 MSV Incognito</p>
</footer>
</main>
`, 'https://wiki.msvincognito.nl/course');
assert.match(page.markdown, /Useful article content/);
assert.doesNotMatch(page.markdown, /Previous|Star on GitHub|Copyright/);
assert.deepEqual(page.links, ['https://wiki.msvincognito.nl/previous']);
});
test('preserves legitimate article headers, asides, footers, and spaced sections', () => {
const page = extractPage(`
<main>
<article>
<header><h1>Research note</h1><p>By the board</p></header>
<aside>Important contextual aside.</aside>
<div class="mt-16">A deliberately spaced substantive section.</div>
<footer>Article footnote.</footer>
</article>
</main>
`, 'https://wiki.msvincognito.nl/research-note');
assert.match(page.markdown, /# Research note/);
assert.match(page.markdown, /By the board/);
assert.match(page.markdown, /Important contextual aside/);
assert.match(page.markdown, /deliberately spaced substantive section/);
assert.match(page.markdown, /Article footnote/);
});
test('crawls in sorted order, writes successful pages, and records failures', async (context) => {
const captureRoot = await mkdtemp(path.join(tmpdir(), 'live-wiki-crawl-'));
context.after(() => rm(captureRoot, { recursive: true, force: true }));
const outputRoot = path.join(captureRoot, 'live-wiki', '2026-08-02');
const manifestPath = path.join(captureRoot, 'manifest.json');
const requests = [];
const waits = [];
const responses = new Map([
[
'https://wiki.msvincognito.nl/',
response('<main><h1>Home</h1><a href="/z-last">Z</a><a href="/a-first">A</a></main>'),
],
[
'https://wiki.msvincognito.nl/a-first',
response('<main><h1>First</h1><p>Captured text.</p></main>'),
],
['https://wiki.msvincognito.nl/z-last', response('Unavailable', 503)],
]);
const manifest = await crawlWiki({
capturedAt: '2026-08-02',
manifestPath,
outputRoot,
fetchImpl: async (url) => {
requests.push(url);
return responses.get(url);
},
waitImpl: async (milliseconds) => waits.push(milliseconds),
});
assert.deepEqual(requests, [
'https://wiki.msvincognito.nl/',
'https://wiki.msvincognito.nl/a-first',
'https://wiki.msvincognito.nl/z-last',
]);
assert.deepEqual(waits, [150, 150]);
assert.deepEqual(manifest.pages.map(({ url }) => url), [
'https://wiki.msvincognito.nl/',
'https://wiki.msvincognito.nl/a-first',
]);
assert.deepEqual(manifest.failures, [{
url: 'https://wiki.msvincognito.nl/z-last',
error: 'HTTP 503 Service Unavailable',
}]);
const captured = await readFile(path.join(outputRoot, 'a-first.md'), 'utf8');
assert.match(captured, /^> Source: https:\/\/wiki\.msvincognito\.nl\/a-first\n> Captured: 2026-08-02\n\n/);
assert.match(captured, /# First\n\nCaptured text\./);
assert.equal(
manifest.pages[1].contentHash,
createHash('sha256').update(captured).digest('hex'),
);
const savedManifest = JSON.parse(await readFile(manifestPath, 'utf8'));
assert.deepEqual(savedManifest, manifest);
});
test('follows same-origin redirects manually, paces every hop, and captures the final URL', async (context) => {
const captureRoot = await mkdtemp(path.join(tmpdir(), 'live-wiki-redirect-'));
context.after(() => rm(captureRoot, { recursive: true, force: true }));
const outputRoot = path.join(captureRoot, 'live-wiki', '2026-08-02');
const requests = [];
const waits = [];
const responses = new Map([
['https://wiki.msvincognito.nl/', redirectResponse('/guide')],
['https://wiki.msvincognito.nl/guide', response('<main><h1>Guide</h1><p>Final content.</p></main>')],
]);
const manifest = await crawlWiki({
capturedAt: '2026-08-02',
manifestPath: path.join(captureRoot, 'manifest.json'),
outputRoot,
fetchImpl: async (url, options) => {
requests.push({ url, redirect: options.redirect });
return responses.get(url);
},
waitImpl: async (milliseconds) => waits.push(milliseconds),
});
assert.deepEqual(requests, [
{ url: 'https://wiki.msvincognito.nl/', redirect: 'manual' },
{ url: 'https://wiki.msvincognito.nl/guide', redirect: 'manual' },
]);
assert.deepEqual(waits, [150]);
assert.deepEqual(manifest.pages.map(({ url, path: capturePath }) => ({ url, path: capturePath })), [{
url: 'https://wiki.msvincognito.nl/guide',
path: 'live-wiki/2026-08-02/guide.md',
}]);
const captured = await readFile(path.join(outputRoot, 'guide.md'), 'utf8');
assert.match(captured, /^> Source: https:\/\/wiki\.msvincognito\.nl\/guide$/m);
});
test('rejects cross-origin redirects without requesting the target', async (context) => {
const captureRoot = await mkdtemp(path.join(tmpdir(), 'live-wiki-cross-origin-'));
context.after(() => rm(captureRoot, { recursive: true, force: true }));
const requests = [];
const manifest = await crawlWiki({
capturedAt: '2026-08-02',
manifestPath: path.join(captureRoot, 'manifest.json'),
outputRoot: path.join(captureRoot, 'live-wiki', '2026-08-02'),
fetchImpl: async (url) => {
requests.push(url);
return redirectResponse('https://example.com/private');
},
waitImpl: async () => {},
});
assert.deepEqual(requests, ['https://wiki.msvincognito.nl/']);
assert.deepEqual(manifest.pages, []);
assert.deepEqual(manifest.failures, [{
url: 'https://wiki.msvincognito.nl/',
error: 'Redirect target is not an authorized content URL: https://example.com/private',
}]);
});
test('stops a same-origin redirect chain at the configured hop limit', async (context) => {
const captureRoot = await mkdtemp(path.join(tmpdir(), 'live-wiki-redirect-limit-'));
context.after(() => rm(captureRoot, { recursive: true, force: true }));
const requests = [];
const waits = [];
const manifest = await crawlWiki({
capturedAt: '2026-08-02',
manifestPath: path.join(captureRoot, 'manifest.json'),
maxRedirects: 1,
outputRoot: path.join(captureRoot, 'live-wiki', '2026-08-02'),
fetchImpl: async (url) => {
requests.push(url);
return url.endsWith('/one')
? redirectResponse('/two')
: redirectResponse('/one');
},
waitImpl: async (milliseconds) => waits.push(milliseconds),
});
assert.deepEqual(requests, [
'https://wiki.msvincognito.nl/',
'https://wiki.msvincognito.nl/one',
]);
assert.deepEqual(waits, [150]);
assert.deepEqual(manifest.pages, []);
assert.deepEqual(manifest.failures, [{
url: 'https://wiki.msvincognito.nl/',
error: 'Redirect limit of 1 exceeded at https://wiki.msvincognito.nl/two',
}]);
});
test('captures a redirected final URL only once when its canonical route is also queued', async (context) => {
const captureRoot = await mkdtemp(path.join(tmpdir(), 'live-wiki-canonical-redirect-'));
context.after(() => rm(captureRoot, { recursive: true, force: true }));
const requests = [];
const manifest = await crawlWiki({
capturedAt: '2026-08-02',
manifestPath: path.join(captureRoot, 'manifest.json'),
outputRoot: path.join(captureRoot, 'live-wiki', '2026-08-02'),
fetchImpl: async (url) => {
requests.push(url);
if (url.endsWith('/alias')) return redirectResponse('/target');
if (url.endsWith('/target')) return response('<main><h1>Target</h1></main>');
return response('<main><h1>Home</h1><a href="/alias">Alias</a><a href="/target">Target</a></main>');
},
waitImpl: async () => {},
});
assert.deepEqual(requests, [
'https://wiki.msvincognito.nl/',
'https://wiki.msvincognito.nl/alias',
'https://wiki.msvincognito.nl/target',
]);
assert.deepEqual(manifest.pages.map(({ url }) => url), [
'https://wiki.msvincognito.nl/',
'https://wiki.msvincognito.nl/target',
]);
});
test('records an unsafe route as a per-URL failure and preserves other pages', async (context) => {
const captureRoot = await mkdtemp(path.join(tmpdir(), 'live-wiki-unsafe-path-'));
context.after(() => rm(captureRoot, { recursive: true, force: true }));
const outputRoot = path.join(captureRoot, 'live-wiki', '2026-08-02');
const manifest = await crawlWiki({
capturedAt: '2026-08-02',
manifestPath: path.join(captureRoot, 'manifest.json'),
outputRoot,
fetchImpl: async (url) => url.endsWith('/foo%20bar')
? response('<main><h1>Unsafe</h1></main>')
: response('<main><h1>Home</h1><a href="/foo%20bar">Unsafe route</a></main>'),
waitImpl: async () => {},
});
assert.deepEqual(manifest.pages.map(({ url }) => url), ['https://wiki.msvincognito.nl/']);
assert.deepEqual(manifest.failures, [{
url: 'https://wiki.msvincognito.nl/foo%20bar',
error: 'Unsafe live-wiki route segment: foo%20bar',
}]);
assert.match(await readFile(path.join(outputRoot, 'index.md'), 'utf8'), /# Home/);
});
test('records destination collisions before a later page can overwrite a capture', async (context) => {
const captureRoot = await mkdtemp(path.join(tmpdir(), 'live-wiki-collision-'));
context.after(() => rm(captureRoot, { recursive: true, force: true }));
const outputRoot = path.join(captureRoot, 'live-wiki', '2026-08-02');
const manifest = await crawlWiki({
capturedAt: '2026-08-02',
manifestPath: path.join(captureRoot, 'manifest.json'),
outputRoot,
fetchImpl: async (url) => url.endsWith('/index')
? response('<main><h1>Colliding page</h1></main>')
: response('<main><h1>Home</h1><a href="/index">Collision</a></main>'),
waitImpl: async () => {},
});
assert.deepEqual(manifest.pages.map(({ url }) => url), ['https://wiki.msvincognito.nl/']);
assert.deepEqual(manifest.failures, [{
url: 'https://wiki.msvincognito.nl/index',
error: 'Capture destination collision: https://wiki.msvincognito.nl/index and https://wiki.msvincognito.nl/ both map to index.md',
}]);
const captured = await readFile(path.join(outputRoot, 'index.md'), 'utf8');
assert.match(captured, /# Home/);
assert.doesNotMatch(captured, /Colliding page/);
});
test('rejects discovery beyond the configured page ceiling', async (context) => {
const captureRoot = await mkdtemp(path.join(tmpdir(), 'live-wiki-limit-'));
context.after(() => rm(captureRoot, { recursive: true, force: true }));
await assert.rejects(
crawlWiki({
capturedAt: '2026-08-02',
manifestPath: path.join(captureRoot, 'manifest.json'),
outputRoot: path.join(captureRoot, 'live-wiki', '2026-08-02'),
maxPages: 2,
fetchImpl: async () => response(
'<main><h1>Home</h1><a href="/one">One</a><a href="/two">Two</a></main>',
),
waitImpl: async () => {},
}),
/more than 2 content pages/i,
);
});
test('excludes soft 404 navigation placeholders and removes stale captures', async (context) => {
const captureRoot = await mkdtemp(path.join(tmpdir(), 'live-wiki-soft-404-'));
context.after(() => rm(captureRoot, { recursive: true, force: true }));
const outputRoot = path.join(captureRoot, 'live-wiki', '2026-08-02');
const stalePath = path.join(outputRoot, 'missing.md');
const unrelatedStalePath = path.join(outputRoot, 'obsolete.md');
await mkdir(outputRoot, { recursive: true });
await writeFile(stalePath, 'stale capture', 'utf8');
await writeFile(unrelatedStalePath, 'obsolete capture', 'utf8');
const manifest = await crawlWiki({
capturedAt: '2026-08-02',
manifestPath: path.join(captureRoot, 'manifest.json'),
outputRoot,
fetchImpl: async (url) => url.endsWith('/missing')
? response('<main><h3>404</h3><p>This page could not be found.</p></main>')
: response('<main><h1>Home</h1><a href="/missing">Missing category</a></main>'),
waitImpl: async () => {},
});
assert.deepEqual(manifest.pages.map(({ url }) => url), ['https://wiki.msvincognito.nl/']);
assert.deepEqual(manifest.failures, []);
await assert.rejects(readFile(stalePath, 'utf8'), { code: 'ENOENT' });
await assert.rejects(readFile(unrelatedStalePath, 'utf8'), { code: 'ENOENT' });
});
test('leaves the previous snapshot and manifest untouched after a fatal crawl error', async (context) => {
const captureRoot = await mkdtemp(path.join(tmpdir(), 'live-wiki-atomic-failure-'));
context.after(() => rm(captureRoot, { recursive: true, force: true }));
const outputRoot = path.join(captureRoot, 'live-wiki', '2026-08-02');
const manifestPath = path.join(captureRoot, 'manifest.json');
await mkdir(outputRoot, { recursive: true });
await writeFile(path.join(outputRoot, 'previous.md'), 'previous snapshot', 'utf8');
await writeFile(manifestPath, '{"snapshot":"previous"}\n', 'utf8');
await assert.rejects(crawlWiki({
capturedAt: '2026-08-02',
manifestPath,
maxPages: 2,
outputRoot,
fetchImpl: async (url) => url.endsWith('/one')
? response('<main><h1>One</h1><a href="/two">Two</a></main>')
: response('<main><h1>Home</h1><a href="/one">One</a></main>'),
waitImpl: async () => {},
}), /more than 2 content pages/i);
assert.deepEqual(await readdir(outputRoot), ['previous.md']);
assert.equal(await readFile(path.join(outputRoot, 'previous.md'), 'utf8'), 'previous snapshot');
assert.equal(await readFile(manifestPath, 'utf8'), '{"snapshot":"previous"}\n');
});
test('capture command reports failure after the crawler preserves its manifest', async () => {
const messages = [];
const exitCode = await runCapture({
crawlImpl: async () => ({
pages: [{ url: 'https://wiki.msvincognito.nl/' }],
failures: [{ url: 'https://wiki.msvincognito.nl/broken', error: 'HTTP 503' }],
}),
logError: (message) => messages.push(message),
logInfo: () => {},
});
assert.equal(exitCode, 1);
assert.deepEqual(messages, ['Captured 1 page with 1 failure; inspect to-be-studied/manifest.json.']);
});
function response(body, status = 200) {
return new Response(body, {
status,
statusText: status === 200 ? 'OK' : status === 503 ? 'Service Unavailable' : 'Error',
headers: { 'content-type': 'text/html; charset=utf-8' },
});
}
function redirectResponse(location, status = 302) {
return new Response('', {
status,
statusText: status === 302 ? 'Found' : 'Redirect',
headers: { location },
});
}

View file

@ -24,6 +24,27 @@ test('validation rejects source traversal outside the selected scope', async ()
assert.throws(() => validateManifest(manifest), /outside the selected migration scope/); assert.throws(() => validateManifest(manifest), /outside the selected migration scope/);
}); });
test('validation rejects backslash source traversal outside the selected scope', async () => {
const manifest = await loadManifest('docs/migration-manifest.json');
manifest.entries[2].source = 'pages/study/..\\..\\wiki\\x.txt';
assert.throws(() => validateManifest(manifest), /outside the selected migration scope/);
});
test('validation rejects a fabricated source inside the broad study path', async () => {
const manifest = await loadManifest('docs/migration-manifest.json');
manifest.entries[2].source = 'pages/study/fabricated.txt';
assert.throws(() => validateManifest(manifest), /canonical migration contract/);
});
test('validation rejects a mode that differs from the canonical migration contract', async () => {
const manifest = await loadManifest('docs/migration-manifest.json');
manifest.entries[2].mode = 'merge';
assert.throws(() => validateManifest(manifest), /canonical migration contract/);
});
test('validation rejects destination traversal outside the docs root', async () => { test('validation rejects destination traversal outside the docs root', async () => {
const manifest = await loadManifest('docs/migration-manifest.json'); const manifest = await loadManifest('docs/migration-manifest.json');
manifest.entries[2].destination = 'src/content/docs/../../../outside.md'; manifest.entries[2].destination = 'src/content/docs/../../../outside.md';
@ -31,6 +52,13 @@ test('validation rejects destination traversal outside the docs root', async ()
assert.throws(() => validateManifest(manifest), /outside src\/content\/docs/); assert.throws(() => validateManifest(manifest), /outside src\/content\/docs/);
}); });
test('validation rejects backslash destination traversal outside the docs root', async () => {
const manifest = await loadManifest('docs/migration-manifest.json');
manifest.entries[2].destination = 'src/content/docs/..\\..\\..\\outside.md';
assert.throws(() => validateManifest(manifest), /outside src\/content\/docs/);
});
test('validation rejects duplicate destinations unless the later entry is a merge', async () => { test('validation rejects duplicate destinations unless the later entry is a merge', async () => {
const manifest = await loadManifest('docs/migration-manifest.json'); const manifest = await loadManifest('docs/migration-manifest.json');
manifest.entries[1].mode = 'page'; manifest.entries[1].mode = 'page';
@ -57,3 +85,19 @@ test('destination check reports only files that have not been migrated', async (
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
}); });
test('destination check rejects a directory at a manifest file path', async () => {
const root = await mkdir(join(tmpdir(), `migration-${process.pid}-${Date.now()}`), { recursive: true });
const directory = join(root, 'src/content/docs/not-a-file.md');
await mkdir(directory, { recursive: true });
try {
const result = await checkDestinations({
entries: [{ destination: 'src/content/docs/not-a-file.md' }],
}, root);
assert.deepEqual(result.notFiles, ['src/content/docs/not-a-file.md']);
} finally {
await rm(root, { recursive: true, force: true });
}
});

View file

@ -66,3 +66,37 @@ test('landing page labels source-era programme information as potentially outdat
/This information originated in the previous wiki and may be outdated\./, /This information originated in the previous wiki and may be outdated\./,
); );
}); });
test('Netlify builds the static site into dist', async () => {
const config = await readFile('netlify.toml', 'utf8');
assert.match(config, /command = "npm run build"/);
assert.match(config, /publish = "dist"/);
});
test('the optional GitHub Pages workflow deploys only main or manual runs', async () => {
const workflow = await readFile('.github/workflows/deploy.yml', 'utf8');
assert.match(workflow, /push:\s*\n\s+branches: \[main\]/);
assert.match(workflow, /workflow_dispatch:/);
assert.doesNotMatch(workflow, /pull_request:|schedule:/);
assert.match(workflow, /actions\/checkout@v7/);
assert.match(workflow, /withastro\/action@v6/);
assert.match(workflow, /actions\/deploy-pages@v5/);
});
test('GitHub Pages write and OIDC permissions are scoped only to deployment', async () => {
const workflow = await readFile('.github/workflows/deploy.yml', 'utf8');
assert.doesNotMatch(workflow, /^permissions:/m);
const build = workflow.match(/\n build:\n([\s\S]*?)(?=\n deploy:\n)/)?.[1] ?? '';
assert.match(build, /^ permissions:\n contents: read$/m);
assert.doesNotMatch(build, /pages: write|id-token: write/);
const deploy = workflow.match(/\n deploy:\n([\s\S]*)$/)?.[1] ?? '';
assert.match(deploy, /^ permissions:\n pages: write\n id-token: write$/m);
});
test('maintainer documentation keeps Forgejo CI-independent', async () => {
const readme = await readFile('README.md', 'utf8');
assert.match(readme, /Forgejo[^\n]*authoritative private repository/i);
assert.match(readme, /does not depend on Forgejo CI/i);
});

View file

@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { checkRenderedOutput } from '../scripts/lib/rendered-output.mjs';
async function withRenderedSite(run) {
const root = await mkdtemp(join(tmpdir(), 'incognito-rendered-'));
try {
await run(root);
} finally {
await rm(root, { recursive: true, force: true });
}
}
test('reports every published HTML page that does not contain exactly one H1', async () => {
await withRenderedSite(async (root) => {
await mkdir(join(root, 'about'), { recursive: true });
await mkdir(join(root, 'empty'), { recursive: true });
await writeFile(join(root, 'index.html'), '<main><h1>Home</h1></main>');
await writeFile(join(root, 'about/index.html'), '<main><h1>About</h1><h1>Duplicate</h1></main>');
await writeFile(join(root, 'empty/index.html'), '<main><p>No heading</p></main>');
await writeFile(join(root, '404.html'), '<main><p>Generated error page</p></main>');
const result = await checkRenderedOutput({ distRoot: root });
assert.equal(result.htmlFilesChecked, 3);
assert.deepEqual(result.headingIssues, [
{ file: 'about/index.html', h1Count: 2 },
{ file: 'empty/index.html', h1Count: 0 },
]);
});
});
test('reports any to-be-studied path that reaches built output', async () => {
await withRenderedSite(async (root) => {
await writeFile(join(root, 'index.html'), '<main><h1>Home</h1></main>');
await mkdir(join(root, 'to-be-studied'), { recursive: true });
await writeFile(join(root, 'to-be-studied/leak.txt'), 'unpublished');
const result = await checkRenderedOutput({ distRoot: root });
assert.deepEqual(result.unpublishedPaths, ['to-be-studied/leak.txt']);
});
});

9
to-be-studied/README.md Normal file
View file

@ -0,0 +1,9 @@
# Live-wiki research snapshot
This directory is a research snapshot of public, unauthenticated pages from `https://wiki.msvincognito.nl/`. It is not a source for the published migration and may contain incomplete, superseded, or outdated material.
The snapshot was captured on 2026-08-02 with `npm run capture:live-wiki`. The crawler stays on the live wiki's origin, never authenticates or submits forms, waits 150 ms between every request and redirect hop, refuses to crawl more than 500 content pages, and records request failures in `manifest.json`. It follows only bounded, same-origin redirects and records each final canonical URL once. It converts the first `<main>` element (falling back to `<article>` and then `<body>`) to readable Markdown after removing site chrome. Public navigation enumerated the content; the site's `/sitemap.xml` and `/robots.txt` routes both render its soft-404 page, so neither supplied additional URLs. The capture excludes navigation category URLs that return the same deterministic soft-404 page instead of content.
Files under `live-wiki/2026-08-02/` retain their public source URL and capture date. `manifest.json` records the sorted capture inventory and SHA-256 hash of every Markdown file. `comparison.md` compares that inventory with the 29-entry DokuWiki export manifest.
Nothing in this directory is published by Starlight. This directory must never be moved or copied into `src/content/docs/` without a separate content review. It must also remain outside `public/`.

View file

@ -0,0 +1,76 @@
# Export and live-wiki comparison
This is a research inventory, not a merge decision. It compares the 93 successful canonical pages in `manifest.json` with all 29 source entries in `docs/migration-manifest.json`. The comparison is page-level: a live course page can be new-only even when its surrounding year/programme taxonomy overlaps an exported index page. Five public navigation aliases redirect to canonical description routes and are not duplicated in the capture.
## New live-wiki pages with no exported page equivalent
These 87 successful live pages have no one-to-one page in the export. Course titles are listed so every captured course page is concretely classified rather than represented only by a count.
| Live route or family | Count | Captured pages | Classification |
| --- | ---: | --- | --- |
| `/computer-science/course-description` | 1 | Course Description | The export has no Computer Science programme page; its bachelor programme is Data Science and Artificial Intelligence. The `/computer-science` navigation alias redirects here. |
| `/computer-science/year-1/**` | 12 | Discrete Math; Introduction to Computer Science; Procedural Programming; Calculus; Logic; Objects in Programming; Computer Architecture; Data Structures and Algorithms; Linear Algebra; Databases; Algorithmic Design; Statistics | New course-level hierarchy organized by year and period; the export has no corresponding course pages. |
| `/computer-science/year-2/**` | 14 | Intelligent User Interfaces; Introduction to Artificial Intelligence; Operating Systems; Computer Networks; Software Engineering and Architectures; Computer Security; Embedded Programming; Parallel Programming; Numerical Methods; Principles of Programming Languages; M2-1: AI and Machine Learning; M2-1: Intelligent Interaction; M2-2: Cybersecurity; M2-2: High Performance Computing | New course/elective pages; no exported equivalents. |
| `/computer-science/year-3/**` | 16 | Digital Society; Game Theory; Graph Theory; Introduction to Quantum Computing; Robotics and Embedded Systems; Ubiquitous Computing & Internet of Things; Block Chains; Cryptography; Immersive Technologies; Introduction to Bio-Informatics; Large Scale IT and Cloud Computing; Software and Systems Verification; Startup Engineering: Building Scalable Tech Ventures; The History and Philosophy of Computing; Operating Systems; Theory of Computing | New course/elective pages; no exported equivalents. |
| `/data-science-and-artificial-intelligence/year-1/**` | 12 | Discrete Mathematics; Introduction to Data Science and Artificial Intelligence; Procedural Programming; Calculus; Logic; Objects in Programming; Data Structures and Algorithms; Linear Algebra; Principles of Data Science; Computational and Cognitive Neuroscience; Numerical Methods; Software Engineering | The exported Year 1 page is only a schedule/index shell and has no individual course pages. |
| `/data-science-and-artificial-intelligence/year-2/**` | 13 | Databases; Graph Theory; Probability and Statistics; Machine Learning; Reasoning Techniques; Simulation and Statistical Analysis; Human Computer Interaction & Affective Computing; Mathematical Modelling; Natural Language Processing; Game Theory; Introduction to Image & Video Processing; Linear Programming; Philosophy & Artificial Intelligence | The exported Year 2 branch contains projects and an honours placeholder, not these course pages. |
| `/data-science-and-artificial-intelligence/year-3/**` | 16 | Digital Society; Game Theory; Introduction to Quantum Computing; Multi-Scale Modelling of Biological Systems; Robotics and Embedded Systems; Semantic Web; Business Process & Knowledge Management; Computer Security; Introduction to Bio-Informatics; Large Scale IT and Cloud Computing; Logic for Artificial Intelligence; Parallel Programming; Recommender Systems; Data Analysis; Intelligent Systems; Operations Research | The exported Year 3 branch contains thesis/project/study-abroad material, not these course pages. |
| `/useful-guides/housing-guide` | 1 | Housing Guide | New 2025 housing-market and scam-avoidance guide; the exported Handy Locations page only names study/food-location categories. |
| `/useful-guides/linux-tricks` | 1 | Linux Tricks | Linux-specific Eduroam, VPN, file-service, and remote-desktop advice; there is no matching exported page. |
| `/useful-guides/survivingdacs` | 1 | DACS Survival Guide | Broad study, projects, exams, programming, mathematics, and student-life advice; there is no one-to-one export page. |
## Exported pages with no obvious live-wiki page equivalent
All seven exported project pages are called out explicitly. Year landing pages are included because their apparent live category URLs are soft 404s; their taxonomy is compared separately below.
| Export source path | Exported topic | Evidence/classification |
| --- | --- | --- |
| `pages/study/msv_incognito.txt` | MSV Incognito association | The live home page links the association but has no association-history/membership page. |
| `pages/study/bachelor/year_1.txt` | Bachelor Year 1 index and schedule | No valid live Year 1 landing page; live leaf courses are a structural overlap only. |
| `pages/study/bachelor/year_1/project_1-1.txt` | Project 1-1 | Exported project page; no live project page. |
| `pages/study/bachelor/year_1/project_1-2.txt` | Project 1-2 | Exported project page; no live project page. |
| `pages/study/bachelor/year_2.txt` | Bachelor Year 2 index and schedule | No valid live Year 2 landing page; live leaf courses are a structural overlap only. |
| `pages/study/bachelor/year_2/honours_programme.txt` | Year 2 honours programme | No live honours page. |
| `pages/study/bachelor/year_2/project_2-1.txt` | Project 2-1 | Exported project page; no live project page. |
| `pages/study/bachelor/year_2/project_2-2.txt` | Project 2-2 | Exported project page; no live project page. |
| `pages/study/bachelor/year_3.txt` | Bachelor Year 3 index and schedule | No valid live Year 3 landing page; live leaf courses are a structural overlap only. |
| `pages/study/bachelor/year_3/bachelors_thesis.txt` | Bachelor thesis | No live thesis page. |
| `pages/study/bachelor/year_3/honours_programme.txt` | Year 3 honours programme | No live honours page. |
| `pages/study/bachelor/year_3/project_3-1.txt` | Project 3-1 | Exported project page; no live project page. |
| `pages/study/bachelor/year_3/study_abroad.txt` | Study abroad | No live study-abroad page. |
| `pages/study/master_ai/year_1.txt` | MSc AI Year 1 index | The live MSc AI branch has only a sparse course-description page and no year hierarchy. |
| `pages/study/master_ai/year_1/project_mai_1.txt` | MSc AI Research Project 1 | Exported project page; no live project page. |
| `pages/study/master_ai/year_1/project_mai_2.txt` | MSc AI Research Project 2 | Exported project page; no live project page. |
| `pages/study/master_ai/year_2.txt` | MSc AI Year 2 index | No live MSc AI Year 2 page. |
| `pages/study/master_dsdm/year_1.txt` | MSc DSDM Year 1 index | The live DSDM branch has only a sparse course-description page and no year hierarchy. |
| `pages/study/master_dsdm/year_2.txt` | MSc DSDM Year 2 index | No live DSDM Year 2 page. |
| `pages/study/useful_information/pages/dke_locations.txt` | DKE/DACS teaching locations | No live locations page. |
| `pages/study/useful_information/pages/handy_locations.txt` | Study, food, and other locations | The Housing Guide is materially different and is not an equivalent. |
| `pages/study/useful_information/pages/it_services.txt` | General UM IT services | Linux Tricks overlaps a few technologies but is Linux-specific and not a replacement for the exported service-desk, portal, enrolment, grades, VPN, and printing page. |
## Overlapping topics and substantive differences
| Export source path | Live URL | Substantive difference |
| --- | --- | --- |
| `pages/start.txt` + `pages/study.txt` | `https://wiki.msvincognito.nl/` | The export has association/registration/editing instructions, official links, Maastricht University context, and programme descriptions. The live home is a short DACS landing page linking four programme areas and Useful Guides. |
| `pages/study/bachelor.txt` | `https://wiki.msvincognito.nl/data-science-and-artificial-intelligence/course-description` | Both identify the Data Science and Artificial Intelligence bachelor area. The export explains the programme index and contribution workflow; the canonical live route currently contains only a sparse “Course Description / #Hello” page. The base navigation route redirects here. |
| `pages/study/bachelor/year_1.txt` | `https://wiki.msvincognito.nl/data-science-and-artificial-intelligence/year-1/period-1/discrete-mathematics` (representative of 12 Year 1 leaf pages) | The export is a year index with a missing schedule image and page-creation controls. The live wiki has period/course leaf pages but its Year 1 and period category URLs render soft 404s. |
| `pages/study/bachelor/year_2.txt` | `https://wiki.msvincognito.nl/data-science-and-artificial-intelligence/year-2/period-1/databases` (representative of 13 Year 2 leaf pages) | The export is a schedule/index shell plus upload instructions. The live wiki replaces that organization with period/elective course leaves and no valid Year 2 landing page. |
| `pages/study/bachelor/year_3.txt` | `https://wiki.msvincognito.nl/data-science-and-artificial-intelligence/year-3/period-4/data-analysis` (representative of 16 Year 3 leaf pages) | The export discusses thesis timing and indexes thesis/project/study-abroad pages. The live branch lists course/elective leaves and contains no thesis or project page. |
| `pages/study/master_ai.txt` | `https://wiki.msvincognito.nl/artificial-intelligence/course-description` | Both label the MSc Artificial Intelligence area. The export describes course summaries, exams, year navigation, and contribution flow; the canonical live route is currently a sparse “Course Description / #Hello” page with no year/course content. The base navigation route redirects here. |
| `pages/study/master_dsdm.txt` | `https://wiki.msvincognito.nl/data-science-for-decision-making/course-description` | Both label the MSc Data Science for Decision Making area. The export has year navigation and contribution context; the canonical live route contains only the sparse description placeholder. The base navigation route redirects here. |
| `pages/study/useful_information.txt` | `https://wiki.msvincognito.nl/useful-guides/description` | The export indexes locations, IT services, and laptop advice. The canonical live index instead presents Housing, Laptop Buying Advice, Linux Tricks, and Surviving DACS, and claims the guides are updated “somewhat regularly.” The `/useful-guides` navigation alias redirects here. |
| `pages/study/useful_information/pages/laptop_buy_advice.txt` | `https://wiki.msvincognito.nl/useful-guides/laptop-buying-advice` | The live page preserves and edits the exported tier guide, marks it as last updated 2021, and adds a current minimum of 16 GB RAM/512 GB SSD, Apple M-series compatibility, and an Nvidia/CUDA recommendation for data-science programmes. |
## Navigation placeholders excluded from the capture
Public navigation enumerated 31 category URLs that return HTTP 200 but render exactly `404` and `This page could not be found.` They are navigation/taxonomy placeholders, not content pages, so the crawler excludes them and `manifest.json` has no failures. All leaf pages reachable through the same public navigation were retained.
| Route family | Excluded count | Exact category suffixes |
| --- | ---: | --- |
| `/computer-science/year-1` | 5 | year root; `period-1`; `period-2`; `period-4`; `period-5` |
| `/computer-science/year-2` | 7 | year root; `period-1`; `period-2`; `period-4`; `period-5`; `semester-1-electives`; `semester-2-electives` |
| `/computer-science/year-3` | 4 | year root; `period-1-electives`; `period-2-electives`; `period-4` |
| `/data-science-and-artificial-intelligence/year-1` | 5 | year root; `period-1`; `period-2`; `period-4`; `period-5` |
| `/data-science-and-artificial-intelligence/year-2` | 6 | year root; `period-1`; `period-2`; `period-4`; `period-5`; `period-5-electives` |
| `/data-science-and-artificial-intelligence/year-3` | 4 | year root; `period-1-electives`; `period-2-electives`; `period-4` |

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/artificial-intelligence/course-description
> Captured: 2026-08-02
# Course Description
#Hello
#Hello

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/course-description
> Captured: 2026-08-02
# Course Description
#Hello
#Hello

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-1/period-1/discrete-math
> Captured: 2026-08-02
# Discrete Math
##### Empty Page
Start writing in `content/1.computer-science/year-1/period-1/discrete-math.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-1/period-1/introduction-to-computer-science
> Captured: 2026-08-02
# Introduction to Computer Science
##### Empty Page
Start writing in `content/1.computer-science/year-1/period-1/introduction-to-computer-science.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-1/period-1/procedural-programming
> Captured: 2026-08-02
# Procedural Programming
##### Empty Page
Start writing in `content/1.computer-science/year-1/period-1/procedural-programming.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-1/period-2/calculus
> Captured: 2026-08-02
# Calculus
##### Empty Page
Start writing in `content/1.computer-science/year-1/period-2/calculus.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-1/period-2/logic
> Captured: 2026-08-02
# Logic
##### Empty Page
Start writing in `content/1.computer-science/year-1/period-2/logic.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-1/period-2/objects-in-programming
> Captured: 2026-08-02
# Objects in Programming
##### Empty Page
Start writing in `content/1.computer-science/year-1/period-2/objects-in-programming.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-1/period-4/computer-architecture
> Captured: 2026-08-02
# Computer Architecture
##### Empty Page
Start writing in `content/1.computer-science/year-1/period-4/computer-architecture.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-1/period-4/data-structures-and-algorithms
> Captured: 2026-08-02
# Data Structures and Algorithms
##### Empty Page
Start writing in `content/1.computer-science/year-1/period-4/data-structures-and-algorithms.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-1/period-4/linear-algebra
> Captured: 2026-08-02
# Linear Algebra
##### Empty Page
Start writing in `content/1.computer-science/year-1/period-4/linear-algebra.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-1/period-5/databases
> Captured: 2026-08-02
# Databases
##### Empty Page
Start writing in `content/1.computer-science/year-1/period-5/databases.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-1/period-5/discrete-math
> Captured: 2026-08-02
# Algorithmic Design
##### Empty Page
Start writing in `content/1.computer-science/year-1/period-5/discrete-math.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-1/period-5/statistics
> Captured: 2026-08-02
# Statistics
##### Empty Page
Start writing in `content/1.computer-science/year-1/period-5/statistics.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-2/period-1/intelligent-user-interfaces
> Captured: 2026-08-02
# Intelligent User Interfaces
##### Empty Page
Start writing in `content/1.computer-science/year-2/period-1/intelligent-user-interfaces.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-2/period-1/introduction-to-artificial-intelligence
> Captured: 2026-08-02
# Introduction to Artificial Intelligence
##### Empty Page
Start writing in `content/1.computer-science/year-2/period-1/introduction-to-artificial-intelligence.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-2/period-1/operating-systems
> Captured: 2026-08-02
# Operating Systems
##### Empty Page
Start writing in `content/1.computer-science/year-2/period-1/operating-systems.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-2/period-2/computer-networks
> Captured: 2026-08-02
# Computer Networks
##### Empty Page
Start writing in `content/1.computer-science/year-2/period-2/computer-networks.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-2/period-2/software-engineering-and-architectures
> Captured: 2026-08-02
# Software Engineering and Architectures
##### Empty Page
Start writing in `content/1.computer-science/year-2/period-2/software-engineering-and-architectures.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-2/period-4/computer-security
> Captured: 2026-08-02
# Computer Security
##### Empty Page
Start writing in `content/1.computer-science/year-2/period-4/computer-security.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-2/period-4/embedded-programming
> Captured: 2026-08-02
# Embedded Programming
##### Empty Page
Start writing in `content/1.computer-science/year-2/period-4/embedded-programming.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-2/period-4/parallel-programming
> Captured: 2026-08-02
# Parallel Programming
##### Empty Page
Start writing in `content/1.computer-science/year-2/period-4/parallel-programming.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-2/period-5/numerical-methods
> Captured: 2026-08-02
# Numerical Methods
##### Empty Page
Start writing in `content/1.computer-science/year-2/period-5/numerical-methods.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-2/period-5/principles-of-programming-languages
> Captured: 2026-08-02
# Principles of Programming Languages
##### Empty Page
Start writing in `content/1.computer-science/year-2/period-5/principles-of-programming-languages.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-2/semester-1-electives/ai-and-machine-learning
> Captured: 2026-08-02
# M2-1: AI and Machine Learning
##### Empty Page
Start writing in `content/1.computer-science/year-2/semester-1-electives/ai-and-machine-learning.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-2/semester-1-electives/intelligent-interaction
> Captured: 2026-08-02
# M2-1: Intelligent Interaction
##### Empty Page
Start writing in `content/1.computer-science/year-2/semester-1-electives/intelligent-interaction.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-2/semester-2-electives/cybersecurity
> Captured: 2026-08-02
# M2-2: Cybersecurity
##### Empty Page
Start writing in `content/1.computer-science/year-2/semester-2-electives/cybersecurity.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-2/semester-2-electives/high-performance-computing
> Captured: 2026-08-02
# M2-2: High Performance Computing
##### Empty Page
Start writing in `content/1.computer-science/year-2/semester-2-electives/high-performance-computing.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-3/period-1-electives/digital-society
> Captured: 2026-08-02
# Digital Society
##### Empty Page
Start writing in `content/1.computer-science/year-3/period-1-electives/digital-society.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-3/period-1-electives/game-theory
> Captured: 2026-08-02
# Game Theory
##### Empty Page
Start writing in `content/1.computer-science/year-3/period-1-electives/game-theory.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-3/period-1-electives/graph-theory
> Captured: 2026-08-02
# Graph Theory
##### Empty Page
Start writing in `content/1.computer-science/year-3/period-1-electives/graph-theory.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-3/period-1-electives/introduction-to-quantum-computing
> Captured: 2026-08-02
# Introduction to Quantum Computing
##### Empty Page
Start writing in `content/1.computer-science/year-3/period-1-electives/introduction-to-quantum-computing.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-3/period-1-electives/robotics-and-embedded-systems
> Captured: 2026-08-02
# Robotics and Embedded Systems
##### Empty Page
Start writing in `content/1.computer-science/year-3/period-1-electives/robotics-and-embedded-systems.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-3/period-1-electives/ubiquitous-computing-iot
> Captured: 2026-08-02
# Ubiquitous Computing & Internet of Things
##### Empty Page
Start writing in `content/1.computer-science/year-3/period-1-electives/ubiquitous-computing-iot.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-3/period-2-electives/block-chains
> Captured: 2026-08-02
# Block Chains
##### Empty Page
Start writing in `content/1.computer-science/year-3/period-2-electives/block-chains.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-3/period-2-electives/cryptography
> Captured: 2026-08-02
# Cryptography
##### Empty Page
Start writing in `content/1.computer-science/year-3/period-2-electives/cryptography.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-3/period-2-electives/immersive-technologies
> Captured: 2026-08-02
# Immersive Technologies
##### Empty Page
Start writing in `content/1.computer-science/year-3/period-2-electives/immersive-technologies.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-3/period-2-electives/introduction-to-bio-informatics
> Captured: 2026-08-02
# Introduction to Bio-Informatics
##### Empty Page
Start writing in `content/1.computer-science/year-3/period-2-electives/introduction-to-bio-informatics.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-3/period-2-electives/large-scale-it-and-cloud-computing
> Captured: 2026-08-02
# Large Scale IT and Cloud Computing
##### Empty Page
Start writing in `content/1.computer-science/year-3/period-2-electives/large-scale-it-and-cloud-computing.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-3/period-2-electives/software-and-systems-verification
> Captured: 2026-08-02
# Software and Systems Verification
##### Empty Page
Start writing in `content/1.computer-science/year-3/period-2-electives/software-and-systems-verification.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-3/period-2-electives/startup-engineering
> Captured: 2026-08-02
# Startup Engineering: Building Scalable Tech Ventures
##### Empty Page
Start writing in `content/1.computer-science/year-3/period-2-electives/startup-engineering.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-3/period-4/history-and-philosophy-of-computing
> Captured: 2026-08-02
# The History and Philosophy of Computing
##### Empty Page
Start writing in `content/1.computer-science/year-3/period-4/history-and-philosophy-of-computing.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-3/period-4/operating-systems
> Captured: 2026-08-02
# Operating Systems
##### Empty Page
Start writing in `content/1.computer-science/year-3/period-4/operating-systems.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/computer-science/year-3/period-4/theory-of-computing
> Captured: 2026-08-02
# Theory of Computing
##### Empty Page
Start writing in `content/1.computer-science/year-3/period-4/theory-of-computing.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/data-science-and-artificial-intelligence/course-description
> Captured: 2026-08-02
# Course Description
#Hello
#Hello

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/data-science-and-artificial-intelligence/year-1/period-1/discrete-mathematics
> Captured: 2026-08-02
# Discrete Mathematics
##### Empty Page
Start writing in `content/2.data-science-and-artificial-intelligence/year-1/period-1/discrete-mathematics.md` to see this page taking shape.

View file

@ -0,0 +1,8 @@
> Source: https://wiki.msvincognito.nl/data-science-and-artificial-intelligence/year-1/period-1/introduction-to-data-science-and-ai
> Captured: 2026-08-02
# Introduction to Data Science and Artificial Intelligence
##### Empty Page
Start writing in `content/2.data-science-and-artificial-intelligence/year-1/period-1/introduction-to-data-science-and-ai.md` to see this page taking shape.

Some files were not shown because too many files have changed in this diff Show more