c18510b3c3
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
233 lines
7.5 KiB
JavaScript
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" }[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;
|
|
}
|