/* ===================================================================== Renderer — document model → the exact markup of an exercise sheet. This is the single code path to output: the builder canvas shows what this produces, and export writes what this produces. There is no second renderer to drift out of step. ===================================================================== */ import { parse } from './inline.js'; import { partNumbers } from './model.js'; const esc = (s = '') => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const pad = (n) => ' '.repeat(n); /* ---------- blocks ---------- */ /** Tag the outermost element so the canvas can map DOM back to model. */ function mark(html, node, ctx) { if (!ctx.marks) return html; return html.replace(/^(\s*<[a-z0-9]+)/i, `$1 data-node="${node.id}"`); } function block(node, ctx, depth) { return mark(blockInner(node, ctx, depth), node, ctx); } function blockInner(node, ctx, depth) { const p = pad(depth); switch (node.kind) { case 'prose': { const cls = node.variant ? ` class="${node.variant}"` : ''; return `${p}${parse(node.text, ctx.refs)}

`; } case 'cmd': return `${p}
${esc(node.command)}
`; case 'terminal': return `${p}${terminal(node)}`; case 'protip': { const keys = (node.keys || []).map((k) => `${esc(k)}`).join(''); const cls = node.emphasis ? 'protip headline' : 'protip'; return [ `${p}
`, `${p}
${keys}
`, `${p}

${esc(node.label)} ${parse(node.text, ctx.refs)}

`, `${p}
`, ].join('\n'); } case 'list': return [ `${p}`, ].join('\n'); case 'table': return table(node, ctx, depth); case 'note': return [ `${p}
`, ...node.children.map((c) => block(c, ctx, depth + 1)), `${p}
`, ].join('\n'); case 'callout': return [ `${p}
`, `${p}

${parse(node.heading, ctx.refs)}

`, ...node.children.map((c) => block(c, ctx, depth + 1)), `${p}
`, ].join('\n'); // legacy: inline side quests from before the rule that they live at part // level. Still rendered so old sheets survive; not offered in the palette. case 'sidequest': return [ `${p}
`, `${p} ${parse(node.summary, ctx.refs)}`, ...node.children.map((c) => block(c, ctx, depth + 1)), `${p}
`, ].join('\n'); default: throw new Error(`cannot render kind: ${node.kind}`); } } function terminal(node) { const body = (node.lines || []).map((segments) => segments.map((seg) => seg.role ? `${esc(seg.text)}` : esc(seg.text) ).join('')).join('\n'); const cap = node.caption ? `${esc(node.caption)}` : ''; return `
${cap}${body}
`; } function table(node, ctx, depth) { const p = pad(depth); const out = [`${p}
`, `${p} `]; if (node.columns && node.columns.some((c) => c)) { out.push(`${p} `); out.push(`${p} ${node.columns.map((c) => ``).join('')}`); out.push(`${p} `); } out.push(`${p} `); node.rows.forEach((row) => { out.push(`${p} ${row.map((c) => ``).join('')}`); }); out.push(`${p} `, `${p}
${parse(c, ctx.refs)}
${parse(c, ctx.refs)}
`, `${p}
`); return out.join('\n'); } /* ---------- parts ---------- */ /** * Consecutive tasks share one
    . A run that follows other content * continues the numbering rather than restarting at 1. */ function taskList(items, ctx, depth, offset) { const p = pad(depth); const style = offset ? ` style="counter-reset: task ${offset}"` : ''; return [ `${p}
      `, ...items.map((t) => [ `${p} `, ...t.children.map((c) => block(c, ctx, depth + 2)), `${p} `, ].join('\n')), `${p}
    `, ].join('\n'); } function partBody(node, ctx, depth) { const out = []; let i = 0, numbered = 0; while (i < node.children.length) { if (node.children[i].kind === 'task') { const run = []; while (i < node.children.length && node.children[i].kind === 'task') run.push(node.children[i++]); out.push(taskList(run, ctx, depth, numbered)); numbered += run.length; } else { out.push(block(node.children[i], ctx, depth)); i += 1; } } return out; } function part(node, ctx) { const side = node.track === 'side'; const tag = ctx.marks ? ` data-node="${node.id}"` : ''; const rail = side ? ' Side quest' : ' \n Core'; return [ `
    `, '
    ', rail, '
    ', '
    ', `

    ${parse(node.title, ctx.refs)}

    `, ...partBody(node, ctx, 2), '
    ', '
    ', ].join('\n'); } /* ---------- whole document ---------- */ export function renderBody(doc, opts = {}) { const ctx = { refs: partNumbers(doc), marks: !!opts.marks }; const m = doc.meta; const out = ['
    ', '']; out.push('
    '); if (m.logo) out.push(` `); out.push(`
    ${esc(eyebrow(m))}
    `); out.push(`

    ${esc(m.exercise)}
    ${esc(m.title)}

    `); out.push(`

    ${parse(m.standfirst, ctx.refs)}

    `); out.push('
    ', ''); doc.intro.forEach((node) => { out.push(node.kind === 'legend' ? legend(doc) : block(node, ctx, 0), ''); }); doc.parts.forEach((node) => out.push(part(node, ctx), '')); if (doc.closing.length) { out.push('
    '); doc.closing.forEach((node) => { if (node.kind === 'heading') out.push(`

    ${parse(node.text, ctx.refs)}

    `); else out.push(block(node, ctx, 1)); }); out.push('
    ', ''); } out.push('
    '); return out.join('\n'); } /** Course code · course name · term, skipping whatever is blank. */ export function eyebrow(m) { return [m.courseCode, m.courseName, m.term].map((s) => (s || '').trim()) .filter(Boolean).join(' · '); } function legend(doc) { const l = doc.meta.legendText || {}; const card = (cls, label, text) => `
    \n ${esc(label)}\n ${text}\n
    `; return [ '
    ', card('l-core', 'Core', l.core || ''), card('l-side', 'Side quest', l.side || ''), card('l-protip', 'Protip', l.protip || ''), '
    ', ].join('\n'); }