A reusable build system for course materials: a calendar-driven weekly schedule engine, PDF version tracking, and Canvas/README calendar generation. A course's weekly shape and slide-authoring format are both configuration, not hardcoded — a course declares its shape, picks a renderer backend, and everything else (dates, slot IDs, versions, calendar tables) follows from that.
Consuming repos mount this as a git submodule, pinned to a specific
commit — update deliberately, never track main.
core/ Renderer-agnostic engine: schedule, version
tracking, Canvas/README generation.
backends/
latex-beamer/ LaTeX/Beamer renderer backend.
typst/ Typst backend (one source, compiled twice
with a different --input, not two headers).
test/ Minimal reference backend / template.
institutions/
nus/ Optional NUS/Singapore holiday + academic-
calendar data-fetching. Opt-in only.
core/ has no renderer-specific logic; backends/*/ has no
scheduling/versioning/Canvas logic. A course picks its backend via
config/course.mk's RENDERER; core/backend-lib.sh dispatches into
backends/$RENDERER/.
tooling/core/cli.sh build # walk the semester, track versions
tooling/core/cli.sh build --pdfs # also compile every PDF
tooling/core/cli.sh readme # print a markdown calendar table
tooling/core/cli.sh canvas # print an HTML calendar table
tooling/core/cli.sh bump SLOT_ID VARIANT
tooling/core/cli.sh place # regenerate the content map from
# config/content-order.confCopy examples/Makefile into your repo and adjust
its config paths. See cli.sh's header comment for the full config/env
contract, and AGENTS.md for a step-by-step new-course
checklist.
For lower-level access, source core/*.sh directly (set
TOOLKIT_DIR/COURSE_ROOT first): semester_weeks walks the calendar,
week_occurrences lists a week's scheduled slots, backend_build_slot/
backend_content_hash/backend_extract_title dispatch into the
renderer, get_slot_version/bump_slot_version track versions,
render_markdown_calendar/render_html_calendar build the tables. Each
function's header comment has its full argument list; cli.sh is a
complete worked example.
core/splice-lib.sh's splice_markers FILE START END CONTENT_FILE
replaces everything between two marker lines in a hand-maintained file
with generated content, leaving the markers themselves in place — the
usual way a course's own regeneration script updates a README in place.
-
config/course.mk—COURSE_CODE,COURSE_NAME,HOSTING_ORG,CANVAS_HOST,RENDERER(whichbackends/dir to use). -
config/session-kinds.conf— the weekly schedule shape, one row per weekly occurrence of a kind (a kind with N occurrences/week gets N rows sharing the sameKIND_ID):KIND_ID|LABEL|WEEKDAY|SUFFIX|SLOT_PATTERN|VARIANTS|WEEK_START|WEEK_END|EXCLUDE_WEEKS|DAY_LABEL|CANCEL_EXTRA_WEEKDAYS|AUTO_SHIFT_ON_HOLIDAY|HOLIDAY_CONFLICT_WEEKS|CONTENT_LIST_FILE|PUBLIC_VARIANTS|HEADER_LABELField Meaning KIND_IDshort id, e.g. lecture,studioLABELdisplay name, e.g. "Lecture" WEEKDAYmon..sunSUFFIXdistinguishes same-kind occurrences in one week ( A/B);-if only oneSLOT_PATTERNtemplated public ID: {n}= teaching week,{suffix}=SUFFIX,{count}= flat 1-based counter across every active occurrence of thisKIND_ID(merged across its rows) instead of week-derived — e.g.Lab{count}→Lab1..LabNstraight through. A row skipped byEXCLUDE_WEEKSdoesn't consume a countVARIANTScomma-separated build variants ( view,print,problem,solution, ornone) — everything built and version-tracked; seePUBLIC_VARIANTSfor what's linkedWEEK_START/WEEK_ENDinclusive teaching-week bounds EXCLUDE_WEEKSoptional, -for none: comma-separated weeks to skip within rangeDAY_LABELoptional, -for none: overrides the weekday name a split column's header shows (e.g.Mon-Wedwhen the real day isn't fixed)CANCEL_EXTRA_WEEKDAYSoptional, -for none: comma-separated extra weekdays this occurrence also spans (e.g. a studio meeting Mon+Tue) — a holiday on any of them cancels it. Still one row, oneSLOT_IDAUTO_SHIFT_ON_HOLIDAYoptional, -for none: a holiday-colliding week is skipped at placement time (likeEXCLUDE_WEEKS), so content shifts to the next eligible week, cascading. Requires{count}inSLOT_PATTERNHOLIDAY_CONFLICT_WEEKSoptional, -for none: weeks where a collision is already known/accepted (e.g. a take-home replaces the session) — held in place instead of shifted/cancelled, rendered with a⚠️prefixCONTENT_LIST_FILEoptional, -for none: path to a file of content identifiers in curriculum order. Unlike{count},SLOT_IDstays week-derived while the content index shifts past a holidayPUBLIC_VARIANTSoptional, -for none: subset ofVARIANTSthat gets linked on a rendered page (the rest are still built/tracked, just never linked)HEADER_LABELoptional, -for none: fully overrides a column's header text instead of the computed"Weekday (Label Suffix)"defaultColumn splitting: one occurrence/week → one merged column headered by
KIND_ID. More than one → one column per occurrence, headered"Wednesday (Lecture A)". Column order follows the file's own row order (by first appearance of eachKIND_ID+SUFFIX), so a course can put e.g. Reflection's column between two Lecture columns just by writing the rows in that order. Recess row: whenRECESS_AFTER_WEEKis nonzero, both renderers insert a dashed "Recess" row with a "🏖️ Recess Week (24 Aug – 28 Aug)" note between the two surrounding teaching weeks.Holiday handling, in short:
AUTO_SHIFT_ON_HOLIDAYmoves content forward past a collision (only valid with{count});HOLIDAY_CONFLICT_WEEKSholds it in place instead, for a collision already accounted for;CONTENT_LIST_FILEgets the same forward-shift behavior but for a week-derived ({n}) slot, by shifting a separate content index rather than the slot itself.available_slot_count(inschedule-lib.sh) reports how many eligible weeks a kind has through a given week, merged across its rows, so a course's own build step can hard-fail when it has more content than room rather than silently dropping or shifting content past the end of term. -
A content-to-slot map —
SLOT_ID|SOURCE_PATHpairs, hand-written or generated bycli.sh place(below). -
Optionally, a content order (
config/content-order.conf) — every item in curriculum order, from whichcli.sh placegenerates the content map,graded-slots.confandkind-extra-slots.conf:session|Studios/1_GPIO/GPIO_handout.typ session|Studios/2_Interrupts/interrupts_handout.typ|graded +Project|Projects/Final_Project/Final_Project_Specification.typA
KIND_ID|SOURCE_PATH[|graded]line is the next item for that kind; a+NAME|SOURCE_PATHline is an extra handout sharing the session of the item above it (slot ID<slot>-NAME). Items fill the kind's sessions in (week, row) order — all of a kind's rows share one sequence — and a holiday-cancelled session is skipped, so every later item moves forward one session, taking its graded mark and extras with it. AHOLIDAY_CONFLICT_WEEKSweek keeps its item.placefails, writing nothing, if there are more items than sessions. The three generated files shouldn't be hand-edited: edit the order and re-runplace. -
Extra slots (
kind-extra-slots.conf,WEEK|KIND_ID|SLOT_ID): a plainKIND_IDshows in every column of that kind; a suffix-qualified key (session-S2) shows only in that column. Extra slots get titles, versions and PDF builds like any other slot. -
Release/label/note/holiday config (all optional, empty-safe — pass
/dev/nullfor any you don't need): a release allow-list (one slot ID per line), aWEEK|KIND_ID|LABEL[|COLOR]override file for a week+kind with no real occurrence (the optional 4th field colors that one label in HTML, overridingCALENDAR_OCCASION_COLOR), aWEEK|NOTEfile, aDATE|NAMEholiday calendar, and aHOLIDAY_NAME|EMOJImap. -
Source content in whatever format
RENDERERexpects.
Optional config/course.mk keys; unset keeps the default. cli readme
is plain Markdown and has no equivalent.
| Key | Controls | Default |
|---|---|---|
CALENDAR_BORDER_COLOR |
table/cell borders | #dddddd |
CALENDAR_HEADER_BG |
header row background | #eeeeee |
CALENDAR_LINK_COLOR |
a released item's link | (unset — inherits the page's own link color) |
CALENDAR_PENDING_COLOR |
an unreleased item's text | #888888 |
CALENDAR_CANCELLED_COLOR |
a holiday-cancelled occurrence's text | #c0392b |
CALENDAR_NOTES_COLOR |
the Notes column's text | #555555 |
CALENDAR_CURRENT_BG |
current week's row background | (unset) |
CALENDAR_CURRENT_BORDER_COLOR |
current week's left-accent border | (unset) |
CALENDAR_ROW_ODD_BG / CALENDAR_ROW_EVEN_BG |
alternating row backgrounds | (unset) |
CALENDAR_OCCASION_COLOR |
an occasion label's text, unless its own row sets a color | (unset) |
CALENDAR_CURRENT_WEEK_BG |
current week's number-cell background | (unset — falls back to CALENDAR_CURRENT_BG) |
CALENDAR_WEEK_BG |
non-current week-number cell background | (unset) |
CALENDAR_RECESS_BG |
Recess row's background (text is CALENDAR_NOTES_COLOR) |
(unset) |
CALENDAR_SHOW_WEEK_DATES |
shows a "10 Aug – 14 Aug" sub-line under each week number | (unset) |
CALENDAR_COLUMN_WIDTHS |
comma-separated CSS widths (Week, kind columns, Notes) — emits a <colgroup> |
(unset) |
CALENDAR_CANCELLED_NEWLINE |
splits a cancellation cell across two lines instead of one | (unset) |
Setting CALENDAR_CURRENT_BG or CALENDAR_CURRENT_BORDER_COLOR also
adds a "📍 This week" sub-label to the current week's number cell
(not separately configurable).
All config-driven and opt-in — omitting a file just omits the feature.
- Key Events —
config/key-events.conf(DATE|START_TIME|END_TIME|NAME), appended as a table after the calendar. Dates shown as "10 Aug 2026" (format_date_long). - Key Event links (HTML only) —
config/key-event-links.conf(NAME|LABEL1|URL1|LABEL2|URL2|..., any number of pairs), shown under the event's name in the Key Events table, e.g. an exam's past papers. Keyed by NAME, so it works whenkey-events.confis generated. An empty URL renders as a greyed-out pending label. - Extra Notes-column categories —
config/special-dates.conf(DATE|NAME) and Key Events both also surface in the weekly Notes column, alongside holidays and the maintainer note, in that fixed order. Holiday notes are filtered to days some kind actually meets that specific week (class_weekdays); special dates/key events are not filtered this way. - Source-repo links (
cli readmeonly) —config/source-links.conf(SLOT_ID|PATH) renders[source](PATH)instead of the usual variant links for a listed slot. - Holiday-cancellation phrase order (
cli readmeonly) — setHOLIDAY_FIRSTto swap"No <Kind> (<holiday>)"to"<holiday> (No <Kind>)". - Public Holidays Reference (
cli readmeonly) — a legend table of every holiday within ±7 days of the semester, appended automatically. - Resources — a verbatim block under a "Resources" heading:
config/canvas-resources.html/config/readme-resources.md. - A second per-occurrence link (e.g. a recording) —
config/kind-extra-links.conf(KIND_ID|LABEL) +config/extra-links.conf(SLOT_ID|URL). - Occasion labels with optional links — a week+kind label now always
shows alongside any real occurrences that week (not just when the
kind has zero).
config/occasion-links.conf(WEEK|KIND_ID|LINK1_LABEL|LINK1_URL|LINK2_LABEL|LINK2_URL, either URL may be empty). A split column tries a suffix-qualified key first (KIND_ID-SUFFIX) before falling back to the plainKIND_ID. - Graded/important slot marking —
config/graded-slots.conf(oneSLOT_IDper line) prefixes a title with "🔴 ". - Extra per-week slots — a second linked file sharing a week's
already-scheduled session without its own weekday/date/cancellation
check:
config/kind-extra-slots.conf(WEEK|KIND_ID|SLOT_ID).cli readmegroups a slot into an existing entry when the titles match exactly, else keeps it separate;cli canvasnever groups. Skipped entirely if the week's real occurrence is holiday-cancelled. - An extra note line per occurrence (
cli canvasonly) —config/extra-notes.conf(SLOT_ID|HTML), rendered under a regular occurrence's title+links. The toolkit doesn't care how the HTML was produced (e.g. a course-owned LaTeX-section extractor).
A backend at backends/<name>/ implements three scripts:
build-slot.sh SOURCE_PATH VARIANT OUTPUT_PATH [SLOT_ID]— produce one variant's artifact.VARIANTis opaque, backend-owned.SLOT_ID, if supported, lets the backend substitute the real slot ID into a placeholder in the source.content-hash.sh SOURCE_PATH VARIANT [SLOT_ID]— a hash of every input affecting this slot/variant's output.extract-title.sh SOURCE_PATH— the slot's display title (empty if none).
This fits a course whose compile step is "one source file + a shared
preamble + an optional variant header" (latex-beamer, typst,
test). A course with materially more complex build orchestration
(custom preambles, multi-stage placeholder injection, diagram
generation, its own parallel-build job isolation) is expected to keep
that as its own bespoke build script, sourcing whichever narrower
core/*.sh primitives it needs (sgt_date, get_slot_version,
splice_markers, ...) instead of forcing it through this contract —
such a script can skip build_slot/content_hash entirely and never
call backend_build_slot/backend_content_hash at all.
Interactive-snippet testing (e.g. running embedded code through a real interpreter) is not part of this toolkit — it's a course/content concern, kept entirely in the consuming repo.
core/enrich-lib.sh's is_holiday/holiday_emoji just read a course's
own flat config/holidays.conf/config/holiday-emoji.conf — fetching a
real calendar is out of scope for core/. institutions/nus/ is an
optional, pluggable way to produce that data:
calendar-data-lib.sh—fetch_sg_holidays_dynamic(data.gov.sg),fetch_nus_calendar_dynamic(NUS's academic-calendar PDF: recess/ reading week, NUS-specific holidays),fetch_nusmods_exam_dynamic(a module's exam date via the NUSMods API).fetch-calendar-data.sh— orchestrator: run withCOURSE_ROOTset to a course repo, writes real holiday data to that repo'sconfig/holidays.confplus a raw cache underconfig/calendar-data/.holiday-emoji.conf— a defaultHOLIDAY_NAME|EMOJImap to copy into a course's own config as a starting point.
COURSE_ROOT=/path/to/your-course-repo \
tooling/institutions/nus/fetch-calendar-data.shMakes real network calls — not part of tests/run.sh's offline suite.
Re-run whenever semester dates change or a new academic year is needed.
Every script targets bash 3.2 (macOS's default /bin/bash): use
tr/sed for case conversion, not bash 4+ parameter expansion
(${var,,}, ${var^}).
tests/run.sh # full suite, including real pdflatex/typst compiles
tests/run.sh --skip-latex # skip pdflatex compiles if not installed
tests/run.sh --skip-typst # skip typst compiles if not installedBackend tests auto-skip if their toolchain (pdflatex, or
typst+pdfinfo) isn't on PATH. No external test framework — see
tests/assert.sh. Runs in CI on every push and PR.