mr-md 2.0.0-beta → 2.1.1-beta

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.
package/src/builder.ts CHANGED
@@ -31,6 +31,10 @@ import type {
31
31
  YouTubeOptions,
32
32
  } from "./types.js";
33
33
 
34
+ import { fileURLToPath } from "url";
35
+ const __filename = fileURLToPath(import.meta.url);
36
+ const __dirname = path.dirname(__filename);
37
+
34
38
  function getCallerDir(): string | undefined {
35
39
  const err = new Error();
36
40
  const stack = err.stack?.split("\n");
@@ -47,24 +51,76 @@ function getCallerDir(): string | undefined {
47
51
  p = p.replace(/^file:\/\//, "");
48
52
  }
49
53
  if (p.startsWith("/") && p[2] === ":") {
50
- p = p.substring(1); // Handle Windows paths like /C:/
54
+ p = p.substring(1);
51
55
  }
52
-
53
56
  if (!builderFilePath) {
54
57
  builderFilePath = p;
55
58
  continue;
56
59
  }
57
-
58
60
  if (p === builderFilePath) {
59
61
  continue;
60
62
  }
61
-
62
63
  return path.dirname(p);
63
64
  }
64
65
  }
65
66
  return undefined;
66
67
  }
67
68
 
69
+
70
+
71
+ function copyAssets(outDir: string) {
72
+ const assetsDir = path.join(outDir, "assets");
73
+ if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir, { recursive: true });
74
+
75
+ const srcStylesDir = path.join(__dirname, "styles");
76
+ const srcClientDir = path.join(__dirname, "client");
77
+
78
+ const fallbackStylesDir = path.join(__dirname, "../src/styles");
79
+ const fallbackClientDir = path.join(__dirname, "../src/client");
80
+
81
+ const copyDir = (src: string, dest: string) => {
82
+ if (!fs.existsSync(src)) return;
83
+ if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
84
+ for (const file of fs.readdirSync(src)) {
85
+ const srcFile = path.join(src, file);
86
+ const destFile = path.join(dest, file);
87
+ if (fs.statSync(srcFile).isDirectory()) {
88
+ copyDir(srcFile, destFile);
89
+ } else {
90
+ fs.copyFileSync(srcFile, destFile);
91
+ }
92
+ }
93
+ };
94
+
95
+ if (fs.existsSync(srcStylesDir)) copyDir(srcStylesDir, assetsDir);
96
+ else copyDir(fallbackStylesDir, assetsDir);
97
+
98
+ if (fs.existsSync(srcClientDir)) copyDir(srcClientDir, assetsDir);
99
+ else copyDir(fallbackClientDir, assetsDir);
100
+ }
101
+
102
+ function mergeOptions(options: BuildOptions, callerDir?: string): BuildOptions {
103
+ const merged: BuildOptions = {
104
+ outDir: options.outDir ?? (callerDir ? path.join(callerDir, "out") : path.join(process.cwd(), "out")),
105
+ contentBase: options.contentBase ?? callerDir ?? process.cwd(),
106
+ theme: options.theme ?? "auto",
107
+ palette: options.palette ?? "ink",
108
+ strict: options.strict ?? process.env.NODE_ENV !== "development",
109
+ standalone: options.standalone ?? true,
110
+ };
111
+ for (const [k, v] of Object.entries(options)) {
112
+ if (v !== undefined && k !== "preset") {
113
+ (merged as any)[k] = v;
114
+ }
115
+ }
116
+ merged.preset = {
117
+ layout: options.preset?.layout ?? "lesson",
118
+ density: options.preset?.density ?? "comfortable",
119
+ tone: options.preset?.tone ?? "scholarly",
120
+ };
121
+ return merged;
122
+ }
123
+
68
124
  // ─── LessonBuilder ────────────────────────────────────────────────────────────
69
125
 
