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