Skip to main content
Recipe number 179

Cookbook · Chapter 10 · Output & integration

A course module with a test answered on screen

Two exercise styles from one Markdown: practice checked at will, a test with one check and no retry; answered in HTML and EPUB, graded in a SCORM course.

On this page
Output
Canvas · HTML · EPUB · SCORM
Postext
Tested with Postext 1.65.0
Needs ≥ 1.65.0
Licence
Updated 1 Nov 2026
Code MIT · Text CC BY 4.0
  • Trim 176 × 250 mm
  • 1 column
  • Source Serif 4 10.8/15.5
  • Archivo
  • 4 pages
  • Level
  • Postext 1.65.0
  • Laid out in 120 ms
  • 239 lines of code

In short

An Earth science lesson on earthquakes: read it, practise as often as you like, then take a short test that lets you check each answer once. It prints as a booklet and works on screen, in an e-book and on a learning platform.

What you'll build

Module 4 of The Restless Planet, an introductory Earth science course: four pages on earthquakes, on a 176 × 250 mm page that opens under a painting of a fault crossing a dry plain. The reading leads to a practice section and a module test, both written in the Markdown as :::exercise fences with their answers. The printed module shows the practice key and keeps the test's key out. On screen the same pages are answerable: the practice can be checked and started again at will, while each test question takes one check, never starts again and shows its solution only when that check is spent. A panel beside the pages adds up what a learning platform would record: the score, whether the test is complete, and pass or fail at 70 %. The same pages are written as a fixed-layout EPUB and as a SCORM 2004 package, a course with the lesson and the test as two parts the platform grades on their own.

This recipe answers

  • How do I let readers answer and check the exercises in the HTML or EPUB edition?
  • How do I turn a book's exercises into a SCORM course that reports scores to a learning platform, with a test that allows one check?

The short answer

script.js · lines 35–60in full code
// Practice: check, start again and see the solution as often as the learner likes.
const practice = {
  id: 'practice',
  number: { shape: 'disc', background: col('rust'), fontFamily: SANS, fontWeight: 700 },
  instruction: { fontFamily: SANS, bold: true, fontSize: pt(10) },
  options: { marker: 'circle' },
  blank: { look: 'line', color: col('ink'), weight: pt(0.6) },
  match: { look: 'dots', gap: em(3), color: col('rust') },
  bank: { box: 'bank' },
  marginTop: mm(5), marginBottom: mm(1), itemSpacing: em(0.15),
};
// The test: one check, no fresh start, the solution only once the check is spent. On paper
// the same questions print without their key.
const test = {
  id: 'test', extends: 'practice', box: 'test',
  number: { shape: 'square', background: col('slate') },
  match: { color: col('slate') },
  solutions: { placement: 'none' },
  digital: { maxChecks: 1, reset: false, showSolution: 'noChecksLeft' },
};
const exercises = {
  solutions: { placement: 'directive', layout: 'key' }, // the practice key where :::solutions is
  // The pass mark the course's results are judged against (a SCORM package reports passed or
  // failed with it); the HTML and EPUB editions carry it with the exercise data.
  digital: { passingScore: 70 },
};

Practice and test from one Markdown: the test style limits each attempt

Ingredients

