feat: write quarto assignment with encryption

This commit is contained in:
2026-08-10 03:25:15 -04:00
parent 327ac371e4
commit 1cc8137be9
15 changed files with 1678 additions and 53 deletions
+249
View File
@@ -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; }
+160
View File
@@ -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:
* <div class="solutions-gate" data-bundle="a1.1-solutions.json"></div>
* `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:
* <div class="solution" data-solution-for="q-enthalpy-qp-001" hidden></div>
* 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 = `
<div class="gate-inner">
<span class="gate-lock" aria-hidden="true"></span>
<label class="gate-label" for="gate-pw">Solutions are locked</label>
<input class="gate-input" id="gate-pw" type="password"
autocomplete="off" spellcheck="false"
placeholder="Enter the page password" />
<button class="gate-btn" type="button">Unlock</button>
<p class="gate-status" role="status" aria-live="polite"></p>
</div>`;
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 = `
<div class="gate-inner">
<span class="gate-lock is-open" aria-hidden="true"></span>
<span class="gate-label">Solutions unlocked</span>
<button class="gate-btn gate-btn-ghost" type="button">Hide again</button>
</div>`;
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();
}
})();
+24
View File
@@ -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 `<id>-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<PathBuf>,
/// Encrypt with this password instead of a generated one. Use only to
/// re-encrypt a page with a known password.
#[arg(long)]
password: Option<String>,
/// 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<PathBuf>,
},
}
#[derive(Debug, Subcommand)]
+49
View File
@@ -195,6 +195,55 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
}
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)
}
}
}
}
-1
View File
@@ -29,5 +29,4 @@ pub mod canvas;
pub mod gradescope;
pub mod responses;
pub mod store;
#[cfg(feature = "parquet")]
pub mod store_parquet;
+2 -31
View File
@@ -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<ResponseSet> {
///
/// * `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<ResponseSet> {
let rows = crate::store_parquet::read(path)?;
let mut set = ResponseSet::new();
@@ -422,16 +403,6 @@ fn read_parquet(path: &Path) -> Result<ResponseSet> {
Ok(set)
}
/// Stub for builds without Parquet support.
#[cfg(not(feature = "parquet"))]
fn read_parquet(path: &Path) -> Result<ResponseSet> {
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
+2
View File
@@ -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;
-4
View File
@@ -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('"', "&quot;")
}
// ---
// Package construction
// ---
/// Options for a QTI export.
#[derive(Debug, Clone)]
+976
View File
@@ -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 `<id>-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<String>,
}
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 `<id>-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<Rendered> {
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<String> {
let mut out = String::new();
out.push_str(
"<!-- _questions.qmd — generated by `coursebank export site`. No YAML front\n\
\x20 matter, no title, no prose. Include from the page with:\n\
\x20 {{< include _questions.qmd >}} -->\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<Vec<(String, String)>> {
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<String> {
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<String> = printed
.iter()
.filter(|(_, c)| c.correct)
.map(|(position, c)| {
format!(
"<b>{}</b> &mdash; {}",
letter(*position),
inline_html(&c.text)
)
})
.collect();
out.push_str(
"<p class=\"sol-answer\"><span class=\"badge badge-correct\">Correct answer</span> ",
);
out.push_str(&keyed.join("; "));
out.push_str("</p>\n");
if let Some(solution) = item.solution.as_ref() {
if let Some(model) = &solution.model_answer {
out.push_str(&format!(
"<div class=\"sol-model\">{}</div>\n",
block_html(model)
));
}
if let Some(explanation) = &solution.explanation {
out.push_str(&format!(
"<p class=\"sol-explain\">{}</p>\n",
inline_html(explanation)
));
}
}
let distractors: Vec<(usize, &Choice)> = printed
.iter()
.copied()
.filter(|(_, c)| !c.correct)
.collect();
if distractors
.iter()
.any(|(_, c)| c.misconception.is_some() || why_wrong(c).is_some())
{
out.push_str("<p class=\"sol-feedback-title\">Why the other options miss</p>\n");
out.push_str("<ul class=\"sol-feedback\">\n");
for (position, c) in &distractors {
if c.misconception.is_none() && why_wrong(c).is_none() {
continue;
}
out.push_str(&format!(
" <li><span class=\"opt\">{}</span>\n",
letter(*position)
));
out.push_str(" <div class=\"opt-body\">\n");
if let Some(mis) = &c.misconception {
out.push_str(&format!(
" <span class=\"opt-mis\">{}</span>\n",
inline_html(mis)
));
}
if let Some(why) = why_wrong(c) {
out.push_str(&format!(
" <span class=\"opt-why\">{}</span>\n",
inline_html(why)
));
}
out.push_str(" </div></li>\n");
}
out.push_str("</ul>\n");
}
push_reference(&mut out, course, item.solution.as_ref());
out
}
/// An open-response fragment: the model answer, the rubric, accepted variants.
fn open_fragment(course: &CourseFile, item: &Item) -> Option<String> {
let solution = item.solution.as_ref().filter(|s| !s.is_empty())?;
let mut out = String::new();
if let Some(model) = &solution.model_answer {
out.push_str(&format!(
"<div class=\"sol-model\">{}</div>\n",
block_html(model)
));
}
if !solution.rubric.is_empty() {
let caption = match solution.rubric_points() {
Some(total) => {
let unit = if (total - 1.0).abs() < f64::EPSILON {
"point"
} else {
"points"
};
format!("Rubric &mdash; {} {unit}", trim_number(total))
}
None => "Rubric".to_string(),
};
out.push_str("<table class=\"sol-rubric\">\n");
out.push_str(&format!(" <caption>{caption}</caption>\n"));
out.push_str(
" <thead><tr><th scope=\"col\">Pts</th><th scope=\"col\">Criterion</th></tr></thead>\n",
);
out.push_str(" <tbody>\n");
for criterion in &solution.rubric {
let pts = criterion.points.map(trim_number).unwrap_or_default();
out.push_str(&format!(
" <tr><td>{pts}</td><td>{}</td></tr>\n",
inline_html(&criterion.description)
));
}
out.push_str(" </tbody>\n</table>\n");
}
if !solution.accepted.is_empty() {
let joined = solution
.accepted
.iter()
.map(|a| inline_html(a))
.collect::<Vec<_>>()
.join("; ");
out.push_str(&format!(
"<p class=\"sol-accepted\"><span class=\"badge\">Also accepted</span> {joined}</p>\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::<Vec<_>>()
.join("; ");
out.push_str(&format!("<p class=\"sol-ref\">Source: {sources}</p>\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!("<a href=\"{}\">{body}</a>", 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<String> {
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<Segment<'_>> {
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 `&lt;`, 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 `<p>`.
fn block_html(src: &str) -> String {
src.split("\n\n")
.map(str::trim)
.filter(|p| !p.is_empty())
.map(|p| format!("<p>{}</p>", inline_html(p)))
.collect::<Vec<_>>()
.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<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
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<String> {
let mut raw = [0u8; 16];
csprng(&mut raw)?;
// Thirty-two divides 256, so the modulo is unbiased.
let symbols: Vec<u8> = raw
.iter()
.map(|b| PW_ALPHABET[(*b % 32) as usize])
.collect();
let groups: Vec<String> = 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 `<page>-solutions.json`.
///
/// # Errors
///
/// Returns [`Error::Other`] on a CSPRNG or encryption failure.
pub fn build_bundle(page: &str, password: &str, fragments: &[(String, String)]) -> Result<Bundle> {
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::<sha2::Sha256>(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("<span class=\"badge badge-correct\">Correct answer</span>"));
assert!(mcq.contains("<b>A</b> &mdash; Enthalpy, $\\Delta H$"));
assert!(mcq.contains(
"<div class=\"sol-model\"><p>The change in enthalpy, $\\Delta H$.</p></div>"
));
assert!(mcq.contains("<span class=\"opt\">B</span>"));
assert!(mcq.contains("<span class=\"opt-mis\">Uses the constant-volume result.</span>"));
assert!(mcq.contains("<span class=\"opt-why\">That holds only at constant volume.</span>"));
assert!(mcq.contains("<p class=\"sol-ref\">Source: KKW §6.3</p>"));
}
#[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("<caption>Rubric &mdash; 2 points</caption>"));
assert!(open.contains("<td>1</td><td>States $H = U + PV$.</td>"));
assert!(open.contains("<span class=\"badge\">Also accepted</span>"));
}
#[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$ &lt; 5");
// Bold outside math becomes a tag; a dollar-math run is passed through.
assert_eq!(
inline_html("**H** is $H = U + PV$"),
"<strong>H</strong> 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(), "<p>alpha</p>".to_string()),
("q-open".to_string(), "<p>beta</p>".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(), "<p>alpha</p>");
}
#[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"));
}
}
+7 -1
View File
@@ -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 {}
+1 -1
View File
@@ -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;
+6 -6
View File
@@ -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) {
+2 -2
View File
@@ -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[", "<sub>", "</sub>");