Files
exercise_builder/app/inline.js
T
ryan c18510b3c3 Import the exercise builder
The drag-and-drop workspace for building BI122G exercise sheets, served
by the Workshop at /projects/exercise-builder. Plain HTML, CSS and ES
modules; no build step. It runs entirely in the browser — work in
progress lives in localStorage and finished sheets leave as downloads,
so nothing is written on the server.

This directory is canonical. It began as a deployment target for
deploy_builder.sh syncing from the BI122G course folder, and that
arrangement has ended; see the README.

Includes the sheet's light/dark switch: the stylesheet already carried
both themes and followed the reader's system setting, so this adds the
control that overrides it — a corner toggle built by sheet.js, applied
before first paint by a head script the exporter writes, and hidden in
print. The builder's toolbar previews the same attribute on the canvas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ed5kU3HfZq9dfj7EYj5J2v
2026-08-11 19:59:59 +02:00

69 lines
3.0 KiB
JavaScript

/* =====================================================================
Inline markup — a deliberately small syntax, chosen over contenteditable
because it round-trips exactly and cannot carry pasted styling.
`code` → <code>
[[Ctrl]] → <kbd>
**strong** → <strong>
*emphasis* → <em>
[text](url) → <a href>
{{part:id}} → "Part 5", resolved from the live numbering
parse() and serialise() are inverses; the round-trip test relies on it.
===================================================================== */
const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;' };
const escape = (s) => s.replace(/[&<>]/g, (c) => ESC[c]);
/** markup → HTML. `refs` maps a part id to its number. */
export function parse(text, refs = new Map()) {
const slots = [];
const stash = (html) => `${slots.push(html) - 1}`;
let s = text;
// code first: nothing inside a code span is markup
s = s.replace(/`([^`]+)`/g, (_, body) => stash(`<code>${escape(body)}</code>`));
s = s.replace(/\[\[([^\]]+)\]\]/g, (_, body) => stash(`<kbd>${escape(body)}</kbd>`));
s = s.replace(/\{\{part:([A-Za-z0-9_-]+)\}\}/g, (_, id) => {
const n = refs.get(id);
return stash(n ? `Part ${n}` : '<span class="ref-missing">?</span>');
});
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, href) =>
stash(`<a href="${escape(href)}">${escape(label)}</a>`));
s = escape(s);
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
s = s.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
s = s.replace(/\n/g, '<br>'); // inverse of the sentinel in serialise()
return s.replace(/(\d+)/g, (_, i) => slots[Number(i)]);
}
const UNESC = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'" };
const unescape = (s) => s.replace(/&(amp|lt|gt|quot|#39);/g, (m) => UNESC[m]);
/** HTML → markup, for importing an existing sheet. */
export function serialise(html) {
let s = html.replace(/&nbsp;|&#160;/g, '\u00A0');
s = s.replace(/<code>(.*?)<\/code>/gs, (_, b) => '`' + unescape(b) + '`');
s = s.replace(/<kbd>(.*?)<\/kbd>/gs, (_, b) => '[[' + unescape(b) + ']]');
s = s.replace(/<a [^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gs, (_, h, b) => `[${unescape(b)}](${h})`);
s = s.replace(/<strong>(.*?)<\/strong>/gs, '**$1**');
s = s.replace(/<em>(.*?)<\/em>/gs, '*$1*');
// a sentinel, so the whitespace collapse below cannot swallow the break
s = s.replace(/<br\s*\/?>/g, '\u0000');
s = s.replace(/<[^>]+>/g, ''); // anything left is decoration
// collapse ordinary whitespace but never a non-breaking space, which is
// meaningful typography the author put there on purpose
return unescape(s)
.replace(/[^\S\u00A0]+/g, ' ')
.replace(/ ?\u0000 ?/g, '\n')
.trim();
}
/** Part ids referenced by a document, so the UI can flag broken links. */
export function refsUsed(text) {
return [...text.matchAll(/\{\{part:([A-Za-z0-9_-]+)\}\}/g)].map((m) => m[1]);
}