Type
Source Serif 4, Archivo (SIL OFL 1.1)
Assets
  • fault-1500.jpg
  • A fault across a dry plain in late afternoon light (the module's opener) (Generated With Diffusion Models, original)

Method

#1 · Two styles, two sets of rules

The code is the short answer above. The exercises say only what they ask; the fence's style decides how they behave. practice keeps the book's defaults, so its bar offers Check, Show solution and Try again without limits. test extends it and adds a digital block: maxChecks: 1, reset: false and showSolution: 'noChecksLeft', so the bar counts down to "No checks left" and only then offers the solution. On paper the same style sends its solutions nowhere (placement: 'none'), while the practice key prints where the :::solutions line stands.

#2 · A saved attempt comes back from the store

script.js · lines 276–292in full code
// The platform keeps each exercise's answers under its key; this learner checked three
// questions of the test, with two mistakes, and left the ordering one to do.
const tests = doc.exercises.filter((ex) => ex.style === 'test');
const mistakes = { 'test-statements': 2, 'test-distance': 1 }; // a wrong slot in each
const attempt = (ex) => ({ checked: true, n: 1, v: Object.fromEntries(ex.slots.map((slot, i) => {
  const right = exerciseSolutionValue(slot, ex);
  const wrong = slot.kind === 'truefalse' ? 3 - right : '180'; // 25 s × 8 km, slipped
  return [slot.key, mistakes[ex.anchor] === i ? wrong : right];
})) });
const kept = new Map(tests.filter((ex) => ex.anchor !== 'test-sequence')
  .map((ex) => [ex.anchor, JSON.stringify(attempt(ex))]));
const saved = new Map();
const platform = { // the three calls of localStorage, over the platform's record
  getItem: (key) => saved.get(key) ?? kept.get(key.split(':').pop()) ?? null,
  setItem: (key, value) => saved.set(key, value),
  removeItem: (key) => saved.delete(key),
};

attachExercises reads each exercise's answers with getItem and writes them with setItem, under the storageKey and the exercise's anchor. Any object with those three calls can take the place of localStorage; a learning platform keeps the same JSON as the learner's saved data. This one hands back three checked questions of the test, with two wrong answers (true for statement 3, 180 km for the distance), so the page opens the way a returning learner would find it: those questions locked, their solutions available, the ordering question still to do.

#3 · Answer the pages and hear every check

script.js · lines 296–333in full code
const el = (tag, css, text) => Object.assign(document.createElement(tag),
  { textContent: text ?? '' }, { style: css });
const course = el('section', 'display: flex; flex-wrap: wrap; gap: 16px; justify-content: center;'
  + 'padding: 24px 16px 0; font: 13px/1.5 system-ui, sans-serif; color: #d9d5cc');
course.id = 'course';
const frame = el('div', 'width: min(700px, 94vw); height: 720px; overflow: auto;'
  + 'border-radius: 6px; background: #2a2e35');
frame.innerHTML = renderToHtml(doc, { mode: 'single', background: palette.paper, padding: 16,
  resourceImageUrl: imageUrl });
const card = el('aside', 'width: min(300px, 94vw); padding: 14px 16px; border-radius: 6px;'
  + 'border: 1px solid #2c3038; background: #15181d');
const results = new Map();
function report(result) { // every check, fresh start and solution shown
  if (result.style !== 'test') return;
  results.set(result.key, result);
  const all = [...results.values()];
  const [right, total] = all.reduce(([r, n], x) => [r + x.right, n + x.total], [0, 0]);
  const score = Math.round((100 * right) / total);
  const done = all.length === tests.length && all.every((x) => x.checked || x.locked);
  const verdict = !done ? t({ en: 'incomplete', es: 'sin terminar' })
    : score >= exercises.digital.passingScore ? t({ en: 'passed', es: 'aprobado' })
      : t({ en: 'failed', es: 'suspenso' });
  card.replaceChildren(el('h2', 'margin: 0; font-size: 15px; color: #f4f1ea',
    t({ en: 'What the platform hears', es: 'Lo que recibe la plataforma' })),
  el('p', 'margin: 6px 0; font-size: 22px; color: #f4f1ea', `${score} % · ${verdict}`),
  el('p', 'margin: 0 0 8px', t({ en: `${right} of ${total} answers right in the test, pass `
    + `mark ${exercises.digital.passingScore} %`, es: `${right} de ${total} respuestas bien en el `
    + `examen, aprueba con el ${exercises.digital.passingScore} %` })),
  ...all.map((x) => el('p', 'margin: 0; font-variant-numeric: tabular-nums',
    `${t({ en: 'Exercise', es: 'Ejercicio' })} ${x.number} · `
    + `${x.right}/${x.total} · ${x.checked || x.locked ? t({ en: 'checked', es: 'comprobado' })
      : t({ en: 'to do', es: 'por hacer' })}`)));
}
attachExercises(frame, { storageKey: 'restless-planet:4', store: platform, onResult: report });
course.append(frame, card);
document.getElementById('pages').before(course);
const first = frame.querySelector(`[data-pt-ex="${tests[0].id}"]`);
frame.scrollTop = first.getBoundingClientRect().top - frame.getBoundingClientRect().top - 120;

renderToHtml writes the pages and the exercise records; attachExercises lays inputs, toggles and the bar over them. onResult hears each exercise when it is set up and after every check, fresh start and solution shown, with right and total graded answers, the checks used and whether the attempt is locked. The panel adds the test's answers the way a SCORM player does: right answers over graded answers across the test, complete when every question is checked or locked, passed at passingScore. The page uses dpi: 96, so a millimetre of the print page is a CSS millimetre on screen.

#4 · The same exercises in an EPUB

script.js · lines 337–357in full code
const epubFonts = (faces) => Promise.all(Object.entries(faces).flatMap(([family, specs]) =>
  specs.map(async (spec) => {
    const [weight, style, id] = [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal',
      fontsourceId(family)];
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-latin-${weight}-${style}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
    const bytes = new Uint8Array(await res.arrayBuffer());
    return { family, weight, style, format: 'woff2', bytes };
  })));
const metadata = { title: doc.metadata.title, subtitle: doc.metadata.subtitle, language: LANG,
  modified: new Date('2026-10-31T00:00:00Z') }; // fixed: the same bytes on every run
const fonts = await epubFonts(FONTS);
const resourceBytes = (fileId) => ({ bytes: imageBytes(fileId), mediaType: 'image/jpeg' });
const epub = await renderToEpub([doc], { layout: 'fixed', metadata, fonts, resourceBytes });
const offer = (bytes, name, type) => {
  const link = el('a', 'color: #d8a21a', `${name} · ${Math.round(bytes.length / 1024)} KB`);
  link.href = URL.createObjectURL(new Blob([bytes], { type }));
  link.download = name;
  document.getElementById('pt-actions').append(link);
};
offer(epub, `${RECIPE}.epub`, 'application/epub+zip');

The fixed-layout EPUB carries the exercise data and the answering script, so a reading system that runs scripts offers the same inputs and the same limits; one that does not still has the answer key the writer adds. The fonts are handed over as bytes, the same Fontsource files the page loaded.

#5 · A SCORM course with the test on its own

script.js · lines 360–370in full code
// The module's headings as postext-scorm reads them; the last one, the test, runs to the end.
const sections = treeLeaves(scormTree([{ markdown }], config)[0]);
const exam = sections.at(-1);
const items = [
  { title: t({ en: 'Lesson and practice', es: 'Lección y práctica' }), doc: 0,
    range: { start: sections[0].range.start, end: exam.range.start } },
  { title: exam.title, doc: 0, range: exam.range }, // its score is the test's alone
];
const scorm = await renderToScorm([doc], { version: '2004', layout: 'fixed', items, metadata,
  fonts, resourceBytes }); // the pass mark comes from exercises.digital.passingScore
offer(scorm, `${RECIPE}-scorm.zip`, 'application/zip');

renderToScorm takes the documents and the options renderToEpub takes, plus the SCORM version and the course's items. Each item is a SCO, the part of a course a learning platform launches and grades by itself, and a SCO counts every exercise in it. scormTree reads the module's headings with the stretch of Markdown each one covers, so the first SCO runs from the title to the test (the practice is graded there) and the second holds the test alone, to the end. Each reports passed or failed against exercises.digital.passingScore. Inside the package a player stands where platform stands on this page: it keeps the answers in the platform's record and sends every check, so a learner who comes back finds the test as they left it.

The whole recipe

Sandbox
// ═══ Postext Cookbook · Nº 179 · A course module with a test answered on screen ═══
// https://postext.dev/en/cookbook/self-checking-course-module
// Code: MIT · Text: original (CC BY 4.0) · Painting: generated with diffusion models
// Fonts: Source Serif 4, Archivo (SIL OFL 1.1) · Needs postext ≥ 1.65.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, renderToHtml,
  attachExercises, exerciseSolutionValue,
} from 'https://esm.sh/postext';
import { renderToEpub } from 'https://esm.sh/postext-epub';
import { renderToScorm, scormTree, treeLeaves } from 'https://esm.sh/postext-scorm';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'self-checking-course-module';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1f2523', // text: a green-tinted near-black
  rust: '#9c4420', // the course's accent: kicker, practice numbers, rules
  slate: '#2f5266', // the test: its numbers and its frame
  tint: '#f5efe4', // the safety box, the word bank
  rule: '#d6cdbf', // hairlines
  muted: '#646d69', // running heads, colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'rust (defaults)', value: { hex: palette.rust, model: 'hex' } },
];
const [TEXT, SANS] = ['Source Serif 4', 'Archivo'];
const [TOP, OUTER, LEAD] = [22, 21, 15.5]; // mm, mm, pt
const BAND = 100; // mm: the painting's depth from the trim, over 3 mm of bleed

