/* ===================================================================== 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
    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(/[\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>/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*[\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(//).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*[\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; }