Skip to content

Latest commit

 

History

351 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

study-companion

A versioned, data-driven Astro 6 integration + component library for interactive course study guides (e.g. optikk.martinsundal.no).

A course is authored as data — a course.yaml plus sections/*.mdx — and pins a version of this framework. The framework owns all design, schema, page wiring, and widgets; course repos stay thin and upgrade-safe. Static output, server-rendered KaTeX, near-zero client JS except small interactive islands.

Roadmap & open work: see ROADMAP.md. Per-release content migrations: see MIGRATIONS.md.


Consuming the framework (a course repo)

A course repo is three thin files — package.json (pins this framework via a git tag), astro.config.mjs, and src/content.config.ts — plus a content/ folder. No pages, components, or toolchain config; the framework injects all of it.

Don't hand-write these. The canonical, ready-to-copy example is course-template/ — scaffold a new course from it:

npx degit MartinSA04/StudyCompanion/course-template course-mycode

It ships the framework pin, a GitHub Pages deploy workflow, an annotated content/course.yaml, and example sections. Then read AUTHORING.md — the content author's guide (archetypes, widget decision guide, conventions, per-section definition-of-done).

You author only under content/ — course.yaml (metadata, formulas, symbols, glossary, exams, deadlines, feature flags — progress/search/flashcards/theme, each independently switchable — analytics, ui strings), flashcards.yaml, and sections/NN-slug.mdx — and drop static assets in public/ (figures/, sims/; the favicon, app icons and share card are auto-generated from the course accent). See course-template/ for the exact file layout and an annotated course.yaml.

For local framework development, point the study-companion dependency at a link:../path/to/study-companion instead of the git tag (see course-template/package.json).

Run pnpm dev to preview, pnpm build for static output to dist/. Search (Pagefind) is built into dist/pagefind/ and only works in build/preview, not dev; pass studyCompanion({ pagefind: false }) in astro.config.mjs to skip that step on an environment where the Pagefind binary can't run (the search UI then simply finds no index).

pnpm build also validates cross-references and fails on any dead link: a <Term name> / <FormulaRef id> / <ExamRef id> with no matching course.yaml entry (or an <ExamRef> to a paper with no url — <ExamRef> opens exams[].url directly, it does not jump to an anchor on the paper's row), a <FormulaRef> / <ExamRef> tag with no string id at all (a misspelt ids= or an id={…} expression — without one the link would silently open the wrong target), a symbols[] row whose term / formula matches nothing, or a duplicate <Statement> / formula / symbol anchor or exam id. The error names the section file and the unresolved target. It fails, too, on math that would render wrong with no error of its own — a one-line $$…$$ block, a doubled backslash in a string prop's $…$ math, and any TeX KaTeX cannot parse (AUTHORING.md §6).


Authoring content

Author under content/ only. course-template/content/ is a complete, annotated example — course.yaml documents every field, and sections/*.mdx show the section shapes; AUTHORING.md is the full guide (archetypes, conventions, per-section definition-of-done) and WRITING.md is how a module teaches (reader, intuition before the math, module scope, plain-language and terminology rules, derivations, sim design rules). Two things that bite:

  • course.yaml — YAML scalars containing LaTeX must be double-quoted with escaped backslashes: tex: "\\dfrac{a}{b}".
  • sections/NN-slug.mdx — frontmatter is the contract; order (not the filename) is the source of truth for sequence. Inline $…$ and display $$…$$ math render server-side via KaTeX.

Overview & chrome, straight from course.yaml. The overview renders an exam-facts card (exam.date / time / format / aids, with a muted authority-note link to exam.authorityUrl — default NTNU Studentweb) and, when deadlines[] is set, a quiet agenda: ONE flat list (no per-section dates, no done-states) whose past items hide themselves client-side against the reader's clock. links[] may carry a group (buckets the sidebar Lenker list, first-seen order) and a muted note. The footer's trust line reads as one flowing sentence: ui.footerDisclaimer, joined by a ui.reportIssueLabel issue link (→ ${repoUrl}/issues/new) when repoUrl is set. A module page's freshness line (ui.updatedLabel + its updated date) and its "Foreslå endring" link sit in a row just above the footer separator, reading as page metadata rather than site chrome. That link opens a prefilled new-issue form (title = the module's folio + name; body links the page and its source file on repoBranch) rather than GitHub's /edit/ route, which dead-ends non-collaborators at a fork prompt. When hubUrl is set, the sidebar gets a quiet "up" link (ui.hubLinkLabel, default "Alle emner") above Oversikt, back to the reader's course-hub site; the hub owns the live course list, so no sibling-course data is ever baked into a pinned build.

Widgets (available in every MDX section — no imports needed)

Component Props Purpose
<Formula> tex, caption?, block?, onSheet? Server-rendered KaTeX. onSheet={false} puts the «Ikke på formelarket» tag above a formula that is not on the exam's formula sheet. Wide formulas scroll; a top-level \allowbreak marks where one may wrap when it does not fit (AUTHORING.md).
<Figure> src, alt, caption?, number?, width?, height?, full? Captioned image/diagram. width+height reserve the aspect-ratio (no layout shift); lazy-loaded. caption may contain $…$; number → "Figur N".
<Video> id, title, channel?, duration?, caption?, number? A YouTube video as a click-to-load facade: a text-only card until the reader clicks, then a youtube-nocookie player in place. Nothing third-party loads before the click (no thumbnail, no Google JS, no cookies). id takes a video id or any youtube.com/youtu.be link, including a ?t= start; a link it cannot parse fails the build. caption may contain $…$; number → "Video N".
<Statement> kind? (law|theorem|definition|principle), name, id? A named, boxed result with a stable # anchor for deep-linking. name may contain $…$; id defaults to a slug of name.
<Term> name, slot? Inline link into the glossary page. name is the headword (slugified to the row anchor); the slot, if any, is the display word (e.g. an inflected form), else name is shown.
<Sidenote> label?, slot Short margin note. Floats into the right margin strip on wide viewports; falls back to a quiet inline aside when the strip collapses. Floats beside the content that follows it, so place it just before the relevant paragraph. Keep it to a sentence or two.
<FormulaRef> id, slot? Inline cross-ref to a formula's row in the Formelsamling (matches course.formulas[].id). With no slot it renders the formula (KaTeX) as the link; the target row flashes via :target.
<ExamRef> id, task?, slot? Inline reference from a quoted exam task to its paper: opens course.exams[].url in a new tab. id matches course.exams[].id; task is the task as the paper numbers it ("3b"). Default text is the paper's label + "oppgave 3b" (ui.examTaskLabel); the slot overrides it. The build fails on an unknown id or a paper with no url.
<Example> label?, title? Worked example ("regneeksempel"); default slot is the problem, then a <Solution>. title may contain $…$.
<Solution> label?, open? Collapsible worked solution, used inside <Example>. Put <Answer> inside it so it stays hidden until revealed.
<Answer> label? Highlighted final answer; place inside <Solution>.
<LearningGoals> title? Module objectives ("læringsmål"); slotted list.
<ExamFocus> title? Exam-priority block ("eksamensfokus"); slotted MDX.
<Table> columns, rows, caption?, rowHeader?, align? Data-driven reference/complexity table; cells may contain $math$. First column is a row header; scrolls horizontally on overflow.
<Callout> type? = note|tip|warning, title? Admonition box; slotted MDX body.
<Derivation> title?, open? Collapsible worked steps (<details>).
<Steps> / <Step> <Step title?> Numbered procedure as a vertical ribbon (a real <ol>). title may contain $…$.
<KeyTakeaways> title? End-of-module recap; the slotted bullet list renders as a checklist.
<Hints> / <Hint> <Hint solution? label? open?> Progressive hint ladder — auto "Hint 1", "Hint 2", …; solution makes the "Løsning" closer. Works with no JS.
<Compare> / <CompareCol> <CompareCol title> Side-by-side comparison of 2–3 concepts; cards auto-stack on mobile. title may contain $…$.
<CodeBlock> code, lang?, title?, id?, activeLine?, activeLines? Shiki-highlighted block (+ auto copy button). activeLine/activeLines emphasise 1-based line(s); with an id, a <Simulation> can step the highlight via api.codeBlock(id).
<SelfCheck> question, revealLabel? Prompt with answer behind a reveal. revealLabel defaults to "Vis svar".
<Quiz> question, options[], answer (0-based index, or an array of indices), explanation? MCQ; text may contain $…$. One index = radio rows graded on click; an array = checkbox rows (square marks, «Kryss av alle som stemmer» hint) graded by a «Sjekk svar» button.
<Simulation> src, title?, caption?, height?, host? (canvas|dom) Mounts a course-owned simulation (see below). host="dom" gives the module an SVG/HTML stage (api.stage) instead of a canvas. title/caption may contain $inline$ math.
<Stepper> src, codeId?, title?, caption?, height? Generic algorithm trace-player: the framework owns transport/seek/speed/variables + <CodeBlock> line-sync; a course module (public/steppers/) supplies run(input) + render(stage, frame). title/caption may contain $inline$ math. See AUTHORING.md §5.

Fenced code blocks (```py) are highlighted by Shiki and get a copy button automatically.

Captions & labels (<Formula caption>, course.yaml formula label, etc.) render $…$ via KaTeX and allow a few attribute-less inline tags (<b> <em> <sub> <sup> <code> <br>, …) — use those for emphasis, not Markdown **…**. Full escaping rules and the exact tag whitelist: AUTHORING.md §6.


Course-owned simulations

Interactive simulations live in the course repo (an ES module in public/sims/) so the framework carries no per-course code:

<Simulation src="/sims/thin-lens.js" title="Tynnlinse-avbildning" height={300} />

The module default-exports init(api), called on scroll-into-view, where api = { canvas, ctx, controls, getSize, onResize, codeBlock, signal } (with stage replacing canvas/ctx for host="dom"). The context is pre-scaled for devicePixelRatio; signal is a page-lifecycle AbortSignal for teardown across view transitions; codeBlock(id) returns a controller that drives a <CodeBlock> in lockstep with what the sim paints. The src is verified at build time (same for <Stepper src>), so a typo can't ship a dead widget. Full walkthrough and examples: AUTHORING.md §5.


Versioning

  • SemVer with intent: breaking schema change → major; new optional field or widget → minor; fix → patch. Tag every release vMAJOR.MINOR.PATCH.
  • Courses pin a tag and never break on framework changes until they move the pin.
  • loadCourse (the single content chokepoint) compares course.schemaVersion against the framework's SCHEMA_VERSION and fails the build with a clear message on a mismatch (newer → bump the pin; older → migrate, see MIGRATIONS.md). schemaVersion is required — there is deliberately no default, so an omitted version can't silently "match".
  • Within a major version SCHEMA_VERSION never changes.

Development

The framework ships a kitchen-sink demo course (content/, served via srcDir: demo/) so widgets can be developed and screenshot-verified standalone — it exercises every component in both themes.

pnpm install
pnpm dev                # serve the demo course (http://localhost:4321)
pnpm build              # static build of the demo + Pagefind index → dist/
pnpm preview            # preview the build (search works here, not in dev)
pnpm test               # unit tests for the pure logic (built-in node:test)
pnpm test:visual        # Playwright snapshots of the demo, both themes (CI)
pnpm typecheck          # tsc on the framework source

The brand fonts are self-hosted (no Google Fonts request): node scripts/fetch-fonts.mjs vendors Fraunces/Spectral/IBM Plex Mono woff2 into src/styles/fonts/ and regenerates src/styles/fonts.css; re-run it to refresh. All Google ranges are kept (unicode-range gates what each page downloads), so no glyph goes missing. KaTeX fonts are already bundled from its stylesheet.

pnpm test:visual (config in visual/) builds + previews the demo and compares full-page screenshots per route × theme. Baselines are platform-specific — generate them once on Linux with pnpm test:visual:update and commit visual/*-snapshots/; CI (.github/workflows/visual.yml) compares against them. Specs that depend on the browser engine also run in WebKit (the webkit project), because iPhones render math with it. Install it once with pnpm exec playwright install --with-deps webkit.

pnpm test uses Node's built-in test runner (the toolchain pins Node 22, which strips the TS types behind the experimental type-stripping flag) — no extra dependency. It covers the pure modules (lib/nav, lib/color, lib/progress) and statically checks the mdxComponents map; the .astro widgets themselves are exercised by the demo pnpm build.

The repo depends on itself by name ("study-companion": "link:." in package.json) so the injected routes resolve the package exactly as a real course does — pnpm install wires node_modules/study-companion → the repo root via pnpm's own link: mechanism, no bespoke script. To develop against a real course instead: pnpm --dir ../course dev.

Course hub (kurs.martinsundal.no)

A standalone one-pager listing every course site, deployed to GitHub Pages from this repo (.github/workflows/pages.yml, pushes to main). It is a second tiny Astro build — astro.config.hub.mjs, srcDir: hub/, no course integration — whose page imports the framework's own src/styles/* and ThemeToggle, so it tracks the design system with zero copied CSS. Courses live in hub/courses.yaml (code/title/url/term, plus an optional note surfaced as a tile note — e.g. "Vedlikeholdes ikke lenger" for a frozen course; file order is display order). pnpm hub:dev to work on it locally.

The hub carries the same SEO surface a course site does: canonical, Open Graph and Twitter tags, robots.txt, sitemap.xml, a WebSite site name, and a CollectionPage + ItemList of every guide. Its one build hook (hub-icons) rasterizes the >_ mark via the shared src/lib/rasterizeIcons.ts, because og:image needs a real file — the page's own favicon is a data URI, which crawlers cannot fetch.

SEO & structured data

Every page emits canonical/Open Graph/Twitter tags and schema.org JSON-LD, all derived from course.yaml + section frontmatter (src/lib/seo.ts, src/lib/jsonLd.ts, src/lib/courseGraph.ts). Absolute-URL features need site in the course's astro.config.mjs; without it they degrade to a single DEV warning rather than emitting unusable relative URLs.

A guide is about a course; it is not the course. The overview is a LearningResource, and institution names the provider of the referenced Course — never of this site. Claiming otherwise would assert an institutional authorship the footer disclaimer denies. Modules are LearningResources linked to the guide by a stable @id (<site>/#guide), with <site>/#author doing the same for author, so the per-page fragments form one connected graph.

Titles may carry soft hyphens (U+00AD) to pin where a long compound wraps on a phone (AUTHORING.md §6). Those are display-only: src/lib/text.ts plain() strips them at every boundary where a title becomes machine-read data — the <title>, OG/Twitter meta, JSON-LD, and the web manifest. Anything new that turns a course or section title into metadata must call it too.

The overview also emits a WebSite node whose name matches og:site_name (e.g. "TDT4120 Algoritmer og datastrukturer"). Google reads the site name shown above each result from that node on the subdomain's root page. Without it, a guide falls back to the parent domain's site name, which names a different site.

Nothing relative to the build date may reach a search snippet. Google writes snippets from the HTML it crawled and shows them for weeks. A countdown pill ("om 42 dager") is true only on the day of the build: the client script corrects it for readers, but not for Google. Every .sc-countdown therefore sits inside data-nosnippet, on a span, div or section (the only elements Google honours it on), in the served HTML (Google ignores it when a script adds it). visual/search-snippet.spec.ts checks the served HTML of the overview, the exam page and the hub. Give any new build-relative text the same treatment. Absolute dates are fine: the exam page's meta description leads with the exam date.

Titles and descriptions carry the words a course is searched by: the code, the course name, the institution, and ui.guideLabel ("Kompendium", the word Norwegian students pair with a course in search). Every page title names the course in full after the page ("Sortering · TDT4120 Algoritmer og datastrukturer"); the overview's is "TDT4120 Algoritmer og datastrukturer · Kompendium · NTNU". The overview and tool-page descriptions end with guideSentence (src/lib/seo.ts): "Kompendium i TDT4120 Algoritmer og datastrukturer ved NTNU". The overview's hero-tag shows the institution too, since Google ranks on the words a page shows. Never join these strings with a dash: Norwegian has no long dash (WRITING.md), and the snippet spec checks it.

The flashcards page emits schema.org Quiz of Flashcard questions (Google's Education Q&A rich result); <Quiz> emits none, since Google retired the practice-problem rich result in January 2026. The glossary and the symbol list each emit a DefinedTermSet (a symbol's name is its plain spelling, "Nc" for N_c, and only rows with an id get a # url); every page gets a BreadcrumbList. The flashcard deck is capped at FLASHCARD_LD_LIMIT (100) questions, logged when it truncates.

Releases

Contributors

Languages