// #region answer: practice and test from one Markdown: the test style limits each attempt
// Practice: check, start again and see the solution as often as the learner likes.
const practice = {
  id: 'practice',
  number: { shape: 'disc', background: col('rust'), fontFamily: SANS, fontWeight: 700 },
  instruction: { fontFamily: SANS, bold: true, fontSize: pt(10) },
  options: { marker: 'circle' },
  blank: { look: 'line', color: col('ink'), weight: pt(0.6) },
  match: { look: 'dots', gap: em(3), color: col('rust') },
  bank: { box: 'bank' },
  marginTop: mm(5), marginBottom: mm(1), itemSpacing: em(0.15),
};
// The test: one check, no fresh start, the solution only once the check is spent. On paper
// the same questions print without their key.
const test = {

The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗ (opens in a new tab)

Variations

#SCORM 1.2, or the text reflowed

Older platforms often import only SCORM 1.2, where the pass mark becomes each SCO's mastery score. layout: 'reflowable' sends the text instead of the printed pages, for learners on a phone.

-const scorm = await renderToScorm([doc], { version: '2004', layout: 'fixed', items, metadata,
+const scorm = await renderToScorm([doc], { version: '1.2', layout: 'reflowable', items, metadata,

#Check each answer as it is given

For a quiz with no Check button, set check: 'instant' in exercises.digital. Instant checking drops the check limit; the test style keeps showSolution.

-  digital: { passingScore: 70 },
+  digital: { passingScore: 70, check: 'instant' },

Pitfalls

Give every graded exercise an anchor (:::exercise{#test-scales}). Its answers are kept under the anchor; without one the key is built from the exercise's place in the document, and a question added before it hands a returning learner the wrong saved answers.

Credits

Text
Original prose, CC BY 4.0
Images
  • A fault across a dry plain in late afternoon light (the module's opener) · Generated With Diffusion Models · original
Fonts
Source Serif 4 (SIL OFL 1.1) · Archivo (SIL OFL 1.1)
Sandbox