/* =====================================================================
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}`,
...node.items.map((i) => `${p} - ${parse(i, ctx.refs)}
`),
`${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) => `| ${parse(c, ctx.refs)} | `).join('')}
`);
out.push(`${p} `);
}
out.push(`${p} `);
node.rows.forEach((row) => {
out.push(`${p} ${row.map((c) => `| ${parse(c, ctx.refs)} | `).join('')}
`);
});
out.push(`${p} `, `${p}
`, `${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('
', '');
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');
}