diff --git a/Cargo.toml b/Cargo.toml
index 2fd4f8b..c53ce87 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,10 +17,6 @@ path = "src/main.rs"
name = "coursebank"
path = "src/lib.rs"
-[features]
-default = ["parquet"]
-parquet = ["dep:parquet", "dep:arrow-array", "dep:arrow-schema"]
-
[dependencies]
clap = { version = "4", features = ["derive"] }
csv = "1"
@@ -29,9 +25,14 @@ serde_json = "1"
serde_yaml_ng = "0.10"
thiserror = "2"
-arrow-array = { version = "55", optional = true }
-arrow-schema = { version = "55", optional = true }
-parquet = { version = "55", optional = true }
+arrow-array = { version = "55"}
+arrow-schema = { version = "55"}
+parquet = { version = "55"}
+aes-gcm = { version = "0.10"}
+pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"]}
+sha2 = { version = "0.10"}
+base64 = { version = "0.22"}
+getrandom = { version = "0.2"}
[profile.release]
opt-level = 3
diff --git a/docs/guide/assignment.md b/docs/guide/assignment.md
new file mode 100644
index 0000000..ee3509c
--- /dev/null
+++ b/docs/guide/assignment.md
@@ -0,0 +1,192 @@
+# An assignment on the web
+
+This follows a single homework from an assembled record to a published Quarto page whose solutions stay locked until a student enters a password.
+It assumes a course directory with approved items and an assembled assessment; if you do not have one, [`setup`](crate::guide::setup) and [`first_exam`](crate::guide::first_exam) build both.
+
+The site export is the third off-Canvas path, alongside the printed exam in [`typst_export`](crate::guide::typst_export) and the plain-text worksheet from `export practice`.
+The difference is where the solutions go: printed on a key you keep, or encrypted into a bundle that ships with the page and unlocks in the browser.
+
+## Assemble the homework
+
+Assemble it the same way you assemble an exam, with the platform set to the website rather than paper or Canvas:
+
+```console
+$ coursebank assemble a1.1 \
+ --title "Homework 1" \
+ --kind homework --platform other \
+ --levels 2=1,3=1 --lectures L1.1 --seed 20260210
+```
+
+What lands in `assessments/a1.1.yaml` is a record of the draw.
+The id `a1.1` is the one thing to choose deliberately here: it becomes the bundle's file name and the name the page's gate points at, so keep it URL-safe and stable.
+
+A single unshuffled form is fine for homework.
+To hand different students different option orders, add `--forms 2` and export each form separately; the printed choices and the letters the solutions refer to move together, because both come from the form's recorded seed.
+
+## Export for the web
+
+```console
+$ coursebank export site a1.1 --out build/a1.1 --assets build/static
+password for a1.1: k7m4-9p2q-r8tx-3wn6
+wrote build/a1.1/_questions.qmd
+wrote build/a1.1/a1.1-solutions.json
+wrote build/static/questions.css
+wrote build/static/solutions.js
+
+Include in the page with: {{< include _questions.qmd >}}
+```
+
+That is four files and a password.
+
+`_questions.qmd` is the partial you include from the page.
+`a1.1-solutions.json` is the encrypted bundle, named for the assessment so several assignments can share one site.
+The two files under `build/static` style the questions and perform the unlock; they install once for the whole site, not once per page, so `--assets` is something you run the first time and drop afterward.
+
+The password is printed once and written nowhere.
+Record it now.
+It cannot be recovered from the files, which is the point: the bundle is useless without it, so losing it means re-exporting rather than reading it back.
+
+## What the partial holds
+
+The questions are Quarto fenced divs, with an empty, hidden slot where each solution will land:
+
+```text
+::: {.solutions-gate data-bundle="a1.1-solutions.json"}
+:::
+
+::: {.q #q-enthalpy-qp-001}
+:::: {.q-head}
+[Question 1]{.q-num} [Single best answer]{.q-kind} [1 point]{.q-points}
+::::
+
+:::: {.q-stem}
+A reaction is run in an open flask, so the system stays at the constant
+pressure of the room. The heat the reaction exchanges with its surroundings
+equals the change in which quantity?
+::::
+
+:::: {.q-choices}
+1. Enthalpy, $\Delta H$
+2. Internal energy, $\Delta U$
+3. Gibbs free energy, $\Delta G$
+4. Entropy, $\Delta S$
+::::
+
+:::: {.qsol data-solution-for="q-enthalpy-qp-001" hidden="true"}
+::::
+:::
+```
+
+The choices are printed without letters, and the stylesheet draws the A, B, C, D from their position.
+There is nothing in this file to leak: no `correct` flag, no solution text, no rationale.
+The `.qsol` slot is empty until the browser fills it, and the id in `data-solution-for` is the key the bundle looks up.
+
+Math is written in `$ … $` and passed through untouched, so MathJax typesets it in the page.
+
+## What the bundle holds
+
+Every solution is rendered to HTML, then encrypted:
+
+```json
+{
+ "v": 1,
+ "page": "a1.1",
+ "kdf": { "name": "PBKDF2", "hash": "SHA-256", "iterations": 250000, "salt": "jqbE6xIB..." },
+ "cipher": "AES-GCM",
+ "items": {
+ "q-enthalpy-qp-001": { "iv": "8jzuxY...", "ct": "gmzNSlhioU...(+tag)" },
+ "q-enthalpy-derive-001": { "iv": "1BddCd...", "ct": "sC8czL547/...(+tag)" }
+ }
+}
+```
+
+The password derives an AES-256 key through PBKDF2-HMAC-SHA256 at 250,000 iterations over a random salt.
+Each solution is encrypted under its own random IV, with the authentication tag appended to the ciphertext.
+The plaintext HTML never leaves your machine.
+Items are listed in the order the questions appear, not sorted, so the reader's browser can decrypt the first one to check the password before touching the rest.
+
+## Wire it into the site
+
+Install the two assets once in `_quarto.yml`:
+
+```yaml
+format:
+ html:
+ css:
+ - static/questions.css
+ include-after-body:
+ - static/solutions.js
+```
+
+Put `_questions.qmd` and `a1.1-solutions.json` in the page's own directory, and include the partial from the page:
+
+```markdown
+---
+title: "Homework 1"
+---
+
+{{< include _questions.qmd >}}
+```
+
+The gate resolves `data-bundle` relative to the page URL, so keeping the bundle beside the page is enough.
+If your build prunes files it does not see linked, add `resources: ["*-solutions.json"]` to the page front matter so the bundle ships with the render.
+
+Render with `quarto render`.
+A reader who opens the page sees the questions and a locked panel; typing the password decrypts the solutions in place, with the math typeset.
+
+## Hand out the password, and rotate it
+
+Give the password through a channel students already have, such as the course LMS, rather than the site itself.
+
+Be clear-eyed about what the lock does.
+It keeps solutions off a public page until someone has the password.
+It does not make them secret in a strong sense: the whole bundle is downloaded, so anyone with the password, or anyone they share it with, can decrypt every item, and the ciphertext is open to an offline guessing attack.
+Eighty bits of password entropy and a quarter-million PBKDF2 iterations make guessing slow, but the right mental model is a lock on a take-home worksheet, not a grading system of record.
+
+Rotate the password after the due date by re-running the export, which mints a fresh one and a freshly encrypted bundle:
+
+```console
+$ coursebank export site a1.1 --out build/a1.1
+password for a1.1: 2h9k-w4rq-8mnp-x6tv
+...
+```
+
+To set a password yourself instead of generating one, pass `--password`.
+Use that only when you have a reason to, such as re-encrypting an unchanged page with a password you already circulated.
+
+## Doing this from Rust
+
+The CLI is a thin wrapper over `coursebank::site`, which is compiled only with the `site` feature.
+The example below needs `--features site` to build, so it is not run as a doctest:
+
+```rust,ignore
+use std::path::Path;
+
+use coursebank::assessment::{AssessmentFile, Form};
+use coursebank::site::{self, Options};
+use coursebank::Catalog;
+
+fn main() -> coursebank::Result<()> {
+ let catalog = Catalog::load(Path::new("."))?;
+ let path = catalog.layout.assessments().join("a1.1.yaml");
+ let record = AssessmentFile::load(&path)?;
+
+ // An unshuffled form A; declare a form with a seed to shuffle.
+ let form = Form { id: "A".into(), seed: 0, shuffle_items: false, shuffle_options: false };
+
+ // `password: None` generates a fresh one; pass `Some(_)` to set your own.
+ let rendered = site::render(&catalog, &record, Options { form, password: None })?;
+
+ std::fs::write("build/a1.1/_questions.qmd", &rendered.questions_qmd)?;
+ std::fs::write(
+ format!("build/a1.1/{}-solutions.json", rendered.page),
+ &rendered.solutions_json,
+ )?;
+ // The password is the one thing not on disk; print it now.
+ println!("password for {}: {}", rendered.page, rendered.password);
+
+ Ok(())
+}
+```
+
+`site::assets()` returns the two browser files as name and contents, if you would rather write them from your own code than pass `--assets`.
diff --git a/src/assets/site/questions.css b/src/assets/site/questions.css
new file mode 100644
index 0000000..77fd138
--- /dev/null
+++ b/src/assets/site/questions.css
@@ -0,0 +1,249 @@
+/* ============================================================================
+ questions.css — worksheet items + gated solutions
+ Sits beside editor-notes.css: same left-border-accent language, same
+ small-caps auto-headers, same .quarto-dark dark-mode hook.
+
+ Worksheet content is authored as Quarto MARKDOWN inside fenced divs, so
+ inline math is $ … $ and paragraphs/lists render normally. Choice letters
+ (A, B, C, D) are drawn by a CSS counter, so a choice is just a list item.
+
+ Semantic color coding (information, not decoration):
+ indigo = a solution / answer region
+ green = the correct choice / accepted answer
+ red = a named misconception
+ ==========================================================================*/
+
+:root {
+ --q-ink: #1f2328;
+ --q-muted: #5a5a5a;
+ --q-line: #e4e4e4;
+ --q-card-bg: #ffffff;
+
+ --sol-accent: #4b4f9a; /* indigo: "here be answers" */
+ --sol-bg: #f5f5fb;
+ --sol-rule: #dedcef;
+
+ --ok-ink: #2f6249; /* correct / accepted (matches editorial green) */
+ --ok-bg: #e7f2ec;
+ --mis-ink: #a13d2e; /* misconception (matches editorial red) */
+}
+
+/* ---- Question card ---- */
+.q {
+ border: 1px solid var(--q-line);
+ border-radius: 6px;
+ background: var(--q-card-bg);
+ padding: 1.1rem 1.3rem 1.25rem;
+ margin: 1.6rem 0;
+}
+
+/* Header. Authored as [Question 1]{.q-num} [..]{.q-kind} [..]{.q-points}; */
+.q-head {
+ display: flex;
+ align-items: baseline;
+ gap: 0.75rem;
+ margin-bottom: 0.7rem;
+ padding-bottom: 0.55rem;
+ border-bottom: 1px solid var(--q-line);
+}
+.q-head > p { display: contents; margin: 0; }
+.q-num { font-weight: 700; letter-spacing: 0.01em; }
+.q-kind {
+ font-variant: small-caps; letter-spacing: 0.06em;
+ font-size: 0.78rem; color: var(--q-muted);
+}
+.q-points {
+ margin-left: auto; font-size: 0.8rem; color: var(--q-muted);
+ white-space: nowrap;
+}
+
+.q-stem { margin: 0 0 0.9rem; }
+.q-stem > p:first-child { margin-top: 0; }
+.q-stem > p:last-child { margin-bottom: 0; }
+
+/* ---- Multiple choice (a plain ordered list; letters via counter) ---- */
+.q-choices > ol {
+ list-style: none; margin: 0; padding: 0;
+ display: grid; gap: 0.5rem;
+ counter-reset: choice;
+}
+.q-choices > ol > li {
+ position: relative;
+ padding: 0.55rem 0.7rem 0.55rem 3rem; /* room for the badge on the left */
+ border: 1px solid var(--q-line);
+ border-radius: 5px;
+ counter-increment: choice;
+}
+.q-choices > ol > li::before {
+ content: counter(choice, upper-alpha); /* A, B, C, D … */
+ position: absolute;
+ left: 0.55rem; top: 0.5rem;
+ display: grid; place-items: center;
+ width: 1.9rem; height: 1.9rem;
+ border: 1px solid var(--sol-accent);
+ border-radius: 50%;
+ font-weight: 700; font-size: 0.9rem;
+ color: var(--sol-accent);
+}
+
+/* ---- Free-response writing space (prints with room to write) ----- */
+.q-response {
+ min-height: 6.5rem;
+ border: 1px dashed var(--q-line);
+ border-radius: 5px;
+ background:
+ repeating-linear-gradient(
+ to bottom, transparent, transparent 1.55rem,
+ var(--q-line) 1.55rem, var(--q-line) calc(1.55rem + 1px));
+ background-position: 0 0.9rem;
+}
+.q-response::before {
+ content: "Your answer";
+ display: block;
+ font-variant: small-caps; letter-spacing: 0.06em;
+ font-size: 0.72rem; color: var(--q-muted);
+ padding: 0.3rem 0.6rem 0;
+}
+
+/* ---- Solution slot (filled by solutions.js on unlock) ---- */
+.qsol {
+ border-left: 3px solid var(--sol-accent);
+ border-radius: 0 4px 4px 0;
+ background: var(--sol-bg);
+ padding: 0.9rem 1.15rem;
+ margin-top: 1rem;
+ font-size: 0.95rem; line-height: 1.55;
+}
+.qsol::before {
+ content: "Solution";
+ display: block;
+ font-variant: small-caps; letter-spacing: 0.06em; font-weight: 600;
+ color: var(--sol-accent);
+ padding-bottom: 0.35rem; margin-bottom: 0.6rem;
+ border-bottom: 1px solid var(--sol-rule);
+}
+.qsol > p:first-of-type { margin-top: 0; }
+.qsol > *:last-child { margin-bottom: 0; }
+
+@keyframes sol-in { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; } }
+.qsol.is-unlocked { animation: sol-in 180ms ease-out; }
+@media (prefers-reduced-motion: reduce) { .qsol.is-unlocked { animation: none; } }
+
+/* pieces inside a solution */
+.sol-answer { font-size: 1.02rem; }
+.sol-model {
+ border-left: 2px solid var(--ok-ink);
+ background: var(--ok-bg);
+ padding: 0.55rem 0.8rem; border-radius: 0 4px 4px 0; margin: 0.7rem 0;
+}
+.sol-model > p:first-child { margin-top: 0; }
+.sol-model > p:last-child { margin-bottom: 0; }
+.sol-explain { margin: 0.7rem 0; }
+.sol-feedback-title {
+ font-variant: small-caps; letter-spacing: 0.05em; font-weight: 600;
+ color: var(--q-muted); margin: 0.9rem 0 0.4rem;
+}
+
+/* per-distractor feedback: badge in col 1, BOTH text spans in col 2 ---- */
+.sol-feedback { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.6rem; }
+.sol-feedback > li {
+ display: grid;
+ grid-template-columns: 1.9rem 1fr; /* badge | body */
+ gap: 0.6rem;
+ align-items: start;
+}
+.sol-feedback .opt {
+ display: grid; place-items: center; width: 1.9rem; height: 1.9rem;
+ border-radius: 50%; font-weight: 700; font-size: 0.8rem;
+ color: var(--mis-ink); border: 1px solid var(--mis-ink);
+}
+.sol-feedback .opt-body { /* the single col-2 cell; fills 1fr */
+ display: grid; gap: 0.25rem;
+}
+.sol-feedback .opt-mis { color: var(--mis-ink); font-style: italic; }
+.sol-feedback .opt-why { color: var(--q-ink); }
+
+/* rubric table */
+.sol-rubric { width: 100%; border-collapse: collapse; margin: 0.7rem 0; font-size: 0.92rem; }
+.sol-rubric caption {
+ text-align: left; font-variant: small-caps; letter-spacing: 0.05em;
+ font-weight: 600; color: var(--q-muted); padding-bottom: 0.35rem;
+}
+.sol-rubric th, .sol-rubric td {
+ border-top: 1px solid var(--sol-rule); padding: 0.4rem 0.55rem; text-align: left;
+ vertical-align: top;
+}
+.sol-rubric th:first-child, .sol-rubric td:first-child {
+ width: 2.5rem; text-align: center; font-weight: 700; color: var(--ok-ink);
+}
+
+.sol-accepted { margin: 0.7rem 0; }
+.sol-ref { margin-top: 0.7rem; font-size: 0.85rem; color: var(--q-muted); }
+
+/* badges */
+.badge {
+ display: inline-block; font-variant: small-caps; letter-spacing: 0.05em;
+ font-size: 0.72rem; font-weight: 700; padding: 0.05em 0.5em;
+ border-radius: 999px; margin-right: 0.35em;
+ background: var(--sol-rule); color: var(--sol-accent);
+}
+.badge-correct { background: var(--ok-bg); color: var(--ok-ink); }
+
+/* ---- The password gate --- */
+.solutions-gate { margin: 1.4rem 0; }
+.gate-inner {
+ display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem;
+ padding: 0.75rem 1rem;
+ border: 1px solid var(--sol-rule); border-left: 3px solid var(--sol-accent);
+ border-radius: 0 5px 5px 0; background: var(--sol-bg);
+}
+.gate-lock {
+ width: 0.8rem; height: 0.7rem; border: 2px solid var(--sol-accent);
+ border-radius: 2px; position: relative; flex: none;
+}
+.gate-lock::before {
+ content: ""; position: absolute; left: 50%; top: -0.42rem; transform: translateX(-50%);
+ width: 0.5rem; height: 0.42rem; border: 2px solid var(--sol-accent);
+ border-bottom: none; border-radius: 4px 4px 0 0;
+}
+.gate-lock.is-open::before { left: 20%; }
+.gate-label { font-variant: small-caps; letter-spacing: 0.05em; font-weight: 600; color: var(--sol-accent); }
+.gate-input {
+ flex: 1 1 12rem; min-width: 9rem;
+ padding: 0.4rem 0.6rem; border: 1px solid var(--sol-rule);
+ border-radius: 4px; background: var(--q-card-bg); color: var(--q-ink);
+}
+.gate-btn {
+ padding: 0.42rem 1rem; border: 1px solid var(--sol-accent); border-radius: 4px;
+ background: var(--sol-accent); color: #fff; font-weight: 600; cursor: pointer;
+}
+.gate-btn:hover { filter: brightness(1.08); }
+.gate-btn:disabled { opacity: 0.6; cursor: progress; }
+.gate-btn-ghost { background: transparent; color: var(--sol-accent); }
+.gate-status { flex-basis: 100%; margin: 0; font-size: 0.85rem; color: var(--q-muted); }
+.solutions-gate.is-error .gate-input { border-color: var(--mis-ink); }
+.solutions-gate.is-error .gate-status { color: var(--mis-ink); }
+.gate-input:focus-visible, .gate-btn:focus-visible { outline: 2px solid var(--sol-accent); outline-offset: 2px; }
+
+@media print {
+ .solutions-gate { display: none; }
+ .qsol[hidden] { display: none; }
+ .q { break-inside: avoid; border-color: #bbb; }
+}
+
+/* ============================ Dark mode ================================= */
+.quarto-dark {
+ --q-ink: #dfe2e7;
+ --q-muted: #a7adb6;
+ --q-line: #333a44;
+ --q-card-bg: #1b1f26;
+
+ --sol-accent: #9aa0e6;
+ --sol-bg: #20222e;
+ --sol-rule: #343755;
+
+ --ok-ink: #9dd3b4;
+ --ok-bg: #1b241f;
+ --mis-ink: #e0a498;
+}
+.quarto-dark .gate-btn { color: #14161c; }
diff --git a/src/assets/site/solutions.js b/src/assets/site/solutions.js
new file mode 100644
index 0000000..40c5cf4
--- /dev/null
+++ b/src/assets/site/solutions.js
@@ -0,0 +1,160 @@
+/* ============================================================================
+ * solutions.js — per-page, password-gated solutions for the course site.
+ *
+ * How a page opts in:
+ * 1. Put one gate element somewhere on the page:
+ *
+ * `data-bundle` is resolved relative to the page URL, so each page points
+ * at its own bundle and therefore has its own password. Nothing else is
+ * shared between pages.
+ * 2. For every gated item, leave an empty, hidden slot where the solution
+ * should appear:
+ *
+ * The id in data-solution-for must match a key in the bundle's `items`.
+ *
+ * What happens on unlock:
+ * The typed password is run through PBKDF2 (same params as the bundle's kdf
+ * block) to derive an AES-256-GCM key. The first item is decrypted as a
+ * probe: because GCM authenticates, a wrong password throws, and we report
+ * "wrong password" without revealing anything. On success every slot is
+ * filled, un-hidden, and MathJax re-typesets the injected math.
+ *
+ * The ciphertext ships in the page, so this stops a student
+ * from reading answers in "View source" or the Network tab, and a wrong
+ * password reveals nothing. Anyone who has the password can decrypt, and the
+ * bundle can be brute-forced offline against a weak password. Use a real,
+ * non-guessable per-page password, and rotate it if a key deadline has passed.
+ * ==========================================================================*/
+
+(() => {
+ "use strict";
+
+ const b64ToBytes = (s) =>
+ Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
+
+ async function deriveKey(password, kdf) {
+ const base = await crypto.subtle.importKey(
+ "raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveKey"]);
+ return crypto.subtle.deriveKey(
+ { name: "PBKDF2", salt: b64ToBytes(kdf.salt),
+ iterations: kdf.iterations, hash: kdf.hash },
+ base, { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
+ }
+
+ async function decryptItem(key, item) {
+ const pt = await crypto.subtle.decrypt(
+ { name: "AES-GCM", iv: b64ToBytes(item.iv) }, key, b64ToBytes(item.ct));
+ return new TextDecoder().decode(pt);
+ }
+
+ function typeset(nodes) {
+ if (window.MathJax && typeof window.MathJax.typesetPromise === "function") {
+ window.MathJax.typesetPromise(nodes).catch(() => { /* leave as-is */ });
+ }
+ }
+
+ function buildGate(gate) {
+ gate.classList.add("is-locked");
+ gate.innerHTML = `
+
+
+
+
+
+
+
`;
+ return {
+ input: gate.querySelector(".gate-input"),
+ button: gate.querySelector(".gate-btn"),
+ status: gate.querySelector(".gate-status"),
+ };
+ }
+
+ async function unlock(gate, ui) {
+ const url = gate.getAttribute("data-bundle");
+ const pw = ui.input.value;
+ if (!pw) { ui.input.focus(); return; }
+
+ gate.classList.remove("is-error");
+ ui.button.disabled = true;
+ ui.status.textContent = "Checking\u2026";
+
+ let bundle;
+ try {
+ const res = await fetch(url, { cache: "no-store" });
+ if (!res.ok) throw new Error(`bundle ${res.status}`);
+ bundle = await res.json();
+ } catch (e) {
+ ui.button.disabled = false;
+ ui.status.textContent = "Could not load the solutions file for this page.";
+ return;
+ }
+
+ let key;
+ try {
+ key = await deriveKey(pw, bundle.kdf);
+ // Probe with the first item so a wrong password fails before we touch DOM.
+ const firstId = Object.keys(bundle.items)[0];
+ await decryptItem(key, bundle.items[firstId]);
+ } catch (e) {
+ gate.classList.add("is-error");
+ ui.button.disabled = false;
+ ui.status.textContent = "That password didn\u2019t work. Try again.";
+ ui.input.select();
+ return;
+ }
+
+ const filled = [];
+ for (const slot of document.querySelectorAll("[data-solution-for]")) {
+ const id = slot.getAttribute("data-solution-for");
+ const item = bundle.items[id];
+ if (!item) continue;
+ try {
+ slot.innerHTML = await decryptItem(key, item);
+ slot.hidden = false;
+ slot.classList.add("is-unlocked");
+ filled.push(slot);
+ } catch (e) { /* skip an item that fails; others still unlock */ }
+ }
+ typeset(filled);
+
+ gate.classList.remove("is-locked");
+ gate.classList.add("is-unlocked");
+ gate.innerHTML = `
+
+
+ Solutions unlocked
+
+
`;
+ gate.querySelector(".gate-btn").addEventListener("click", () => {
+ for (const s of filled) { s.hidden = true; s.classList.remove("is-unlocked"); }
+ buildAndWire(gate); // relock the UI; content stays in memory only
+ });
+ }
+
+ function buildAndWire(gate) {
+ const ui = buildGate(gate);
+ const go = () => unlock(gate, ui);
+ ui.button.addEventListener("click", go);
+ ui.input.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); });
+ }
+
+ function init() {
+ const gate = document.querySelector(".solutions-gate[data-bundle]");
+ if (!gate) return;
+ if (!window.crypto || !crypto.subtle) {
+ gate.textContent =
+ "This browser can\u2019t decrypt solutions (no Web Crypto over http/file).";
+ return;
+ }
+ buildAndWire(gate);
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", init);
+ } else {
+ init();
+ }
+})();
\ No newline at end of file
diff --git a/src/cli.rs b/src/cli.rs
index 9a6a9ab..959779e 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -435,6 +435,30 @@ pub(crate) enum ExportCommand {
#[arg(long)]
no_answer_space: bool,
},
+ /// Write a Quarto questions partial and an encrypted, password-gated
+ /// solutions bundle for a course website.
+ ///
+ /// Writes `_questions.qmd` and `-solutions.json` into the output directory,
+ /// and prints a fresh password that the files do not store.
+ Site {
+ /// Assessment id.
+ id: String,
+ /// Which form's option order to print.
+ #[arg(long, default_value = "A")]
+ form: String,
+ /// Output directory; defaults to build/. Point it at the page's own
+ /// directory so the browser fetches the bundle beside the page.
+ #[arg(long)]
+ out: Option,
+ /// Encrypt with this password instead of a generated one. Use only to
+ /// re-encrypt a page with a known password.
+ #[arg(long)]
+ password: Option,
+ /// Also write questions.css and solutions.js into this directory, e.g.
+ /// the site's static assets folder. They install once, not per page.
+ #[arg(long, value_name = "DIR")]
+ assets: Option,
+ },
}
#[derive(Debug, Subcommand)]
diff --git a/src/commands/export.rs b/src/commands/export.rs
index ed6eb69..41fd546 100644
--- a/src/commands/export.rs
+++ b/src/commands/export.rs
@@ -195,6 +195,55 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result {
}
Ok(Outcome::Ok)
}
+ ExportCommand::Site {
+ id,
+ form,
+ out,
+ password,
+ assets,
+ } => {
+ {
+ use coursebank::site;
+ let record = load_record(&catalog, id)?;
+ let form = pick_form(&record, form)?;
+ let dir = out.clone().unwrap_or(build);
+ std::fs::create_dir_all(&dir)?;
+
+ let rendered = site::render(
+ &catalog,
+ &record,
+ site::Options {
+ form,
+ password: password.clone(),
+ },
+ )?;
+
+ let qmd = dir.join("_questions.qmd");
+ yaml::write_text(&qmd, &rendered.questions_qmd)?;
+ let json = dir.join(format!("{}-solutions.json", rendered.page));
+ yaml::write_text(&json, &rendered.solutions_json)?;
+
+ if let Some(asset_dir) = assets {
+ std::fs::create_dir_all(asset_dir)?;
+ for (name, body) in site::assets() {
+ let path = asset_dir.join(name);
+ yaml::write_text(&path, body)?;
+ if !cli.quiet {
+ println!("wrote {}", path.display());
+ }
+ }
+ }
+
+ // The password cannot be recovered from the files; always print it.
+ println!("password for {}: {}", rendered.page, rendered.password);
+ if !cli.quiet {
+ println!("wrote {}", qmd.display());
+ println!("wrote {}", json.display());
+ println!("\nInclude in the page with: {{{{< include _questions.qmd >}}}}");
+ }
+ Ok(Outcome::Ok)
+ }
+ }
}
}
diff --git a/src/data.rs b/src/data.rs
index db5b7f2..1fb0417 100644
--- a/src/data.rs
+++ b/src/data.rs
@@ -29,5 +29,4 @@ pub mod canvas;
pub mod gradescope;
pub mod responses;
pub mod store;
-#[cfg(feature = "parquet")]
pub mod store_parquet;
diff --git a/src/data/store.rs b/src/data/store.rs
index f843193..51cb41d 100644
--- a/src/data/store.rs
+++ b/src/data/store.rs
@@ -49,21 +49,14 @@ impl Format {
///
/// Parquet when the `parquet` feature is on, CSV otherwise.
pub fn preferred() -> Format {
- #[cfg(feature = "parquet")]
- {
- Format::Parquet
- }
- #[cfg(not(feature = "parquet"))]
- {
- Format::Csv
- }
+ Format::Parquet
}
/// Whether this format can be written by the current build.
pub fn is_available(self) -> bool {
match self {
Format::Csv => true,
- Format::Parquet => cfg!(feature = "parquet"),
+ Format::Parquet => true,
}
}
@@ -386,21 +379,10 @@ fn read_csv(path: &Path) -> Result {
///
/// * `path` - the destination.
/// * `rows` - the rows.
-///
-/// # Errors
-///
-/// Returns [`Error::FeatureDisabled`] when the feature is off.
-#[cfg(feature = "parquet")]
fn write_parquet(path: &Path, rows: &[FlatResponse]) -> Result<()> {
crate::store_parquet::write(path, rows)
}
-/// Stub for builds without Parquet support.
-#[cfg(not(feature = "parquet"))]
-fn write_parquet(_path: &Path, _rows: &[FlatResponse]) -> Result<()> {
- Err(Error::FeatureDisabled("Parquet", "parquet"))
-}
-
/// Reads flat responses from Parquet.
///
/// # Arguments
@@ -414,7 +396,6 @@ fn write_parquet(_path: &Path, _rows: &[FlatResponse]) -> Result<()> {
/// # Errors
///
/// Returns [`Error::FeatureDisabled`] when the feature is off.
-#[cfg(feature = "parquet")]
fn read_parquet(path: &Path) -> Result {
let rows = crate::store_parquet::read(path)?;
let mut set = ResponseSet::new();
@@ -422,16 +403,6 @@ fn read_parquet(path: &Path) -> Result {
Ok(set)
}
-/// Stub for builds without Parquet support.
-#[cfg(not(feature = "parquet"))]
-fn read_parquet(path: &Path) -> Result {
- Err(Error::Other(format!(
- "{} is a Parquet file, but this build has Parquet support compiled out; \
- rebuild with `--features parquet`, or re-ingest with `--format csv`",
- path.display()
- )))
-}
-
/// Makes an administration id usable as a file name.
///
/// # Arguments
diff --git a/src/export.rs b/src/export.rs
index c799049..c8bd215 100644
--- a/src/export.rs
+++ b/src/export.rs
@@ -9,6 +9,7 @@
//! | [`qti`] | a QTI 1.2 zip | importing into Canvas |
//! | [`typst`] | `.typ` source | a printed exam, answer key, and bubble sheet |
//! | [`practice`] | Quarto Markdown | a worksheet and a solutions document, off Canvas |
+//! | [`site`] | a Quarto partial and an encrypted bundle | a course page with password-gated solutions |
//! | [`report`] | Markdown and HTML | students, and yourself |
//! | [`lecture`] | Markdown | the reading list on the course website |
//!
@@ -26,4 +27,5 @@ pub mod lecture;
pub mod practice;
pub mod qti;
pub mod report;
+pub mod site;
pub mod typst;
diff --git a/src/export/qti.rs b/src/export/qti.rs
index 4239c2c..c5e6251 100644
--- a/src/export/qti.rs
+++ b/src/export/qti.rs
@@ -47,9 +47,7 @@ const IMSMD_NS: &str = "http://www.imsglobal.org/xsd/imsmd_v1p2";
const IMSCP_SCHEMA: &str = "http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd \
http://www.imsglobal.org/xsd/imsmd_v1p2 imsmd_v1p2p2.xsd";
-// ---
// A very small XML tree
-// ---
/// One XML element.
#[derive(Debug, Clone)]
@@ -155,9 +153,7 @@ fn escape_attr(s: &str) -> String {
escape_text(s).replace('"', """)
}
-// ---
// Package construction
-// ---
/// Options for a QTI export.
#[derive(Debug, Clone)]
diff --git a/src/export/site.rs b/src/export/site.rs
new file mode 100644
index 0000000..53639b6
--- /dev/null
+++ b/src/export/site.rs
@@ -0,0 +1,976 @@
+// SPDX-License-Identifier: Prosperity-3.0.0
+// Copyright Scientific Computing Studio
+// Source: https://git.scient.ing/education/coursebank
+
+//! Rendering an assessment for a Quarto course website, with solutions gated
+//! behind a per-page password.
+//!
+//! This is the path that publishes to the web rather than to Canvas or a printed
+//! exam. It produces three things for one assessment:
+//!
+//! 1. A `_questions.qmd` partial: the questions as Quarto fenced divs, with an
+//! empty, hidden `.qsol` slot per item. A page includes it with
+//! `{{< include _questions.qmd >}}`. It carries no answers.
+//! 2. A `-solutions.json` bundle: every solution rendered to an HTML fragment,
+//! then encrypted. The ciphertext ships in the page, but the plaintext never
+//! does, so a student cannot read answers from the source or the network tab.
+//! 3. A password: a fresh, random, per-bundle password that decrypts the bundle.
+//! It is printed for the instructor and stored nowhere, so it cannot be
+//! recovered from the files. Hand it out, and rotate it after a due date.
+//!
+//! The browser side is [`assets`]: `questions.css` styles the questions and the
+//! unlocked solutions, and `solutions.js` derives the key from the typed password,
+//! decrypts, and injects each fragment. The crypto here matches that script byte
+//! for byte: PBKDF2-HMAC-SHA256 at 250,000 iterations derives a 256-bit key, and
+//! AES-256-GCM encrypts each fragment under a fresh 96-bit IV with the 128-bit tag
+//! appended to the ciphertext. Get any parameter wrong and a correct password
+//! would fail to authenticate.
+//!
+//! Two rules carry over from the other exporters. A question paper never contains
+//! the answer: the `.qsol` slot is empty in the qmd and the answer lives only in
+//! the encrypted bundle. And option order comes from the form's seed, so the
+//! printed letters in the questions and the letters the solution refers to are the
+//! same order.
+
+use crate::assessment::{AssessmentFile, Form, Placement};
+use crate::catalog::Catalog;
+use crate::course::{CourseFile, Reference};
+use crate::error::{Error, Result};
+use crate::item::{Choice, Citation, Item, Solution};
+use crate::markup;
+use crate::select;
+use crate::taxonomy::Format;
+
+use aes_gcm::Aes256Gcm;
+use aes_gcm::aead::generic_array::GenericArray;
+use aes_gcm::aead::{Aead, KeyInit};
+use base64::Engine as _;
+use base64::engine::general_purpose::STANDARD as B64;
+use serde::ser::SerializeMap;
+use serde::{Serialize, Serializer};
+
+/// PBKDF2 iteration count. Must match `solutions.js` and `make_solutions.py`.
+const ITERATIONS: u32 = 250_000;
+
+/// Crockford base32 without the ambiguous `i`, `l`, `o`, `u`. Thirty-two symbols
+/// means five bits each, so sixteen of them carry eighty bits of entropy.
+const PW_ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
+
+/// The bundled browser assets, installed once per site (not per page).
+///
+/// # Returns
+///
+/// Pairs of file name and verbatim contents: the stylesheet and the unlock
+/// script. Write them into the site's static directory and wire them in
+/// `_quarto.yml`.
+pub fn assets() -> [(&'static str, &'static str); 2] {
+ [
+ (
+ "questions.css",
+ include_str!("../assets/site/questions.css"),
+ ),
+ ("solutions.js", include_str!("../assets/site/solutions.js")),
+ ]
+}
+
+/// How to render one assessment for the site.
+#[derive(Debug, Clone)]
+pub struct Options {
+ /// The form whose option order to print.
+ pub form: Form,
+ /// A password to encrypt with. `None` generates a fresh random one, which is
+ /// the intended path; pass `Some` only to re-encrypt a bundle with a known
+ /// password.
+ pub password: Option,
+}
+
+impl Options {
+ /// Builds options for a form, generating the password.
+ ///
+ /// # Arguments
+ ///
+ /// * `form` - the form whose option order to print.
+ ///
+ /// # Returns
+ ///
+ /// Options that will generate a fresh password at render time.
+ pub fn new(form: Form) -> Options {
+ Options {
+ form,
+ password: None,
+ }
+ }
+}
+
+/// Everything one render produces, ready to write.
+#[derive(Debug, Clone)]
+pub struct Rendered {
+ /// The assessment id, used for the bundle file name and the gate's
+ /// `data-bundle` attribute.
+ pub page: String,
+ /// The `_questions.qmd` partial.
+ pub questions_qmd: String,
+ /// The `-solutions.json` bundle, serialized and newline-terminated.
+ pub solutions_json: String,
+ /// The password that decrypts the bundle. Print it; it is stored nowhere.
+ pub password: String,
+}
+
+/// Renders an assessment into the questions partial and the encrypted bundle.
+///
+/// # Arguments
+///
+/// * `catalog` - the loaded course, for items, objectives, and references.
+/// * `record` - the assembled assessment.
+/// * `opts` - the form and an optional password.
+///
+/// # Returns
+///
+/// The partial, the serialized bundle, and the password.
+///
+/// # Errors
+///
+/// Returns [`Error::Unresolved`] when a placement names an item the catalog does
+/// not hold, and [`Error::Other`] on a CSPRNG, encryption, or serialization
+/// failure.
+pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: Options) -> Result {
+ let page = record.assessment.id.clone();
+ let questions_qmd = questions_qmd(catalog, record, &opts.form, &page)?;
+ let fragments = solution_fragments(catalog, record, &opts.form)?;
+ let password = match opts.password {
+ Some(p) => p,
+ None => gen_password()?,
+ };
+ let bundle = build_bundle(&page, &password, &fragments)?;
+ let json = serde_json::to_string_pretty(&bundle)
+ .map_err(|e| Error::other(format!("could not serialize the solutions bundle: {e}")))?;
+ Ok(Rendered {
+ page,
+ questions_qmd,
+ solutions_json: format!("{json}\n"),
+ password,
+ })
+}
+
+/// Builds the `_questions.qmd` partial.
+fn questions_qmd(
+ catalog: &Catalog,
+ record: &AssessmentFile,
+ form: &Form,
+ page: &str,
+) -> Result {
+ let mut out = String::new();
+ out.push_str(
+ "\n\n",
+ );
+ out.push_str(&format!(
+ "::: {{.solutions-gate data-bundle=\"{page}-solutions.json\"}}\n:::\n\n"
+ ));
+
+ let mut number = 0usize;
+ let default_points = catalog.course.policy.points_per_item;
+ let layout = select::layout(record, form);
+ for placement in layout.iter().filter(|p| !p.dropped) {
+ let item = &catalog.require(&placement.item)?.item;
+ number += 1;
+ out.push_str(&question_block(
+ number,
+ placement,
+ item,
+ form,
+ default_points,
+ ));
+ out.push('\n');
+ }
+
+ // Leave exactly one trailing newline.
+ while out.ends_with("\n\n") {
+ out.pop();
+ }
+ Ok(out)
+}
+
+/// One `.q` block: head, stem, choices or a writing box, then the empty slot.
+fn question_block(
+ number: usize,
+ placement: &Placement,
+ item: &Item,
+ form: &Form,
+ default_points: f64,
+) -> String {
+ let id = &item.id;
+ let points = placement
+ .points
+ .unwrap_or_else(|| item.points(default_points));
+ let unit = if (points - 1.0).abs() < f64::EPSILON {
+ "point"
+ } else {
+ "points"
+ };
+
+ let mut b = String::new();
+ b.push_str(&format!("::: {{.q #{id}}}\n"));
+ b.push_str(":::: {.q-head}\n");
+ b.push_str(&format!(
+ "[Question {number}]{{.q-num}} [{}]{{.q-kind}} [{} {unit}]{{.q-points}}\n",
+ kind_label(item.format),
+ trim_number(points),
+ ));
+ b.push_str("::::\n\n");
+
+ b.push_str(":::: {.q-stem}\n");
+ b.push_str(&markup::to_markdown(&item.stem));
+ b.push_str("\n::::\n\n");
+
+ if item.has_options() {
+ b.push_str(":::: {.q-choices}\n");
+ let order = select::option_order(form, &placement.item, item.options.len());
+ for (position, &source) in order.iter().enumerate() {
+ b.push_str(&format!(
+ "{}. {}\n",
+ position + 1,
+ markup::to_markdown(&item.options[source].text)
+ ));
+ }
+ b.push_str("::::\n\n");
+ } else {
+ b.push_str(":::: {.q-response aria-hidden=\"true\"}\n::::\n\n");
+ }
+
+ b.push_str(&format!(
+ ":::: {{.qsol data-solution-for=\"{id}\" hidden=\"true\"}}\n::::\n"
+ ));
+ b.push_str(":::\n");
+ b
+}
+
+/// The human label for a format, shown in the question head.
+fn kind_label(format: Format) -> &'static str {
+ match format {
+ Format::SingleBestAnswer => "Single best answer",
+ Format::MultipleResponse => "Multiple response",
+ Format::TrueFalse => "True or false",
+ Format::OpenResponse => "Open response",
+ }
+}
+
+// --- the solution fragments ---
+
+/// Renders each item's solution to an HTML fragment, in printed order.
+///
+/// An item with nothing to show (an open-response question whose solution is
+/// empty) is left out, so its slot simply never unlocks.
+fn solution_fragments(
+ catalog: &Catalog,
+ record: &AssessmentFile,
+ form: &Form,
+) -> Result> {
+ let mut out = Vec::new();
+ let layout = select::layout(record, form);
+ for placement in layout.iter().filter(|p| !p.dropped) {
+ let item = &catalog.require(&placement.item)?.item;
+ if let Some(html) = fragment(&catalog.course, placement, item, form) {
+ out.push((item.id.clone(), html));
+ }
+ }
+ Ok(out)
+}
+
+/// The fragment for one item, or `None` when there is nothing to show.
+fn fragment(
+ course: &CourseFile,
+ placement: &Placement,
+ item: &Item,
+ form: &Form,
+) -> Option {
+ if item.has_options() {
+ Some(choice_fragment(course, placement, item, form))
+ } else {
+ open_fragment(course, item)
+ }
+}
+
+/// A single-best-answer or multiple-response fragment: the key, the model answer,
+/// the explanation, then per-distractor feedback.
+fn choice_fragment(course: &CourseFile, placement: &Placement, item: &Item, form: &Form) -> String {
+ let order = select::option_order(form, &placement.item, item.options.len());
+ let printed: Vec<(usize, &Choice)> = order
+ .iter()
+ .enumerate()
+ .map(|(position, &source)| (position, &item.options[source]))
+ .collect();
+
+ let mut out = String::new();
+
+ let keyed: Vec = printed
+ .iter()
+ .filter(|(_, c)| c.correct)
+ .map(|(position, c)| {
+ format!(
+ "{} — {}",
+ letter(*position),
+ inline_html(&c.text)
+ )
+ })
+ .collect();
+ out.push_str(
+ "
\n");
+ }
+
+ if !solution.accepted.is_empty() {
+ let joined = solution
+ .accepted
+ .iter()
+ .map(|a| inline_html(a))
+ .collect::>()
+ .join("; ");
+ out.push_str(&format!(
+ "
Also accepted {joined}
\n"
+ ));
+ }
+
+ push_reference(&mut out, course, Some(solution));
+ Some(out)
+}
+
+/// The student-facing reason a distractor is wrong: the instructor explanation
+/// first, then any student feedback.
+fn why_wrong(choice: &Choice) -> Option<&str> {
+ choice
+ .explanation
+ .as_deref()
+ .or(choice.feedback_student.as_deref())
+}
+
+/// Appends the `Source:` line when the solution carries review citations.
+fn push_reference(out: &mut String, course: &CourseFile, solution: Option<&Solution>) {
+ let Some(solution) = solution else { return };
+ if solution.review.is_empty() {
+ return;
+ }
+ let sources = solution
+ .review
+ .iter()
+ .map(|c| cite_html(course, c))
+ .collect::>()
+ .join("; ");
+ out.push_str(&format!("
Source: {sources}
\n"));
+}
+
+/// Renders one citation to HTML, linking it when a URL resolves.
+fn cite_html(course: &CourseFile, citation: &Citation) -> String {
+ if let Some(text) = &citation.text {
+ if citation.reference.is_none() {
+ return markup::escape_html(text);
+ }
+ }
+ let Some(key) = &citation.reference else {
+ return markup::escape_html(&citation.display());
+ };
+ let Some(reference) = course.references.get(key) else {
+ return markup::escape_html(&citation.display());
+ };
+ let label = reference.label.as_deref().unwrap_or(key);
+ let locator = citation.locator.as_deref().unwrap_or("");
+ let body = if locator.is_empty() {
+ markup::escape_html(label)
+ } else {
+ format!(
+ "{} {}",
+ markup::escape_html(label),
+ markup::escape_html(locator)
+ )
+ };
+ match resolve_url(citation, reference) {
+ Some(url) => format!("{body}", markup::escape_html(&url)),
+ None => body,
+ }
+}
+
+/// Resolves a citation's link, from an explicit URL or a path joined to the
+/// reference's base URL.
+fn resolve_url(citation: &Citation, reference: &Reference) -> Option {
+ if let Some(url) = &citation.url {
+ return Some(url.clone());
+ }
+ let path = citation.path.as_deref()?;
+ let base = reference.base_url.as_deref()?;
+ Some(match (base.ends_with('/'), path.starts_with('/')) {
+ (true, true) => format!("{base}{}", &path[1..]),
+ (false, false) => format!("{base}/{path}"),
+ _ => format!("{base}{path}"),
+ })
+}
+
+// --- math-aware markup ---
+
+/// One run of source text, split on math delimiters.
+enum Segment<'a> {
+ /// Prose, formatted with the shared escape, symbol, and inline rules.
+ Text(&'a str),
+ /// A math span, passed through with its interior escaped for the browser.
+ Math { inner: &'a str, display: bool },
+}
+
+/// Splits source into prose and `$…$` or `$$…$$` math runs.
+///
+/// The split runs on raw source so a formatting rule can never reach inside math:
+/// an underscore in `$q_p$` is a subscript, not the start of an emphasis span.
+fn split_math(src: &str) -> Vec> {
+ let mut segments = Vec::new();
+ let mut rest = src;
+ while let Some(at) = rest.find('$') {
+ if at > 0 {
+ segments.push(Segment::Text(&rest[..at]));
+ }
+ let after = &rest[at..];
+ if let Some(display) = after.strip_prefix("$$") {
+ if let Some(end) = display.find("$$") {
+ segments.push(Segment::Math {
+ inner: &display[..end],
+ display: true,
+ });
+ rest = &display[end + 2..];
+ continue;
+ }
+ }
+ let inline = &after[1..];
+ match inline.find('$') {
+ Some(end) => {
+ segments.push(Segment::Math {
+ inner: &inline[..end],
+ display: false,
+ });
+ rest = &inline[end + 1..];
+ }
+ None => {
+ // An unterminated `$` is treated as ordinary text.
+ segments.push(Segment::Text(after));
+ rest = "";
+ }
+ }
+ }
+ if !rest.is_empty() {
+ segments.push(Segment::Text(rest));
+ }
+ segments
+}
+
+/// Formats a prose run: HTML-escape, then the symbol table and inline markup.
+fn format_prose(text: &str) -> String {
+ markup::apply_inline(&markup::apply_symbols(&markup::escape_html(text), true))
+}
+
+/// Emits a math run with its delimiters, escaping the interior so the browser
+/// hands MathJax clean text (a `<` inside math becomes `<`, which the DOM
+/// decodes back before MathJax reads it).
+fn render_math(inner: &str, display: bool) -> String {
+ let delim = if display { "$$" } else { "$" };
+ format!("{delim}{}{delim}", markup::escape_html(inner))
+}
+
+/// Converts authoring markup to an inline HTML string, preserving math.
+///
+/// No paragraph wrapping: the caller supplies the surrounding element.
+fn inline_html(src: &str) -> String {
+ let mut out = String::new();
+ for segment in split_math(src.trim()) {
+ match segment {
+ Segment::Text(text) => out.push_str(&format_prose(text)),
+ Segment::Math { inner, display } => out.push_str(&render_math(inner, display)),
+ }
+ }
+ out
+}
+
+/// Like [`inline_html`], but wraps each blank-line-separated paragraph in `
", inline_html(p)))
+ .collect::>()
+ .join("")
+}
+
+// --- the encrypted bundle -----
+
+/// The encrypted solutions bundle, matching the `solutions.js` v1 format.
+#[derive(Debug, Clone, Serialize)]
+pub struct Bundle {
+ v: u8,
+ page: String,
+ kdf: Kdf,
+ cipher: &'static str,
+ items: OrderedItems,
+}
+
+#[derive(Debug, Clone, Serialize)]
+struct Kdf {
+ name: &'static str,
+ hash: &'static str,
+ iterations: u32,
+ salt: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
+struct Enc {
+ iv: String,
+ ct: String,
+}
+
+/// Item entries serialized as a JSON object in insertion order, so the bundle
+/// lists solutions in the order the questions appear rather than sorted by id.
+#[derive(Debug, Clone)]
+struct OrderedItems(Vec<(String, Enc)>);
+
+impl Serialize for OrderedItems {
+ fn serialize(&self, serializer: S) -> std::result::Result {
+ let mut map = serializer.serialize_map(Some(self.0.len()))?;
+ for (id, enc) in &self.0 {
+ map.serialize_entry(id, enc)?;
+ }
+ map.end()
+ }
+}
+
+/// Generates a random per-bundle password.
+///
+/// Sixteen Crockford base32 symbols, grouped in fours, for eighty bits of entropy
+/// from the system CSPRNG, for example `k7m4-9p2q-r8tx-3wn6`.
+///
+/// # Returns
+///
+/// The password, to print for the instructor.
+///
+/// # Errors
+///
+/// Returns [`Error::Other`] if the system CSPRNG is unavailable.
+pub fn gen_password() -> Result {
+ let mut raw = [0u8; 16];
+ csprng(&mut raw)?;
+ // Thirty-two divides 256, so the modulo is unbiased.
+ let symbols: Vec = raw
+ .iter()
+ .map(|b| PW_ALPHABET[(*b % 32) as usize])
+ .collect();
+ let groups: Vec = symbols
+ .chunks(4)
+ .map(|c| String::from_utf8_lossy(c).into_owned())
+ .collect();
+ Ok(groups.join("-"))
+}
+
+/// Encrypts rendered fragments into a bundle.
+///
+/// # Arguments
+///
+/// * `page` - the assessment id, stored as `page` and echoed by the script.
+/// * `password` - the password to derive the key from.
+/// * `fragments` - item id and solution HTML, in the order to list them.
+///
+/// # Returns
+///
+/// The bundle, ready to serialize as `-solutions.json`.
+///
+/// # Errors
+///
+/// Returns [`Error::Other`] on a CSPRNG or encryption failure.
+pub fn build_bundle(page: &str, password: &str, fragments: &[(String, String)]) -> Result {
+ let mut salt = [0u8; 16];
+ csprng(&mut salt)?;
+ let key = derive_key(password, &salt);
+ let cipher = Aes256Gcm::new_from_slice(&key)
+ .map_err(|_| Error::other("the derived AES key was the wrong length"))?;
+
+ let mut items = Vec::with_capacity(fragments.len());
+ for (id, html) in fragments {
+ let mut iv = [0u8; 12];
+ csprng(&mut iv)?;
+ let nonce = GenericArray::from_slice(&iv);
+ let ct = cipher
+ .encrypt(nonce, html.as_bytes())
+ .map_err(|_| Error::other("AES-GCM encryption failed"))?;
+ items.push((
+ id.clone(),
+ Enc {
+ iv: B64.encode(iv),
+ ct: B64.encode(ct),
+ },
+ ));
+ }
+
+ Ok(Bundle {
+ v: 1,
+ page: page.to_string(),
+ kdf: Kdf {
+ name: "PBKDF2",
+ hash: "SHA-256",
+ iterations: ITERATIONS,
+ salt: B64.encode(salt),
+ },
+ cipher: "AES-GCM",
+ items: OrderedItems(items),
+ })
+}
+
+/// Derives the AES-256 key with PBKDF2-HMAC-SHA256.
+fn derive_key(password: &str, salt: &[u8]) -> [u8; 32] {
+ let mut key = [0u8; 32];
+ pbkdf2::pbkdf2_hmac::(password.as_bytes(), salt, ITERATIONS, &mut key);
+ key
+}
+
+/// Fills a buffer with cryptographically secure random bytes.
+fn csprng(buf: &mut [u8]) -> Result<()> {
+ getrandom::getrandom(buf).map_err(|e| Error::other(format!("system CSPRNG unavailable: {e}")))
+}
+
+// --- small helpers -------
+
+/// Formats a point value: no decimal when whole, at most two places otherwise.
+fn trim_number(value: f64) -> String {
+ if value.fract() == 0.0 {
+ format!("{}", value as i64)
+ } else {
+ let s = format!("{value:.2}");
+ s.trim_end_matches('0').trim_end_matches('.').to_string()
+ }
+}
+
+/// The printed letter for a position: 0 is A, 1 is B, and so on.
+fn letter(position: usize) -> char {
+ char::from(b'A' + (position % 26) as u8)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn form() -> Form {
+ Form {
+ id: "A".into(),
+ seed: 0,
+ shuffle_items: false,
+ shuffle_options: false,
+ }
+ }
+
+ fn catalog(tag: &str) -> Catalog {
+ let dir = std::env::temp_dir().join(format!("cb-site-{tag}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(dir.join("banks")).unwrap();
+ std::fs::write(
+ dir.join("course.yaml"),
+ r#"
+course: { code: BIOSC 1000, title: Biochemistry, term: 2026f }
+references:
+ kkw:
+ label: KKW
+ title: The molecules of life
+ base_url: https://example.org/kkw/
+lectures:
+ L1.1: { title: Enthalpy }
+learning_objectives:
+ lo-enthalpy:
+ text: Define enthalpy and explain the constant-pressure result.
+ lectures: [L1.1]
+ order: 1
+"#,
+ )
+ .unwrap();
+ std::fs::write(
+ dir.join("banks").join("b.yaml"),
+ r#"
+bank: { id: b, title: Bank }
+items:
+ - id: q-mcq
+ status: draft
+ level: 2
+ format: single_best_answer
+ stem: "The heat at constant pressure equals a change in what?"
+ learning_objectives: [lo-enthalpy]
+ options:
+ - { id: A, text: "Enthalpy, $\\Delta H$", correct: true, feedback_student: "Right, $q_p = \\Delta H$." }
+ - { id: B, text: "Internal energy, $\\Delta U$", misconception: "Uses the constant-volume result.", explanation: "That holds only at constant volume." }
+ solution:
+ model_answer: "The change in enthalpy, $\\Delta H$."
+ review:
+ - { ref: kkw, locator: "§6.3" }
+ - id: q-open
+ status: draft
+ level: 3
+ format: open_response
+ stem: "Show why $q_p = \\Delta H$."
+ learning_objectives: [lo-enthalpy]
+ solution:
+ model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$."
+ rubric:
+ - { description: "States $H = U + PV$.", points: 1 }
+ - { description: "Reaches $q_p = \\Delta H$.", points: 1 }
+ accepted: ["$q_p = \\Delta H$ via $H = U + PV$"]
+"#,
+ )
+ .unwrap();
+ Catalog::load(&dir).expect("catalog loads")
+ }
+
+ fn record() -> AssessmentFile {
+ use crate::assessment::{Assessment, Kind, Platform};
+ AssessmentFile {
+ schema_version: "1.0".into(),
+ assessment: Assessment {
+ id: "a1.1".into(),
+ title: "Homework 1".into(),
+ term: None,
+ kind: Kind::Homework,
+ date: None,
+ platform: Platform::Other,
+ minutes_allowed: None,
+ attempts: None,
+ shuffle: None,
+ scoring_policy: None,
+ instructions: None,
+ notes: None,
+ },
+ blueprint: None,
+ forms: Vec::new(),
+ items: vec![
+ Placement {
+ number: 1,
+ item: "b::q-mcq".into(),
+ version: None,
+ fingerprint: None,
+ points: Some(1.0),
+ bonus: false,
+ key: vec!["A".into()],
+ level: None,
+ learning_objectives: Vec::new(),
+ credit_overrides: Default::default(),
+ dropped: false,
+ },
+ Placement {
+ number: 2,
+ item: "b::q-open".into(),
+ version: None,
+ fingerprint: None,
+ points: Some(2.0),
+ bonus: false,
+ key: Vec::new(),
+ level: None,
+ learning_objectives: Vec::new(),
+ credit_overrides: Default::default(),
+ dropped: false,
+ },
+ ],
+ }
+ }
+
+ #[test]
+ fn questions_partial_has_no_front_matter_and_no_answers() {
+ let out = questions_qmd(&catalog("qmd"), &record(), &form(), "a1.1").unwrap();
+ assert!(!out.starts_with("---"), "a partial carries no front matter");
+ assert!(out.contains("::: {.solutions-gate data-bundle=\"a1.1-solutions.json\"}"));
+ assert!(out.contains("::: {.q #q-mcq}"));
+ assert!(
+ out.contains("[Question 1]{.q-num} [Single best answer]{.q-kind} [1 point]{.q-points}")
+ );
+ // Choices are printed without letters; the correct flag never appears.
+ assert!(out.contains("1. Enthalpy, $\\Delta H$"));
+ assert!(!out.contains("correct"));
+ // The open-response question gets a writing box, both get an empty slot.
+ assert!(out.contains(":::: {.q-response aria-hidden=\"true\"}"));
+ assert!(out.contains(":::: {.qsol data-solution-for=\"q-mcq\" hidden=\"true\"}"));
+ assert!(out.contains("[2 points]{.q-points}"));
+ // No model answer leaks into the questions.
+ assert!(!out.contains("sol-model"));
+ }
+
+ #[test]
+ fn a_choice_fragment_marks_the_key_and_explains_the_distractor() {
+ let cat = catalog("choice");
+ let rec = record();
+ let frags = solution_fragments(&cat, &rec, &form()).unwrap();
+ let mcq = &frags.iter().find(|(id, _)| id == "q-mcq").unwrap().1;
+ assert!(mcq.contains("Correct answer"));
+ assert!(mcq.contains("A — Enthalpy, $\\Delta H$"));
+ assert!(mcq.contains(
+ "
The change in enthalpy, $\\Delta H$.
"
+ ));
+ assert!(mcq.contains("B"));
+ assert!(mcq.contains("Uses the constant-volume result."));
+ assert!(mcq.contains("That holds only at constant volume."));
+ assert!(mcq.contains("
Source: KKW §6.3
"));
+ }
+
+ #[test]
+ fn an_open_fragment_has_a_rubric_table_with_a_total() {
+ let cat = catalog("open");
+ let rec = record();
+ let frags = solution_fragments(&cat, &rec, &form()).unwrap();
+ let open = &frags.iter().find(|(id, _)| id == "q-open").unwrap().1;
+ assert!(open.contains("
Rubric — 2 points
"));
+ assert!(open.contains("
1
States $H = U + PV$.
"));
+ assert!(open.contains("Also accepted"));
+ }
+
+ #[test]
+ fn inline_html_keeps_math_verbatim_and_escapes_prose() {
+ // A subscript inside math survives; angle brackets outside math are escaped.
+ assert_eq!(inline_html("value $q_p$ < 5"), "value $q_p$ < 5");
+ // Bold outside math becomes a tag; a dollar-math run is passed through.
+ assert_eq!(
+ inline_html("**H** is $H = U + PV$"),
+ "H is $H = U + PV$"
+ );
+ }
+
+ #[test]
+ fn the_bundle_round_trips_through_the_kdf_and_cipher() {
+ use aes_gcm::aead::generic_array::GenericArray;
+ let fragments = vec![
+ ("q-mcq".to_string(), "
alpha
".to_string()),
+ ("q-open".to_string(), "
beta
".to_string()),
+ ];
+ let bundle = build_bundle("a1.1", "enthalpy2026", &fragments).unwrap();
+ assert_eq!(bundle.v, 1);
+ assert_eq!(bundle.page, "a1.1");
+ assert_eq!(bundle.cipher, "AES-GCM");
+ assert_eq!(bundle.kdf.iterations, ITERATIONS);
+ // Insertion order is preserved in the serialized object.
+ let json = serde_json::to_string(&bundle).unwrap();
+ assert!(json.find("q-mcq").unwrap() < json.find("q-open").unwrap());
+
+ // Decrypt the first item the way the browser would and check the plaintext.
+ let salt = B64.decode(&bundle.kdf.salt).unwrap();
+ let key = derive_key("enthalpy2026", &salt);
+ let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
+ let (_, enc) = &bundle.items.0[0];
+ let iv = B64.decode(&enc.iv).unwrap();
+ let ct = B64.decode(&enc.ct).unwrap();
+ let pt = cipher
+ .decrypt(GenericArray::from_slice(&iv), ct.as_ref())
+ .unwrap();
+ assert_eq!(String::from_utf8(pt).unwrap(), "
alpha
");
+ }
+
+ #[test]
+ fn a_generated_password_has_the_expected_shape() {
+ let pw = gen_password().unwrap();
+ assert_eq!(pw.len(), 19); // 16 symbols + 3 dashes
+ assert_eq!(pw.matches('-').count(), 3);
+ assert!(
+ pw.chars()
+ .all(|c| c == '-' || PW_ALPHABET.contains(&(c as u8)))
+ );
+ }
+
+ #[test]
+ fn the_two_browser_assets_are_bundled() {
+ let assets = assets();
+ assert_eq!(assets[0].0, "questions.css");
+ assert_eq!(assets[1].0, "solutions.js");
+ assert!(assets[0].1.contains(".qsol"));
+ assert!(assets[1].1.contains("AES-GCM"));
+ }
+}
diff --git a/src/guide.rs b/src/guide.rs
index b0b99a8..490a0aa 100644
--- a/src/guide.rs
+++ b/src/guide.rs
@@ -21,7 +21,9 @@
//! 3. [`first_exam`] runs one exam end to end: assemble, export, administer,
//! ingest, analyze, report.
//! 4. [`typst_export`] covers printed output, template markers, and render config.
-//! 5. [`recipes`] holds short answers to specific questions, for when you already
+//! 5. [`assignment`] publishes a homework to the course website, with solutions
+//! gated behind a password.
+//! 6. [`recipes`] holds short answers to specific questions, for when you already
//! know the shape of the tool.
//!
//! ## Why the tutorials are in here rather than a wiki
@@ -55,6 +57,10 @@ pub mod first_exam {}
#[doc = include_str!("../docs/TYPST.md")]
pub mod typst_export {}
+/// Publishing an assignment to the website, with solutions gated behind a password.
+#[doc = include_str!("../docs/guide/assignment.md")]
+pub mod assignment {}
+
/// Short answers to specific questions.
#[doc = include_str!("../docs/guide/recipes.md")]
pub mod recipes {}
diff --git a/src/lib.rs b/src/lib.rs
index 766de78..4782d67 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -104,12 +104,12 @@ pub use model::{assessment, bank, catalog, course, history, item, layout, taxono
pub use authoring::{jsonschema, lint, select};
-#[cfg(feature = "parquet")]
pub use data::store_parquet;
pub use data::{canvas, gradescope, responses, store};
pub use analysis::{calibrate, classical, irt, students};
+pub use export::site;
pub use export::{lecture, practice, qti, report, typst};
pub use catalog::Catalog;
diff --git a/src/model/bank.rs b/src/model/bank.rs
index a09b58d..9efd32f 100644
--- a/src/model/bank.rs
+++ b/src/model/bank.rs
@@ -352,7 +352,7 @@ fn validate_item(
issues.push("version must be at least 1".into());
}
- // --- options ---
+ // --- options ----
// An open-response item takes no options; its answer lives in `solution`.
// Every other format needs at least two things to choose between.
if it.format.has_options() {
@@ -428,7 +428,7 @@ fn validate_item(
}
}
- // --- key -----
+ // --- key ---
let keys = it.key_indices();
match it.format {
Format::SingleBestAnswer => {
@@ -465,7 +465,7 @@ fn validate_item(
}
}
- // --- level and process must agree --------
+ // --- level and process must agree -------
if let Some(p) = it.cognitive_process {
if !it.level.allows(p) {
issues.push(format!(
@@ -476,7 +476,7 @@ fn validate_item(
}
}
- // --- design plausibility -----------
+ // --- design plausibility ------
if let Some(d) = &it.design {
if let Some(x) = d.expected_difficulty {
if !(0.0..=1.0).contains(&x) {
@@ -548,7 +548,7 @@ fn validate_item(
));
}
- // --- retirement ----
+ // --- retirement -----
if it.retired.is_some() && it.status != Status::Retired {
issues.push(format!(
"has a `retired` block but status is `{}`",
@@ -588,7 +588,7 @@ fn validate_item(
}
}
- // --- cross-file references ---------
+ // --- cross-file references ----
if let Some(c) = course {
for lo in &it.learning_objectives {
match c.learning_objectives.get(lo) {
diff --git a/src/util/markup.rs b/src/util/markup.rs
index 2a32663..ea99217 100644
--- a/src/util/markup.rs
+++ b/src/util/markup.rs
@@ -199,7 +199,7 @@ pub fn escape_html(s: &str) -> String {
/// # Returns
///
/// The substituted text.
-fn apply_symbols(s: &str, html: bool) -> String {
+pub(crate) fn apply_symbols(s: &str, html: bool) -> String {
let mut out = s.to_string();
for (token, entity, plain) in SYMBOLS {
if out.contains(token) {
@@ -218,7 +218,7 @@ fn apply_symbols(s: &str, html: bool) -> String {
/// # Returns
///
/// The text with inline markup converted.
-fn apply_inline(s: &str) -> String {
+pub(crate) fn apply_inline(s: &str) -> String {
let mut out = s.to_string();
// Bracketed forms first: their contents may contain other markup characters.
out = wrap_bracket(&out, "#sub[", "", "");