mr-md 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,452 @@
1
+ import katex from "katex";
2
+ import type { Block, BuildOptions, QuizFile, QuizQuestion } from "../types.js";
3
+ import {
4
+ blockChrome,
5
+ escapeScriptJson,
6
+ mdInline,
7
+ mdToHtml,
8
+ renderSimulationControls,
9
+ } from "./markdown.js";
10
+ import hljs from "highlight.js";
11
+ import { type NavItem, resolveAssetSrc, resolveContent } from "./utils.js";
12
+
13
+ // HTML escaping utility needed by blocks
14
+ export function escHtml(str: string): string {
15
+ return str
16
+ .replace(/&/g, "&")
17
+ .replace(/</g, "&lt;")
18
+ .replace(/>/g, "&gt;")
19
+ .replace(/"/g, "&quot;")
20
+ .replace(/'/g, "&#39;");
21
+ }
22
+ export function escAttr(str: string): string {
23
+ return escHtml(str);
24
+ }
25
+
26
+ // ─── Block renderers ──────────────────────────────────────────────────────────
27
+
28
+ function renderBlock(
29
+ block: Block,
30
+ idx: number,
31
+ options: BuildOptions,
32
+ ): { html: string; navItem?: NavItem } {
33
+ try {
34
+ const result = renderBlockInner(block, idx, options);
35
+ if (
36
+ result.html &&
37
+ "src" in block &&
38
+ typeof block.src === "string" &&
39
+ block.src.includes(".")
40
+ ) {
41
+ result.html = result.html.replace(
42
+ /^<([a-zA-Z0-9-]+)([^>]*)>/,
43
+ `<$1 data-bk-src="${escAttr(block.src)}"$2>`,
44
+ );
45
+ }
46
+ return result;
47
+ } catch (e) {
48
+ const msg = e instanceof Error ? e.message : String(e);
49
+ console.warn(
50
+ ` ⚠ Error rendering block ${idx + 1} (${block.type}): ${msg}`,
51
+ );
52
+ const errorHtml = `<div class="bk-callout bk-callout--warning"><div class="bk-callout-icon"></div><div class="bk-callout-content"><div class="bk-callout-label">Block Error (${escHtml(block.type)})</div><div class="bk-callout-body"><p>${escHtml(msg)}</p></div></div></div>`;
53
+ return { html: errorHtml };
54
+ }
55
+ }
56
+
57
+ function renderBlockInner(
58
+ block: Block,
59
+ idx: number,
60
+ options: BuildOptions,
61
+ ): { html: string; navItem?: NavItem } {
62
+ switch (block.type) {
63
+ case "heading": {
64
+ const md = resolveContent(block.src, options, "md");
65
+ const { html, title } = mdToHtml(md);
66
+ const label = block.title || title || (typeof block.src === "string" && !block.src.includes(".md") ? block.src : "Heading");
67
+ const id = `heading-${idx}`;
68
+ return {
69
+ html: `<section id="${id}" class="bk-section bk-heading">${html}</section>`,
70
+ navItem: { id, label, kind: "heading" },
71
+ };
72
+ }
73
+
74
+ case "markdown": {
75
+ const md = resolveContent(block.src, options, "md");
76
+ const { html } = mdToHtml(md);
77
+ return { html: `<div class="bk-markdown">${html}</div>` };
78
+ }
79
+
80
+ case "section": {
81
+ const md = resolveContent(block.src, options, "md");
82
+ const { html, title } = mdToHtml(md);
83
+ const label = block.label || title || (typeof block.src === "string" && !block.src.includes(".md") ? block.src : "Section");
84
+ const id = `section-${idx}`;
85
+ return {
86
+ html: `<section id="${id}" class="bk-section bk-subsection">${html}</section>`,
87
+ navItem: { id, label, kind: "section" },
88
+ };
89
+ }
90
+
91
+ case "important":
92
+ case "warning":
93
+ case "tip":
94
+ case "note": {
95
+ const variantMap = {
96
+ important: "Important",
97
+ warning: "Warning",
98
+ tip: "Tip",
99
+ note: "Note",
100
+ };
101
+ const label = variantMap[block.type];
102
+ const md = resolveContent(block.src, options, "md");
103
+ const { html } = mdToHtml(md);
104
+ return {
105
+ html: `<div class="bk-callout bk-callout--${block.type}">
106
+ <div class="bk-callout-icon"></div>
107
+ <div class="bk-callout-content">
108
+ <div class="bk-callout-label">${label}</div>
109
+ <div class="bk-callout-body">${html}</div>
110
+ </div>
111
+ </div>`,
112
+ };
113
+ }
114
+
115
+ case "code": {
116
+ const raw = resolveContent(block.src, options, "text"); // Could be file or inline
117
+ const lang =
118
+ block.lang ??
119
+ (typeof block.src === "string" && block.src.includes(".")
120
+ ? (block.src.split(".").pop() ?? "")
121
+ : "");
122
+ let highlighted = escHtml(raw);
123
+ if (lang && hljs.getLanguage(lang)) {
124
+ highlighted = hljs.highlight(raw, { language: lang }).value;
125
+ } else {
126
+ highlighted = hljs.highlightAuto(raw).value;
127
+ }
128
+ return {
129
+ html: `<div class="bk-code-block">
130
+ ${block.label ? `<div class="bk-code-header"><span class="bk-code-label">${escHtml(block.label)}</span><span class="bk-code-lang">${lang}</span></div>` : ""}
131
+ <div class="bk-code-scroll">
132
+ <pre><code class="language-${lang} hljs">${highlighted}</code></pre>
133
+ </div>
134
+ </div>`,
135
+ };
136
+ }
137
+
138
+ case "simulation": {
139
+ const propsJson = JSON.stringify(block.props ?? {});
140
+ const simSrc = resolveContent(block.src, options, "js");
141
+ const simConfig = { js: simSrc, loop: false, dependencies: block.dependencies };
142
+ return {
143
+ html: blockChrome(
144
+ block.controls === "observe" ? "Simulation" : "Interactive Lab",
145
+ block.label,
146
+ block.caption,
147
+ `${renderSimulationControls(block as Extract<Block, { type: "simulation" }>)}
148
+ <div class="bk-embed-frame bk-embed-interactive">
149
+ <div class="bk-embed-overlay" tabindex="0" role="button" aria-label="Activate interactive simulation">
150
+ <span class="bk-embed-overlay-text">Click to interact</span>
151
+ </div>
152
+ <iframe srcdoc="${iframeDoc(simSrc, propsJson, false, block.dependencies)}"
153
+ sandbox="allow-scripts"
154
+ style="width:100%;height:100%;border:none;display:block;">
155
+ </iframe>
156
+ </div>
157
+ <script type="application/json" class="bk-sim-config">${escapeScriptJson(simConfig)}</script>`,
158
+ block.accent ?? "blue",
159
+ ),
160
+ };
161
+ }
162
+
163
+ case "animation": {
164
+ const animSrc = resolveContent(block.src, options, "js");
165
+ return {
166
+ html: blockChrome(
167
+ "Animation",
168
+ block.label,
169
+ block.caption,
170
+ `<div class="bk-embed-frame bk-embed-interactive">
171
+ <div class="bk-embed-overlay" tabindex="0" role="button" aria-label="Activate interactive animation">
172
+ <span class="bk-embed-overlay-text">Click to interact</span>
173
+ </div>
174
+ <iframe srcdoc="${iframeDoc(animSrc, "{}", block.loop)}"
175
+ sandbox="allow-scripts"
176
+ style="width:100%;height:100%;border:none;display:block;">
177
+ </iframe>
178
+ </div>`,
179
+ block.accent ?? "neutral",
180
+ ),
181
+ };
182
+ }
183
+
184
+ case "media": {
185
+ const src = resolveAssetSrc(block.src, options);
186
+ const media =
187
+ block.kind === "image"
188
+ ? `<img src="${escAttr(src)}" alt="${escAttr(block.alt ?? "")}" loading="lazy">`
189
+ : block.kind === "video"
190
+ ? `<video src="${escAttr(src)}" ${block.poster ? `poster="${escAttr(resolveAssetSrc(block.poster, options))}"` : ""} ${block.controls !== false ? "controls" : ""} playsinline></video>`
191
+ : `<audio src="${escAttr(src)}" ${block.controls !== false ? "controls" : ""}></audio>`;
192
+
193
+ return {
194
+ html: blockChrome(
195
+ block.kind,
196
+ block.label,
197
+ [block.caption, block.credit ? `Credit: ${block.credit}` : ""]
198
+ .filter(Boolean)
199
+ .join(" "),
200
+ `<div class="bk-media bk-media--${block.kind}">${media}</div>`,
201
+ "neutral",
202
+ ),
203
+ };
204
+ }
205
+
206
+ case "youtube": {
207
+ const params = new URLSearchParams();
208
+ params.set("rel", "0");
209
+ if (block.start) params.set("start", String(block.start));
210
+ return {
211
+ html: blockChrome(
212
+ "YouTube",
213
+ block.label,
214
+ block.caption,
215
+ `<div class="bk-embed-frame">
216
+ <iframe src="https://www.youtube-nocookie.com/embed/${escAttr(block.id)}?${params.toString()}"
217
+ title="${escAttr(block.label ?? "YouTube video")}"
218
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
219
+ allowfullscreen
220
+ referrerpolicy="strict-origin-when-cross-origin"
221
+ loading="lazy"
222
+ style="width:100%;height:100%;border:none;display:block;">
223
+ </iframe>
224
+ </div>`,
225
+ "neutral",
226
+ ),
227
+ };
228
+ }
229
+
230
+ case "latex": {
231
+ const rendered = katex.renderToString(block.tex, {
232
+ throwOnError: false,
233
+ displayMode: block.display ?? true,
234
+ });
235
+ return {
236
+ html: blockChrome(
237
+ "LaTeX",
238
+ block.label,
239
+ block.caption,
240
+ `<div class="${block.display === false ? "bk-latex-inline" : "bk-latex-block"}">${rendered}</div>`,
241
+ "violet",
242
+ false
243
+ ),
244
+ };
245
+ }
246
+
247
+ case "columns": {
248
+ return {
249
+ html: blockChrome(
250
+ "Columns",
251
+ block.label,
252
+ block.caption,
253
+ `<div class="bk-columns" style="grid-template-columns:${block.columns
254
+ .map((column) => column.width ?? "minmax(0, 1fr)")
255
+ .join(" ")}">
256
+ ${block.columns
257
+ .map((column) => {
258
+ const content =
259
+ column.latex != null
260
+ ? `<div class="bk-latex-block">${katex.renderToString(column.latex, { throwOnError: false, displayMode: true })}</div>`
261
+ : mdToHtml(
262
+ column.markdown ??
263
+ (column.src
264
+ ? resolveContent(column.src, options, "md")
265
+ : ""),
266
+ ).html;
267
+ return `<div class="bk-column">${content}</div>`;
268
+ })
269
+ .join("")}
270
+ </div>`,
271
+ "neutral",
272
+ false
273
+ ),
274
+ };
275
+ }
276
+
277
+ case "quiz": {
278
+ let quiz: QuizFile = { questions: [] };
279
+ const rawJson = resolveContent(block.src, options, "json");
280
+ try {
281
+ quiz = JSON.parse(rawJson);
282
+ } catch (e) {
283
+ const msg = e instanceof Error ? e.message : String(e);
284
+ if (options.strict !== false) {
285
+ throw new Error(`Invalid Quiz JSON for block ${idx + 1}: ${msg}`);
286
+ }
287
+ console.warn(` ⚠ Invalid Quiz JSON for block ${idx + 1}:`, e);
288
+ return {
289
+ html: `<div class="bk-callout bk-callout--warning"><div class="bk-callout-icon"></div><div class="bk-callout-content"><div class="bk-callout-label">Quiz JSON Error</div><div class="bk-callout-body"><p>${escHtml(msg)}</p></div></div></div>`,
290
+ };
291
+ }
292
+
293
+ return {
294
+ html: `<div class="bk-quiz" id="quiz-${idx}">
295
+ <div class="bk-quiz-head">
296
+ <span>${escHtml(block.label ?? "Check your understanding")}</span>
297
+ ${block.caption ? `<small>${mdInline(block.caption)}</small>` : ""}
298
+ </div>
299
+ <div class="bk-quiz-body">
300
+ ${quiz.questions.map((q, qi) => renderQuestion(q, `quiz-${idx}`, qi)).join("\n")}
301
+ </div>
302
+ </div>`,
303
+ navItem: {
304
+ id: `quiz-${idx}`,
305
+ label: block.label ?? "Questions",
306
+ kind: "quiz",
307
+ },
308
+ };
309
+ }
310
+
311
+ case "divider":
312
+ return { html: '<hr class="bk-divider">' };
313
+
314
+ default:
315
+ return { html: "" };
316
+ }
317
+ }
318
+
319
+ function renderQuestion(q: QuizQuestion, quizId: string, qi: number): string {
320
+ const qid = `${quizId}-q${qi}`;
321
+ const options = q.options
322
+ .map(
323
+ (opt, oi) => `
324
+ <button class="bk-opt" data-correct="${oi === q.answer}" onclick="bkAnswer(this,'${qid}')">
325
+ <span class="bk-opt-dot"></span><span class="bk-opt-text">${mdInline(opt)}</span>
326
+ </button>`,
327
+ )
328
+ .join("");
329
+
330
+ const expHtml = q.explanation
331
+ ? `<div class="bk-explanation" id="${qid}-exp" hidden>${mdToHtml(q.explanation).html}</div>`
332
+ : "";
333
+
334
+ return `
335
+ <div class="bk-question" id="${qid}">
336
+ <div class="bk-q-text">${mdToHtml(q.q).html}</div>
337
+ <div class="bk-opts">${options}</div>
338
+ ${expHtml}
339
+ </div>`;
340
+ }
341
+
342
+ // Wraps a JS string in a minimal iframe document
343
+ function iframeDoc(js: string, props: string, loop?: boolean, dependencies?: string[]): string {
344
+ const scriptTags = (dependencies ?? []).map((url) => `<script src="${escAttr(url)}"></script>`).join("\\n");
345
+ const doc = `<!DOCTYPE html><html><head>
346
+ ${scriptTags}
347
+ <style>
348
+ html, body { height: 100%; width: 100%; margin: 0; padding: 0; overflow: hidden; background: transparent; display: flex; align-items: center; justify-content: center; }
349
+ canvas { display: block; touch-action: none; transform-origin: center center; flex-shrink: 0; }
350
+ body { font-family: sans-serif; }
351
+ </style>
352
+ </head><body>
353
+ <canvas id="c" width="800" height="500"></canvas>
354
+ <script>
355
+ window.__simProps=${props};
356
+ window.__loop=${loop ?? false};
357
+ window.bkSetupCalled = false;
358
+ window.bkCanvasPoint = function(event, canvas) {
359
+ const c = canvas || event.currentTarget || event.target;
360
+ const rect = c.getBoundingClientRect();
361
+ const logicalW = c.__bkLogicalW || 800;
362
+ const logicalH = c.__bkLogicalH || 500;
363
+ return {
364
+ x: (event.clientX - rect.left) * logicalW / rect.width,
365
+ y: (event.clientY - rect.top) * logicalH / rect.height
366
+ };
367
+ };
368
+ window.bkFitCanvas = function(c, requestedW, requestedH, options) {
369
+ if (!c) return { scale: 1, width: requestedW, height: requestedH, cssScale: 1 };
370
+ const dpr = window.devicePixelRatio || 1;
371
+ const w = requestedW;
372
+ const h = requestedH;
373
+
374
+ c.__bkLogicalW = w;
375
+ c.__bkLogicalH = h;
376
+
377
+ c.style.width = w + "px";
378
+ c.style.height = h + "px";
379
+
380
+ c.style.position = "relative";
381
+ c.style.left = "auto";
382
+ c.style.top = "auto";
383
+ c.style.transformOrigin = "center center";
384
+
385
+ const scaleX = window.innerWidth / w;
386
+ const scaleY = window.innerHeight / h;
387
+ const cssScale = Math.max(scaleX, scaleY);
388
+
389
+ c.style.transform = "scale(" + cssScale + ")";
390
+
391
+ const physW = Math.max(1, Math.round(w * dpr));
392
+ const physH = Math.max(1, Math.round(h * dpr));
393
+
394
+ if (!options || options.bitmap !== false) {
395
+ if (c.width !== physW || c.height !== physH) {
396
+ c.width = physW;
397
+ c.height = physH;
398
+ }
399
+ }
400
+ return { scale: dpr, width: w, height: h, cssScale };
401
+ };
402
+ window.bkSetup = function(requestedW, requestedH, loopFn) {
403
+ window.bkSetupCalled = true;
404
+ const canvas = document.getElementById("c");
405
+ if (!canvas) return;
406
+ const ctx = canvas.getContext("2d");
407
+
408
+ function loop() {
409
+ const fit = window.bkFitCanvas(canvas, requestedW, requestedH);
410
+ if (window.innerWidth >= 32 && window.innerHeight >= 32) {
411
+ ctx.save();
412
+ ctx.scale(fit.scale, fit.scale);
413
+
414
+ loopFn(ctx, fit.width, fit.height);
415
+
416
+ ctx.restore();
417
+ }
418
+ requestAnimationFrame(loop);
419
+ }
420
+ loop();
421
+ };
422
+
423
+ window.addEventListener("message", (event) => {
424
+ if (!event.data || event.data.type !== "bk:set-props") return;
425
+ window.__simProps = { ...window.__simProps, ...event.data.props };
426
+ window.dispatchEvent(new CustomEvent("bk:props", { detail: window.__simProps }));
427
+ });
428
+ try {
429
+ ${js}
430
+ } catch (e) {
431
+ console.error("Simulation Error:", e);
432
+ document.body.innerHTML = '<div style="padding:20px;color:red;font-family:monospace">Error: ' + e.message + '</div>';
433
+ }
434
+ if (!window.bkSetupCalled) {
435
+ function fallbackScale() {
436
+ window.bkFitCanvas(document.getElementById("c"), 800, 500, { bitmap: false });
437
+ requestAnimationFrame(fallbackScale);
438
+ }
439
+ fallbackScale();
440
+ }
441
+ </script>
442
+ </body></html>`;
443
+ // We use double quotes for the srcdoc attribute, so we must escape them.
444
+ return doc
445
+ .replace(/&/g, "&amp;")
446
+ .replace(/"/g, "&quot;")
447
+ .replace(/'/g, "&#39;")
448
+ .replace(/</g, "&lt;")
449
+ .replace(/>/g, "&gt;");
450
+ }
451
+
452
+ export { blockChrome, renderBlock, renderBlockInner };
@@ -0,0 +1,163 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { fileURLToPath } from "url";
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = path.dirname(__filename);
7
+ import type { BuildOptions, Lesson } from "../types.js";
8
+ import { escHtml } from "./blocks.js";
9
+ import type { NavItem } from "./utils.js";
10
+
11
+ // ─── Page shell ───────────────────────────────────────────────────────────────
12
+
13
+ function renderNavItem(item: NavItem): string {
14
+ const kindClass =
15
+ item.kind === "heading"
16
+ ? "bk-nav-heading"
17
+ : item.kind === "quiz"
18
+ ? "bk-nav-quiz"
19
+ : "bk-nav-sub";
20
+ return `<a href="#${item.id}" class="bk-nav-item ${kindClass}" data-id="${item.id}">${escHtml(item.label)}</a>`;
21
+ }
22
+
23
+ function renderEndNav(lesson: Lesson): string {
24
+ const { prevSlug, prevTitle, nextSlug, nextTitle } = lesson.meta;
25
+ if (!prevSlug && !nextSlug) return "";
26
+
27
+ return `<nav class="bk-end-nav" aria-label="Lesson navigation" style="grid-template-columns: repeat(2, minmax(0, 1fr));">
28
+ ${prevSlug ? `
29
+ <a class="bk-end-link bk-end-link--prev" href="${prevSlug}.html">
30
+ <span>Previous Lesson</span>
31
+ <strong>${escHtml(prevTitle || "Previous")}</strong>
32
+ </a>
33
+ ` : `<div class="bk-end-link" style="visibility:hidden"></div>`}
34
+ ${nextSlug ? `
35
+ <a class="bk-end-link bk-end-link--next" href="${nextSlug}.html">
36
+ <span>Next Lesson</span>
37
+ <strong>${escHtml(nextTitle || "Next")}</strong>
38
+ </a>
39
+ ` : `<div class="bk-end-link" style="visibility:hidden"></div>`}
40
+ </nav>`;
41
+ }
42
+
43
+ function renderPage(
44
+ lesson: Lesson,
45
+ navItems: NavItem[],
46
+ bodyHtml: string,
47
+ opts: BuildOptions,
48
+ ): string {
49
+ const theme = opts.theme ?? "auto";
50
+ const schemeAttr = theme === "auto" ? "" : `data-theme="${theme}"`;
51
+ const preset = opts.preset ?? {};
52
+ const layout = preset.layout ?? "lesson";
53
+ const density = preset.density ?? "comfortable";
54
+ const tone = preset.tone ?? "scholarly";
55
+ const palette = opts.palette ?? "ink";
56
+ const navHtml = navItems.map(renderNavItem).join("\n");
57
+ const endNavHtml = renderEndNav(lesson);
58
+
59
+ return `<!DOCTYPE html>
60
+ <html lang="en" data-palette="${palette}" ${schemeAttr}>
61
+ <head>
62
+ <meta charset="UTF-8">
63
+ <meta name="viewport" content="width=device-width, initial-scale=1">
64
+ <title>${escHtml(lesson.meta.title)}</title>
65
+ ${lesson.meta.description ? `<meta name="description" content="${escHtml(lesson.meta.description)}">` : ""}
66
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.47/dist/katex.min.css">
67
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/styles/github-dark.min.css">
68
+ ${opts.head ?? ""}
69
+ <style>
70
+ ${opts.font ? `:root { --font-sans: ${opts.font}, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }` : ""}
71
+ ${pageCSS()}
72
+ </style>
73
+ </head>
74
+ <body class="bk-layout-${layout} bk-density-${density} bk-tone-${tone}">
75
+ <div class="bk-shell">
76
+ <aside class="bk-sidebar">
77
+ <div class="bk-sidebar-inner">
78
+ <div class="bk-sidebar-header">
79
+ ${lesson.meta.parentSlug ? `<div style="margin-top: 8px;"><a href="${lesson.meta.parentSlug}.html" class="bk-back-link" aria-label="Back to Chapter" style="margin-bottom: 12px;"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>Back to Chapter</a></div>` : `<div style="margin-top: 8px;"></div>`}
80
+ <div class="bk-sidebar-title">${escHtml(lesson.meta.title)}</div>
81
+ ${lesson.meta.author ? `<div class="bk-sidebar-author">By ${escHtml(lesson.meta.author)}</div>` : ""}
82
+ ${lesson.meta.tags?.length ? `<div class="bk-tag-row">${lesson.meta.tags.map((tag) => `<span>${escHtml(tag)}</span>`).join("")}</div>` : ""}
83
+ </div>
84
+ <nav class="bk-nav">${navHtml}</nav>
85
+ <div class="bk-sidebar-footer">
86
+ <button class="bk-icon-btn bk-settings-button" id="bk-settings-button" type="button" aria-expanded="false" aria-controls="bk-theme-panel" title="Display settings">
87
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
88
+ <span class="bk-sr-only">Display settings</span>
89
+ </button>
90
+ <div class="bk-theme-panel" id="bk-theme-panel" aria-label="Display settings" hidden>
91
+ <div class="bk-theme-row">
92
+ <span>Theme</span>
93
+ <div class="bk-segmented-control" id="bk-theme-icons">
94
+ <button type="button" class="bk-segment-btn ${theme === "light" ? "active" : ""}" data-theme="light" title="Light" aria-label="Light theme">
95
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>
96
+ </button>
97
+ <button type="button" class="bk-segment-btn ${theme === "dark" ? "active" : ""}" data-theme="dark" title="Dark" aria-label="Dark theme">
98
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>
99
+ </button>
100
+ <button type="button" class="bk-segment-btn ${theme === "auto" ? "active" : ""}" data-theme="auto" title="System" aria-label="System theme">
101
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
102
+ </button>
103
+ </div>
104
+ </div>
105
+ <div class="bk-theme-row">
106
+ <span>Palette</span>
107
+ <div class="bk-segmented-control" id="bk-palette-icons">
108
+ <button type="button" class="bk-segment-btn ${palette === "ink" ? "active" : ""}" data-palette="ink" title="Ink" aria-label="Ink palette">
109
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"></path></svg>
110
+ </button>
111
+ <button type="button" class="bk-segment-btn ${palette === "field" ? "active" : ""}" data-palette="field" title="Field" aria-label="Field palette">
112
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"></path><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"></path></svg>
113
+ </button>
114
+ <button type="button" class="bk-segment-btn ${palette === "ember" ? "active" : ""}" data-palette="ember" title="Ember" aria-label="Ember palette">
115
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0011 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 11-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 002.5 2.5z"></path></svg>
116
+ </button>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ </aside>
123
+ <button class="bk-sidebar-collapse-floating" id="bk-sidebar-collapse" aria-label="Collapse sidebar">
124
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
125
+ </button>
126
+ <main class="bk-main">
127
+ <button class="bk-sidebar-expand" id="bk-sidebar-expand" type="button" aria-label="Expand sidebar">
128
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
129
+ </button>
130
+ <article class="bk-content">
131
+ <header class="bk-hero">
132
+ <p class="bk-eyebrow">Interactive Lesson</p>
133
+ <h1 style="view-transition-name: title-${lesson.meta.slug}">${escHtml(lesson.meta.title)}</h1>
134
+ ${lesson.meta.description ? `<p class="bk-deck">${escHtml(lesson.meta.description)}</p>` : ""}
135
+ </header>
136
+ ${bodyHtml}
137
+ ${endNavHtml}
138
+ </article>
139
+ </main>
140
+ </div>
141
+ <script>
142
+ ${clientScript()}
143
+ </script>
144
+ </body>
145
+ </html>`;
146
+ }
147
+
148
+ // ─── CSS ──────────────────────────────────────────────────────────────────────
149
+
150
+ function pageCSS(): string {
151
+ return fs.readFileSync(
152
+ path.join(__dirname, "../styles/theme.css"),
153
+ "utf-8",
154
+ );
155
+ }
156
+
157
+ // ─── Client-side script ───────────────────────────────────────────────────────
158
+
159
+ function clientScript(): string {
160
+ return fs.readFileSync(path.join(__dirname, "../client/app.js"), "utf-8");
161
+ }
162
+
163
+ export { clientScript, pageCSS, renderNavItem, renderPage };