70
126
  export class LessonBuilder {
@@ -86,20 +142,7 @@ export class LessonBuilder {
86
142
  title,
87
143
  slug,
88
144
  };
89
- this.options = {
90
- outDir: options.outDir ?? (callerDir ? path.join(callerDir, "out") : "./out"),
91
- contentBase: options.contentBase ?? callerDir ?? ".",
92
- theme: options.theme ?? "auto",
93
- palette: options.palette ?? "ink",
94
- strict: options.strict ?? process.env.NODE_ENV !== "development",
95
- preset: {
96
- layout: "lesson",
97
- density: "comfortable",
98
- tone: "scholarly",
99
- ...options.preset,
100
- },
101
- ...options,
102
- };
145
+ this.options = mergeOptions(options, callerDir);
103
146
  }
104
147
 
105
148
  // ── Meta setters ────────────────────────────────────────────────────────────
@@ -489,11 +532,17 @@ export class LessonBuilder {
489
532
  const html = render(lesson, this.options);
490
533
 
491
534
  const outDir = path.resolve(this.options.outDir as string);
492
- if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
493
-
494
535
  const outPath = path.join(outDir, `${this.meta.slug}.html`);
536
+ const outPathDir = path.dirname(outPath);
537
+
538
+ if (!fs.existsSync(outPathDir)) fs.mkdirSync(outPathDir, { recursive: true });
539
+
495
540
  fs.writeFileSync(outPath, html, "utf-8");
496
541
 
542
+ if (this.options.standalone === false) {
543
+ copyAssets(outDir);
544
+ }
545
+
497
546
  const relPath = path.relative(process.cwd(), outPath);
498
547
  console.log(` ✓ Built lesson (${this.blocks.length} blocks) → ${relPath}`);
499
548
  return outPath;
@@ -658,20 +707,7 @@ export class ChapterBuilder {
658
707
  title,
659
708
  slug,
660
709
  };
661
- this.options = {
662
- outDir: options.outDir ?? (callerDir ? path.join(callerDir, "out") : "./out"),
663
- contentBase: options.contentBase ?? callerDir ?? ".",
664
- theme: options.theme ?? "auto",
665
- palette: options.palette ?? "ink",
666
- strict: options.strict ?? process.env.NODE_ENV !== "development",
667
- preset: {
668
- layout: "lesson",
669
- density: "comfortable",
670
- tone: "scholarly",
671
- ...options.preset,
672
- },
673
- ...options,
674
- };
710
+ this.options = mergeOptions(options, callerDir);
675
711
  }
676
712
 
