Files
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

233 lines
7.5 KiB
JavaScript

/* =====================================================================
Importer — an existing exercise sheet → the document model.
Lets you start from a finished sheet rather than a blank page, and
doubles as the proof that the model covers everything the sheet uses:
import → render → import again must reach a fixed point.
Takes a DOM. In the browser that is DOMParser; the test harness feeds
it an equivalent.
===================================================================== */
import { uid } from './model.js';
import { serialise } from './inline.js';
const text = (el) => serialise(el.innerHTML);
/** "BI122G · Long course name · Autumn 2026" back into its three fields. */
function splitEyebrow(line) {
const bits = String(line).split('·').map((s) => s.trim()).filter(Boolean);
if (bits.length >= 3) {
return { courseCode: bits[0], courseName: bits.slice(1, -1).join(' · '), term: bits[bits.length - 1] };
}
return { courseCode: bits[0] || '', courseName: bits[1] || '', term: '' };
}
function slug(s, used) {
let base = (s || 'part').toLowerCase().replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '').split('-').slice(0, 3).join('-') || 'part';
let out = base, n = 2;
while (used.has(out)) out = `${base}-${n++}`;
used.add(out);
return out;
}
/* ---------- blocks ---------- */
function readBlock(el) {
const cls = el.classList;
if (el.tagName === 'P' && !cls.contains('said') && !cls.contains('standfirst')) {
const variant = cls.contains('note') ? 'note' : null;
return { id: uid('p'), kind: 'prose', text: text(el), variant };
}
if (cls.contains('cmd')) {
return { id: uid('c'), kind: 'cmd', command: el.textContent.trim() };
}
if (cls.contains('term')) return readTerminal(el);
if (cls.contains('protip')) return readProtip(el);
if (cls.contains('tablewrap')) return readTable(el);
if (cls.contains('note')) {
return { id: uid('n'), kind: 'note', children: readChildren(el) };
}
if (cls.contains('callout')) {
const h = el.querySelector('h3');
return {
id: uid('k'), kind: 'callout',
heading: h ? text(h) : '',
children: readChildren(el, (c) => c !== h),
};
}
if (cls.contains('side-quest')) {
const sum = el.querySelector('summary');
return {
id: uid('s'), kind: 'sidequest',
summary: sum ? text(sum) : '',
children: readChildren(el, (c) => c !== sum),
};
}
if (el.tagName === 'UL' && cls.contains('plain')) {
return {
id: uid('u'), kind: 'list',
items: [...el.querySelectorAll('li')].map(text),
};
}
// a task list flattens into its steps: the <ol> is regenerated on render
if (el.tagName === 'OL' && cls.contains('tasks')) {
return [...el.querySelectorAll(':scope > li')].map((li) => ({
id: uid('i'), kind: 'task', children: readChildren(li),
}));
}
return null; // decoration, skip
}
function readChildren(el, keep = () => true) {
return [...el.children]
.filter(keep)
.flatMap((c) => readBlock(c) || []) // a task list yields several nodes
.filter(Boolean);
}
function readTerminal(el) {
const capEl = el.querySelector('.cap');
const caption = capEl ? capEl.textContent.trim() : '';
// reading must never alter the document it reads, so the caption is
// stripped from a copy of the markup rather than removed from the DOM
const inner = el.innerHTML.replace(/<span class="cap">[\s\S]*?<\/span>/, '');
// split into lines, then each line into typed segments plus the plain
// text between them — transcripts mix roles freely on a single line
const lines = inner.split('\n').map((raw) => {
const segments = [];
const re = /<span class="([pceo])">(.*?)<\/span>/gs;
let at = 0, m;
while ((m = re.exec(raw)) !== null) {
if (m.index > at) {
const between = decode(raw.slice(at, m.index));
if (between) segments.push({ role: null, text: between });
}
segments.push({ role: m[1], text: decode(m[2]) });
at = m.index + m[0].length;
}
if (at < raw.length) {
const tail = decode(raw.slice(at));
if (tail) segments.push({ role: null, text: tail });
}
return segments;
}).filter((segs) => segs.length);
return { id: uid('m'), kind: 'terminal', caption, lines };
}
function decode(s) {
return s.replace(/<[^>]+>/g, '')
.replace(/&(amp|lt|gt|quot|#39);/g,
(m) => ({ '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'" }[m]));
}
function readProtip(el) {
const said = el.querySelector('.said');
const b = said && said.querySelector('b');
const label = b ? b.textContent.trim() : 'Protip';
const body = said ? said.innerHTML.replace(/^\s*<b>[\s\S]*?<\/b>\s*/, '') : '';
return {
id: uid('r'), kind: 'protip',
keys: [...el.querySelectorAll('.keys kbd')].map((k) => k.textContent.trim()),
label,
text: serialise(body),
emphasis: el.classList.contains('headline'),
};
}
function readTable(el) {
const head = [...el.querySelectorAll('thead th')].map(text);
const rows = [...el.querySelectorAll('tbody tr')]
.map((tr) => [...tr.children].map(text));
return {
id: uid('b'), kind: 'table',
columns: head.length ? head : new Array(rows[0] ? rows[0].length : 2).fill(''),
rows,
};
}
/* ---------- whole document ---------- */
export function importSheet(doc) {
const sheet = doc.querySelector('.sheet');
if (!sheet) throw new Error('no .sheet in that file');
const grab = (sel, fn, fallback = '') => {
const el = sheet.querySelector(sel);
return el ? fn(el) : fallback;
};
const h1 = sheet.querySelector('h1');
const [exercise, title] = h1
? h1.innerHTML.split(/<br\s*\/?>/).map((s) => serialise(s))
: ['Exercise', 'Untitled'];
const out = {
meta: {
...splitEyebrow(grab('.eyebrow', (e) => e.textContent.trim())),
exercise, title,
standfirst: grab('.standfirst', text),
logo: grab('.logo', (e) => e.getAttribute('src')),
legendText: {},
},
intro: [],
parts: [],
closing: [],
};
const legendEl = sheet.querySelector('.legend');
if (legendEl) {
const card = (c) => {
const el = legendEl.querySelector('.' + c);
if (!el) return '';
return el.innerHTML
.replace(/^\s*<b>[\s\S]*?<\/b>\s*/, '')
.trim()
.replace(/\s+/g, ' ');
};
out.meta.legendText = { core: card('l-core'), side: card('l-side'), protip: card('l-protip') };
}
// everything between the masthead and the first part is the intro
for (const el of [...sheet.children]) {
if (el.tagName === 'HEADER') continue;
if (el.classList.contains('legend')) { out.intro.push({ id: uid('l'), kind: 'legend' }); continue; }
if (el.classList.contains('part')) break;
const b = readBlock(el);
if (b) out.intro.push(b);
}
const used = new Set();
sheet.querySelectorAll(':scope > .part').forEach((el) => {
const h2 = el.querySelector('.body > h2');
const heading = h2 ? text(h2) : '';
out.parts.push({
id: slug(heading, used),
kind: 'part',
track: el.classList.contains('is-side') ? 'side' : 'core',
title: heading,
children: readChildren(el.querySelector('.body'), (c) => c !== h2),
});
});
const closing = sheet.querySelector('.closing');
if (closing) {
[...closing.children].forEach((el) => {
if (el.classList.contains('colophon')) return;
if (el.tagName === 'H2') {
out.closing.push({ id: uid('h'), kind: 'heading', text: text(el) });
return;
}
const b = readBlock(el);
if (b) out.closing.push(b);
});
}
return out;
}