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
This commit is contained in:
+232
@@ -0,0 +1,232 @@
|
||||
/* =====================================================================
|
||||
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" }[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;
|
||||
}
|
||||
Reference in New Issue
Block a user