Files
ryan c18510b3c3 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
2026-08-11 19:59:59 +02:00

225 lines
7.0 KiB
JavaScript

/* =====================================================================
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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}<p${cls}>${parse(node.text, ctx.refs)}</p>`;
}
case 'cmd':
return `${p}<div class="cmd">${esc(node.command)}</div>`;
case 'terminal':
return `${p}${terminal(node)}`;
case 'protip': {
const keys = (node.keys || []).map((k) => `<kbd>${esc(k)}</kbd>`).join('');
const cls = node.emphasis ? 'protip headline' : 'protip';
return [
`${p}<div class="${cls}">`,
`${p} <div class="keys">${keys}</div>`,
`${p} <p class="said"><b>${esc(node.label)}</b> ${parse(node.text, ctx.refs)}</p>`,
`${p}</div>`,
].join('\n');
}
case 'list':
return [
`${p}<ul class="plain">`,
...node.items.map((i) => `${p} <li>${parse(i, ctx.refs)}</li>`),
`${p}</ul>`,
].join('\n');
case 'table':
return table(node, ctx, depth);
case 'note':
return [
`${p}<div class="note">`,
...node.children.map((c) => block(c, ctx, depth + 1)),
`${p}</div>`,
].join('\n');
case 'callout':
return [
`${p}<div class="callout">`,
`${p} <h3>${parse(node.heading, ctx.refs)}</h3>`,
...node.children.map((c) => block(c, ctx, depth + 1)),
`${p}</div>`,
].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}<details class="side-quest" open>`,
`${p} <summary>${parse(node.summary, ctx.refs)}</summary>`,
...node.children.map((c) => block(c, ctx, depth + 1)),
`${p}</details>`,
].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 ? `<span class="${seg.role}">${esc(seg.text)}</span>` : esc(seg.text)
).join('')).join('\n');
const cap = node.caption ? `<span class="cap">${esc(node.caption)}</span>` : '';
return `<div class="term">${cap}${body}</div>`;
}
function table(node, ctx, depth) {
const p = pad(depth);
const out = [`${p}<div class="tablewrap">`, `${p} <table>`];
if (node.columns && node.columns.some((c) => c)) {
out.push(`${p} <thead>`);
out.push(`${p} <tr>${node.columns.map((c) => `<th>${parse(c, ctx.refs)}</th>`).join('')}</tr>`);
out.push(`${p} </thead>`);
}
out.push(`${p} <tbody>`);
node.rows.forEach((row) => {
out.push(`${p} <tr>${row.map((c) => `<td>${parse(c, ctx.refs)}</td>`).join('')}</tr>`);
});
out.push(`${p} </tbody>`, `${p} </table>`, `${p}</div>`);
return out.join('\n');
}
/* ---------- parts ---------- */
/**
* Consecutive tasks share one <ol>. 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}<ol class="tasks"${style}>`,
...items.map((t) => [
`${p} <li${ctx.marks ? ` data-node="${t.id}"` : ''}>`,
...t.children.map((c) => block(c, ctx, depth + 2)),
`${p} </li>`,
].join('\n')),
`${p}</ol>`,
].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
? ' <span class="tag side">Side quest</span>'
: ' <span class="num"></span>\n <span class="tag core">Core</span>';
return [
`<section class="part${side ? ' is-side' : ''}"${tag}>`,
' <div class="rail">',
rail,
' </div>',
' <div class="body">',
` <h2>${parse(node.title, ctx.refs)}</h2>`,
...partBody(node, ctx, 2),
' </div>',
'</section>',
].join('\n');
}
/* ---------- whole document ---------- */
export function renderBody(doc, opts = {}) {
const ctx = { refs: partNumbers(doc), marks: !!opts.marks };
const m = doc.meta;
const out = ['<div class="sheet">', ''];
out.push('<header class="masthead">');
if (m.logo) out.push(` <img class="logo" alt="University of Skövde" src="${m.logo}">`);
out.push(` <div class="eyebrow">${esc(eyebrow(m))}</div>`);
out.push(` <h1>${esc(m.exercise)}<br>${esc(m.title)}</h1>`);
out.push(` <p class="standfirst">${parse(m.standfirst, ctx.refs)}</p>`);
out.push('</header>', '');
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('<section class="closing">');
doc.closing.forEach((node) => {
if (node.kind === 'heading') out.push(` <h2>${parse(node.text, ctx.refs)}</h2>`);
else out.push(block(node, ctx, 1));
});
out.push('</section>', '');
}
out.push('</div>');
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) =>
` <div class="${cls}">\n <b>${esc(label)}</b>\n ${text}\n </div>`;
return [
'<div class="legend">',
card('l-core', 'Core', l.core || ''),
card('l-side', 'Side quest', l.side || ''),
card('l-protip', 'Protip', l.protip || ''),
'</div>',
].join('\n');
}