677
713
  slug(slug: string): this {
@@ -723,11 +759,17 @@ export class ChapterBuilder {
723
759
  const html = renderChapter(chapterData, this.options);
724
760
 
725
761
  const outDir = path.resolve(this.options.outDir as string);
726
- if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
727
-
728
762
  const outPath = path.join(outDir, `${this.meta.slug}.html`);
763
+ const outPathDir = path.dirname(outPath);
764
+
765
+ if (!fs.existsSync(outPathDir)) fs.mkdirSync(outPathDir, { recursive: true });
766
+
729
767
  fs.writeFileSync(outPath, html, "utf-8");
730
768
 
769
+ if (this.options.standalone === false) {
770
+ copyAssets(outDir);
771
+ }
772
+
731
773
  const relPath = path.relative(process.cwd(), outPath);
732
774
  console.log(
733
775
  ` ✓ Built chapter (${this.lessonBuilders.length} lessons) → ${relPath}`,
package/src/cli/dev.ts CHANGED
@@ -18,7 +18,7 @@ export async function runDev(args: string[]) {
18
18
  for (const entry of entryPoints) {
19
19
  const entryPath = path.join(dir, entry);
20
20
  if (fs.existsSync(entryPath)) {
21
- exec(`NODE_ENV=development bun ${entryPath}`, (err, stdout, stderr) => {
21
+ exec(`NODE_ENV=development bun "${entryPath}"`, (err, stdout, stderr) => {
22
22
  if (err) console.error("Build failed:", stderr);
23
23
  else {
24
24
  console.log("Build successful.");
@@ -53,9 +53,10 @@ export async function runDev(args: string[]) {
53
53
  if (srv.upgrade(req)) return;
54
54
 
55
55
  const url = new URL(req.url);
56
- let filePath = path.join(outDir, url.pathname);
56
+ const decodedPath = decodeURIComponent(url.pathname);
57
+ let filePath = path.resolve(outDir, "." + decodedPath);
57
58
 
58
- if (filePath.endsWith("/")) {
59
+ if (filePath.endsWith(path.sep)) {
59
60
  const files = fs.existsSync(outDir) ? fs.readdirSync(outDir) : [];
60
61
  const htmlFiles = files.filter(f => f.endsWith(".html"));
61
62
  if (htmlFiles.includes("index.html")) {
@@ -67,26 +68,34 @@ export async function runDev(args: string[]) {
67
68
  } else if (htmlFiles.length > 0) {
68
69
  filePath = path.join(outDir, htmlFiles[0]);
69
70
  } else {
70
- filePath += "index.html";
71
+ filePath = path.join(outDir, "index.html");
71
72
  }
72
73
  }
73
74
  }
74
75
 
76
+ if (!filePath.startsWith(outDir + path.sep) && filePath !== outDir) {
77
+ return new Response("Forbidden", { status: 403 });
78
+ }
79
+
75
80
  if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
76
81
  if (filePath.endsWith(".html")) {
77
82
  const file = Bun.file(filePath);
78
83
  let text = await file.text();
79
- text = text.replace("</body>", `<script>
84
+ const lastBodyIndex = text.lastIndexOf("</body>");
85
+ if (lastBodyIndex !== -1) {
86
+ text = text.slice(0, lastBodyIndex) + `<script>
80
87
  const ws = new WebSocket("ws://localhost:3000/");
81
88
  ws.onmessage = (e) => { if (e.data === "reload") location.reload(); };
82
- </script></body>`);
89
+ </script></body>` + text.slice(lastBodyIndex + 7);
90
+ }
83
91
  return new Response(text, { headers: { "Content-Type": "text/html" } });
84
92
  }
85
93
  return new Response(Bun.file(filePath));
86
94
  }
87
95
 
88
- const srcPath = path.join(process.cwd(), dir, url.pathname);
89
- if (fs.existsSync(srcPath) && fs.statSync(srcPath).isFile()) {
96
+ const baseDir = path.resolve(process.cwd(), dir);
97
+ const srcPath = path.resolve(baseDir, "." + decodedPath);
98
+ if (srcPath.startsWith(baseDir + path.sep) && fs.existsSync(srcPath) && fs.statSync(srcPath).isFile()) {
90
99
  return new Response(Bun.file(srcPath));
91
100
  }
92
101
 
@@ -32,7 +32,7 @@ export async function runGenerate(args: string[]) {
32
32
  process.exit(1);
33
33
  }
34
34
 
35
- const name = rawName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
35
+ const name = rawName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "item";
36
36
  const cwd = process.cwd();
37
37
 
38
38
  switch (type) {
package/src/client/app.js CHANGED
@@ -15,11 +15,10 @@ function bkSimDoc(js, props, loop, dependencies) {
15
15
  ";window.bkSetupCalled=false;" +
16
16
  'window.bkCanvasPoint=function(e,c){const r=(c||e.currentTarget||e.target).getBoundingClientRect(),w=(c&&c.__bkLogicalW)||800,h=(c&&c.__bkLogicalH)||500;return{x:(e.clientX-r.left)*w/r.width,y:(e.clientY-r.top)*h/r.height}};' +
17
17
  'window.bkFitCanvas=function(c,reqW,reqH,o){if(!c)return{scale:1,width:reqW,height:reqH,cssScale:1};const d=window.devicePixelRatio||1;const w=reqW;const h=reqH;c.__bkLogicalW=w;c.__bkLogicalH=h;c.style.width=w+"px";c.style.height=h+"px";c.style.position="relative";c.style.left="auto";c.style.top="auto";c.style.transformOrigin="center center";const sx=window.innerWidth/w,sy=window.innerHeight/h,cssS=Math.max(sx,sy);c.style.transform="scale("+cssS+")";const pw=Math.max(1,Math.round(w*d)),ph=Math.max(1,Math.round(h*d));if(!o||o.bitmap!==false){if(c.width!==pw||c.height!==ph){c.width=pw;c.height=ph}}return{scale:d,width:w,height:h,cssScale:cssS}};' +
18
- 'window.bkSetup=function(w,h,f){window.bkSetupCalled=true;const c=document.getElementById("c");if(!c)return;const ctx=c.getContext("2d");let loopId=null;let fit=window.bkFitCanvas(c,w,h);function l(){if(window.innerWidth>=32&&window.innerHeight>=32){ctx.save();ctx.scale(fit.scale,fit.scale);f(ctx,fit.width,fit.height);ctx.restore()}if(window.__loop){loopId=requestAnimationFrame(l)}else{loopId=null}}function i(){if(window.innerWidth>=32&&window.innerHeight>=32){fit=window.bkFitCanvas(c,w,h);l()}else{requestAnimationFrame(i)}}i();window.addEventListener("resize",function(){fit=window.bkFitCanvas(c,w,h);if(!window.__loop&&window.innerWidth>=32&&window.innerHeight>=32&&!loopId){ctx.save();ctx.scale(fit.scale,fit.scale);f(ctx,fit.width,fit.height);ctx.restore()}});window.addEventListener("message",function(event){if(!event.data)return;if(event.data.type==="bk:play"){window.__loop=true;if(!loopId)loopId=requestAnimationFrame(l)}else if(event.data.type==="bk:pause"){window.__loop=false}});};' +
19
- 'window.addEventListener("message",function(event){if(!event.data||event.data.type!=="bk:set-props")return;window.__simProps=Object.assign({},window.__simProps,event.data.props);window.dispatchEvent(new CustomEvent("bk:props",{detail:window.__simProps}));});' +
18
+ 'window.bkSetup=function(w,h,f){window.bkSetupCalled=true;const c=document.getElementById("c");if(!c)return;const ctx=c.getContext("2d");let loopId=null;let fit=window.bkFitCanvas(c,w,h);function l(){if(window.innerWidth>=32&&window.innerHeight>=32){ctx.save();ctx.scale(fit.scale,fit.scale);f(ctx,fit.width,fit.height);ctx.restore()}if(window.__loop){loopId=requestAnimationFrame(l)}else{loopId=null}}function i(){if(window.innerWidth>=32&&window.innerHeight>=32){fit=window.bkFitCanvas(c,w,h);l()}else{requestAnimationFrame(i)}}i();window.addEventListener("resize",function(){fit=window.bkFitCanvas(c,w,h);if(!window.__loop&&window.innerWidth>=32&&window.innerHeight>=32&&!loopId){ctx.save();ctx.scale(fit.scale,fit.scale);f(ctx,fit.width,fit.height);ctx.restore()}});window.addEventListener("message",function(event){if(event.source!==window.parent)return;if(!event.data)return;if(event.data.type==="bk:play"){window.__loop=true;if(!loopId)loopId=requestAnimationFrame(l)}else if(event.data.type==="bk:pause"){window.__loop=false}else if(event.data.type==="bk:set-props"){window.__simProps=Object.assign({},window.__simProps,event.data.props);window.dispatchEvent(new CustomEvent("bk:props",{detail:window.__simProps}));}});};' +
20
19
  "try{" +
21
20
  js +
22
- '}catch(e){console.error("Simulation Error:",e);document.body.innerHTML="<div style=\'padding: 20px; color: red; font-family: monospace;\'>Error: "+e.message+"</div>"}' +
21
+ '}catch(e){console.error("Simulation Error:",e);const d=document.createElement("div");d.style.cssText="padding:20px;color:red;font-family:monospace";d.textContent="Error: "+e.message;document.body.innerHTML="";document.body.appendChild(d);}' +
23
22
  "if(!window.bkSetupCalled){function fallbackScale(){window.bkFitCanvas(document.getElementById('c'),800,500,{bitmap:false});}fallbackScale();window.addEventListener('resize', fallbackScale);}" +
24
23
  "</" + "script></body></html>"
25
24
  );
@@ -76,27 +75,14 @@ function bkWireMaximizeControls() {
76
75
  }
77
76
 
78
77
  function bkWireInteractiveFrames() {
79
- const activate = (e) => {
78
+ const interactiveHandler = (e) => {
80
79
  const obj = e.target.closest?.(".bk-object");
81
- if (!obj) return;
82
- const frame = obj.querySelector(".bk-embed-interactive");
83
- if (frame) {
84
- frame.classList.add("is-interactive");
85
- const iframe = frame.querySelector("iframe");
86
- if (iframe && iframe.contentWindow) {
87
- iframe.contentWindow.postMessage({ type: "bk:play" }, "*");
88
- }
89
- }
90
- };
91
- document.addEventListener("pointerdown", activate, { passive: true });
92
- document.addEventListener("focusin", activate, { passive: true });
80
+ const activateFrame = obj ? obj.querySelector(".bk-embed-interactive") : null;
93
81
 
94
- const exitInteractive = (e) => {
95
82
  document
96
83
  .querySelectorAll(".bk-embed-interactive.is-interactive")
97
84
  .forEach((frame) => {
98
- const container = frame.closest(".bk-object") || frame;
99
- if (!container.contains(e.target)) {
85
+ if (frame !== activateFrame) {
100
86
  frame.classList.remove("is-interactive");
101
87
  const iframe = frame.querySelector("iframe");
102
88
  if (iframe && iframe.contentWindow) {
@@ -104,9 +90,17 @@ function bkWireInteractiveFrames() {
104
90
  }
105
91
  }
106
92
  });
93
+
94
+ if (activateFrame && !activateFrame.classList.contains("is-interactive")) {
95
+ activateFrame.classList.add("is-interactive");
96
+ const iframe = activateFrame.querySelector("iframe");
97
+ if (iframe && iframe.contentWindow) {
98
+ iframe.contentWindow.postMessage({ type: "bk:play" }, "*");
99
+ }
100
+ }
107
101
  };
108
- document.addEventListener("pointerdown", exitInteractive, { passive: true });
109
- document.addEventListener("focusin", exitInteractive, { passive: true });
102
+ document.addEventListener("pointerdown", interactiveHandler, { passive: true });
103
+ document.addEventListener("focusin", interactiveHandler, { passive: true });
110
104
 
111
105
  const obs = new IntersectionObserver((entries) => {
112
106
  entries.forEach((e) => {
@@ -192,6 +186,9 @@ function bkBroadcastTheme(targetWindow) {
192
186
  }
193
187
 
194
188
  window.addEventListener("message", function(e) {
189
+ const isValidSource = Array.from(document.querySelectorAll("iframe")).some(f => f.contentWindow === e.source);
190
+ if (!isValidSource) return;
191
+
195
192
  if (e.data && e.data.type === "bk:request-theme") {
196
193
  // Small delay to ensure CSS has applied if this happens right on load
197
194
  requestAnimationFrame(() => bkBroadcastTheme(e.source));
@@ -266,13 +263,16 @@ function bkWireThemeControls() {
266
263
 
267
264
  if (savedTheme) {
268
265
  updateThemeBtn(savedTheme);
266
+ root.setAttribute("data-theme", savedTheme);
269
267
  }
270
268
  if (savedPalette) {
271
269
  const normalizedPalette = savedPalette === "green" ? "field" : savedPalette;
272
270
  updatePaletteBtn(normalizedPalette);
271
+ root.setAttribute("data-palette", normalizedPalette);
273
272
  }
274
273
  if (savedUi) {
275
274
  updateUiBtn(savedUi);
275
+ root.setAttribute("data-ui", savedUi);
276
276
  }
277
277
 
278
278
  button &&
@@ -344,15 +344,44 @@ function bkWireThemeControls() {
344
344
  }
345
345
 
346
346
  // Quiz interaction
347
- // biome-ignore lint/correctness/noUnusedVariables: Used in generated HTML
348
347
  function bkAnswer(btn, qid) {
349
- // biome-ignore lint/correctness/noUnusedVariables: Kept for clarity
350
- const isCorrect = btn.dataset.correct === "true";
351
348
  const question = document.getElementById(qid);
349
+ if (!question) return;
350
+ const quiz = question.closest(".bk-quiz");
351
+ const dataEl = quiz ? quiz.querySelector(".bk-quiz-data") : null;
352
+ let isCorrect = false;
353
+
354
+ if (dataEl) {
355
+ try {
356
+ const answers = JSON.parse(dataEl.textContent || "[]");
357
+ // qid is format "quiz-IDX-qQI"
358
+ const match = qid.match(/-q(\d+)$/);
359
+ if (match) {
360
+ const qi = parseInt(match[1], 10);
361
+ const optIdx = parseInt(btn.dataset.optIdx, 10);
362
+ isCorrect = answers[qi] === optIdx;
363
+ }
364
+ } catch(e) {}
365
+ }
366
+
352
367
  question.querySelectorAll(".bk-opt").forEach((b) => {
353
- if (b.dataset.correct === "true") {
368
+ b.disabled = true; // Disable buttons for screen readers
369
+ const optIdx = parseInt(b.dataset.optIdx, 10);
370
+ // If we know the answer, highlight it
371
+ if (dataEl) {
372
+ try {
373
+ const answers = JSON.parse(dataEl.textContent || "[]");
374
+ const match = qid.match(/-q(\d+)$/);
375
+ if (match && answers[parseInt(match[1], 10)] === optIdx) {
376
+ b.classList.add("correct");
377
+ return;
378
+ }
379
+ } catch(e) {}
380
+ }
381
+
382
+ if (b === btn && isCorrect) {
354
383
  b.classList.add("correct");
355
- } else if (b === btn) {
384
+ } else if (b === btn && !isCorrect) {
356
385
  b.classList.add("wrong");
357
386
  } else {
358
387
  b.classList.add("disabled");
@@ -234,7 +234,17 @@ function renderBlockInner(
234
234
  loading="lazy"
235
235
  style="width:100%;height:100%;border:none;display:block;">
236
236
  </iframe>
237
- </div>`,
237
+ </div>
238
+ <script>
239
+ if (!window._bkYtBlurSetup) {
240
+ window._bkYtBlurSetup = true;
241
+ window.addEventListener('mousemove', function() {
242
+ if (document.activeElement && document.activeElement.tagName === 'IFRAME') {
243
+ document.activeElement.blur();
244
+ }
245
+ }, { passive: true });
246
+ }
247
+ </script>`,
238
248
  "neutral",
239
249
  ),
240
250
  };
@@ -264,7 +274,7 @@ function renderBlockInner(
264
274
  block.label,
265
275
  block.caption,
266
276
  `<div class="bk-columns" style="grid-template-columns:${block.columns
267
- .map((column) => column.width ?? "minmax(0, 1fr)")
277
+ .map((column) => escAttr(column.width ?? "minmax(0, 1fr)"))
268
278
  .join(" ")}">
269
279
  ${block.columns
270
280
  .map((column) => {
@@ -316,6 +326,7 @@ function renderBlockInner(
316
326
  <div class="bk-quiz-body">
317
327
  ${quiz.questions.map((q, qi) => renderQuestion(q, `quiz-${idx}`, qi)).join("\n")}
318
328
  </div>
329
+ <script type="application/json" class="bk-quiz-data">${escapeScriptJson(quiz.questions.map(q => q.answer))}</script>
319
330
  </div>`,
320
331
  navItems: [{
321
332
  id: `quiz-${idx}`,
@@ -338,7 +349,7 @@ function renderQuestion(q: QuizQuestion, quizId: string, qi: number): string {
338
349
  const options = q.options
339
350
  .map(
340
351
  (opt, oi) => `
341
- <button class="bk-opt" data-correct="${oi === q.answer}" onclick="bkAnswer(this,'${qid}')">
352
+ <button class="bk-opt" data-opt-idx="${oi}" onclick="bkAnswer(this,'${escAttr(qid)}')">
342
353
  <span class="bk-opt-dot"></span><span class="bk-opt-text">${mdInline(opt)}</span>
343
354
  </button>`,
344
355
  )
@@ -511,10 +522,14 @@ window.addEventListener("message", (event) => {
511
522
  window.dispatchEvent(new CustomEvent("bk:props", { detail: window.__simProps }));
512
523
  });
513
524
  try {
514
- ${js}
525
+ ${js.replace(/<\/script>/gi, "<\\/script>")}
515
526
  } catch (e) {
516
527
  console.error("Simulation Error:", e);
517
- document.body.innerHTML = '<div style="padding:20px;color:red;font-family:monospace">Error: ' + e.message + '</div>';
528
+ const errDiv = document.createElement('div');
529
+ errDiv.style.cssText = "padding:20px;color:red;font-family:monospace";
530
+ errDiv.textContent = 'Error: ' + e.message;
531
+ document.body.innerHTML = '';
532
+ document.body.appendChild(errDiv);
518
533
  }
519
534
  if (!window.bkSetupCalled) {
520
535
  function fallbackScale() {
@@ -525,13 +540,7 @@ if (!window.bkSetupCalled) {
525
540
  }
526
541
  </script>
527
542
  </body></html>`;
528
- // We use double quotes for the srcdoc attribute, so we must escape them.
529
- return doc
530
- .replace(/&/g, "&amp;")
531
- .replace(/"/g, "&quot;")
532
- .replace(/'/g, "&#39;")
533
- .replace(/</g, "&lt;")
534
- .replace(/>/g, "&gt;");
543
+ return escAttr(doc);
535
544
  }
536
545
 
537
546
  export { blockChrome, renderBlock, renderBlockInner };
@@ -42,11 +42,13 @@ function renderEndNav(lesson: Lesson): string {
42
42
  </nav>`;
43
43
  }
44
44
 
45
- function renderPage(
46
- lesson: Lesson,
47
- navItems: NavItem[],
48
- bodyHtml: string,
45
+ export function renderLayout(
46
+ title: string,
47
+ description: string | undefined,
48
+ navHtml: string,
49
+ contentHtml: string,
49
50
  opts: BuildOptions,
51
+ extraSidebar: string = "",
50
52
  ): string {
51
53
  const theme = opts.theme ?? "light";
52
54
  const schemeAttr = `data-theme="${theme}"`;
@@ -56,8 +58,7 @@ function renderPage(
56
58
  const tone = preset.tone ?? "scholarly";
57
59
  const palette = opts.palette ?? "ink";
58
60
  const ui = opts.ui ?? "standard";
59
- const navHtml = navItems.map(renderNavItem).join("\n");
60
- const endNavHtml = renderEndNav(lesson);
61
+ const safeFont = opts.font ? opts.font.replace(/[;{}<>\\]/g, "") : "";
61
62
 
62
63
  return `<!DOCTYPE html>
63
64
  <html lang="en" data-palette="${palette}" data-ui="${ui}" ${schemeAttr}>
@@ -75,26 +76,21 @@ function renderPage(
75
76
  </script>
76
77
  <meta charset="UTF-8">
77
78
  <meta name="viewport" content="width=device-width, initial-scale=1">
78
- <title>${escHtml(lesson.meta.title)}</title>
79
- ${lesson.meta.description ? `<meta name="description" content="${escHtml(lesson.meta.description)}">` : ""}
79
+ <title>${escHtml(title)}</title>
80
+ ${description ? `<meta name="description" content="${escHtml(description)}">` : ""}
80
81
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.47/dist/katex.min.css">
81
82
  <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,650;9..144,760&family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&family=Archivo:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Syne:wght@600;700;800&family=Playfair+Display:ital,wght@0,400..700;1,400..700&family=Lora:ital,wght@0,400..700;1,400..700&display=swap" rel="stylesheet">
82
83
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/styles/github-dark.min.css">
83
84
  ${opts.head ?? ""}
84
- <style>
85
- ${opts.font ? `:root { --font-sans: ${opts.font}, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }` : ""}
86
- ${pageCSS()}
87
- </style>
85
+ ${opts.standalone === false ? `<link rel="stylesheet" href="assets/theme.css?v=${Date.now()}">` : `<style>\n${safeFont ? `:root { --font-sans: ${safeFont}, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }` : ""}\n${pageCSS()}\n</style>`}
88
86
  </head>
89
87
  <body class="bk-layout-${layout} bk-density-${density} bk-tone-${tone}">
90
88
  <div class="bk-shell">
91
89
  <aside class="bk-sidebar">
92
90
  <div class="bk-sidebar-inner">
93
91
  <div class="bk-sidebar-header">
94
- ${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>`}
95
- <div class="bk-sidebar-title">${escHtml(lesson.meta.title)}</div>
96
- ${lesson.meta.author ? `<div class="bk-sidebar-author">By ${escHtml(lesson.meta.author)}</div>` : ""}
97
- ${lesson.meta.tags?.length ? `<div class="bk-tag-row">${lesson.meta.tags.map((tag) => `<span>${escHtml(tag)}</span>`).join("")}</div>` : ""}
92
+ ${extraSidebar}
93
+ <div class="bk-sidebar-title">${escHtml(title)}</div>
98
94
  </div>
99
95
  <nav class="bk-nav">${navHtml}</nav>
100
96
  <div class="bk-sidebar-footer">
@@ -156,6 +152,30 @@ ${pageCSS()}
156
152
  <button class="bk-sidebar-expand" id="bk-sidebar-expand" type="button" aria-label="Expand sidebar">
157
153
  <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>
158
154
  </button>
155
+ ${contentHtml}
156
+ </main>
157
+ </div>
158
+ ${opts.standalone === false ? `<script src="assets/app.js?v=${Date.now()}"></script>` : `<script>\n${clientScript()}\n</script>`}
159
+ </body>
160
+ </html>`;
161
+ }
162
+
163
+ function renderPage(
164
+ lesson: Lesson,
165
+ navItems: NavItem[],
166
+ bodyHtml: string,
167
+ opts: BuildOptions,
168
+ ): string {
169
+ const navHtml = navItems.map(renderNavItem).join("\n");
170
+ const endNavHtml = renderEndNav(lesson);
171
+
172
+ const extraSidebar = `
173
+ ${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>`}
174
+ `;
175
+ const authorHtml = lesson.meta.author ? `<div class="bk-sidebar-author">By ${escHtml(lesson.meta.author)}</div>` : "";
176
+ const tagsHtml = lesson.meta.tags?.length ? `<div class="bk-tag-row">${lesson.meta.tags.map((tag) => `<span>${escHtml(tag)}</span>`).join("")}</div>` : "";
177
+
178
+ const contentHtml = `
159
179
  <article class="bk-content">
160
180
  <header class="bk-hero">
161
181
  <p class="bk-eyebrow">Interactive Lesson</p>
@@ -165,13 +185,16 @@ ${pageCSS()}
165
185
  ${bodyHtml}
166
186
  ${endNavHtml}
167
187
  </article>
168
- </main>
169
- </div>
170
- <script>
171
- ${clientScript()}
172
- </script>
173
- </body>
174
- </html>`;
188
+ `;
189
+
190
+ return renderLayout(
191
+ lesson.meta.title,
192
+ lesson.meta.description,
193
+ navHtml,
194
+ contentHtml,
195
+ opts,
196
+ extraSidebar + authorHtml + tagsHtml
197
+ );
175
198
  }
176
199
 
177
200
  // ─── CSS ──────────────────────────────────────────────────────────────────────