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.
@@ -1,6 +1,6 @@
1
1
  import type { BuildOptions, Chapter, Lesson } from "../types.js";
2
2
  import { escAttr, escHtml, renderBlock } from "./blocks.js";
3
- import { clientScript, pageCSS, renderPage } from "./html.js"; // Used in renderChapter
3
+ import { clientScript, pageCSS, renderPage, renderLayout } from "./html.js"; // Used in renderChapter
4
4
  import type { NavItem } from "./utils.js";
5
5
 
6
6
  // ─── Main render function ─────────────────────────────────────────────────────
@@ -316,89 +316,7 @@ export function renderChapter(
316
316
  }
317
317
  `;
318
318
 
319
- return `<!DOCTYPE html>
320
- <html lang="en" data-palette="${palette}" data-ui="${ui}" ${schemeAttr}>
321
- <head>
322
- <meta charset="UTF-8">
323
- <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">
324
- <meta name="viewport" content="width=device-width, initial-scale=1">
325
- <title>${escHtml(chapter.meta.title)}</title>
326
- ${chapter.meta.description ? `<meta name="description" content="${escHtml(chapter.meta.description)}">` : ""}
327
- ${opts.head ?? ""}
328
- <style>
329
- ${opts.font ? `:root { --font-sans: ${opts.font}, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }` : ""}
330
- ${pageCSS()}
331
- ${chapterStyles}
332
- </style>
333
- </head>
334
- <body class="bk-layout-${layout} bk-density-${density} bk-tone-${tone}">
335
- <div class="bk-shell">
336
- <aside class="bk-sidebar">
337
- <div class="bk-sidebar-inner">
338
- <div class="bk-sidebar-header">
339
- <div style="margin-top: 8px;"></div>
340
- <div class="bk-sidebar-title">${escHtml(chapter.meta.title)}</div>
341
- </div>
342
- <nav class="bk-nav">${navHtml}</nav>
343
- <div class="bk-sidebar-footer">
344
- <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">
345
- <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>
346
- <span class="bk-sr-only">Display settings</span>
347
- </button>
348
- <div class="bk-theme-panel" id="bk-theme-panel" aria-label="Display settings" hidden>
349
- <div class="bk-theme-row">
350
- <span>Theme</span>
351
- <div class="bk-segmented-control" id="bk-theme-icons">
352
- <button type="button" class="bk-segment-btn ${theme === "light" ? "active" : ""}" data-theme="light" title="Light" aria-label="Light theme">
353
- <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>
354
- </button>
355
- <button type="button" class="bk-segment-btn ${theme === "auto" ? "active" : (!theme ? "active" : "")}" data-theme="auto" title="System" aria-label="System theme">
356
- <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>
357
- </button>
358
- <button type="button" class="bk-segment-btn ${theme === "dark" ? "active" : ""}" data-theme="dark" title="Dark" aria-label="Dark theme">
359
- <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>
360
- </button>
361
- </div>
362
- </div>
363
- <div class="bk-theme-row">
364
- <span>Palette</span>
365
- <div class="bk-segmented-control" id="bk-palette-icons">
366
- <button type="button" class="bk-segment-btn ${palette === "ink" ? "active" : ""}" data-palette="ink" title="Ink" aria-label="Ink palette">
367
- <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>
368
- </button>
369
- <button type="button" class="bk-segment-btn ${palette === "field" ? "active" : ""}" data-palette="field" title="Field" aria-label="Field palette">
370
- <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 12"></path></svg>
371
- </button>
372
- <button type="button" class="bk-segment-btn ${palette === "ember" ? "active" : ""}" data-palette="ember" title="Ember" aria-label="Ember palette">
373
- <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>
374
- </button>
375
- </div>
376
- </div>
377
- <div class="bk-theme-row">
378
- <span>UI</span>
379
- <div class="bk-segmented-control" id="bk-ui-icons">
380
- <button type="button" class="bk-segment-btn ${ui === 'standard' ? 'active' : ''}" data-ui="standard" title="Standard" aria-label="Standard UI">
381
- <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="3" y="3" width="18" height="18" rx="4" ry="4"></rect><line x1="9" y1="3" x2="9" y2="21"></line></svg>
382
- </button>
383
- <button type="button" class="bk-segment-btn ${ui === 'neo' ? 'active' : ''}" data-ui="neo" title="Neo Brutalist" aria-label="Neo UI">
384
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter"><rect x="3" y="3" width="18" height="18"></rect><path d="M3 10h18"></path><path d="M10 10v11"></path></svg>
385
- </button>
386
- <button type="button" class="bk-segment-btn ${ui === 'playful' ? 'active' : ''}" data-ui="playful" title="Playful" aria-label="Playful UI">
387
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="6" ry="6"></rect><circle cx="8.5" cy="8.5" r="1.5" fill="currentColor"></circle><circle cx="15.5" cy="15.5" r="1.5" fill="currentColor"></circle></svg>
388
- </button>
389
- </div>
390
- </div>
391
- </div>
392
- </div>
393
- </div>
394
- </aside>
395
- <button class="bk-sidebar-collapse-floating" id="bk-sidebar-collapse" aria-label="Collapse sidebar">
396
- <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>
397
- </button>
398
- <main class="bk-main">
399
- <button class="bk-sidebar-expand" id="bk-sidebar-expand" type="button" aria-label="Expand sidebar">
400
- <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>
401
- </button>
319
+ const contentHtml = `
402
320
  <article class="bk-content" style="max-width: 1000px; margin: 0 auto;">
403
321
  <header class="bk-hero" style="border-bottom: none;">
404
322
  <p class="bk-eyebrow">Chapter</p>
@@ -407,11 +325,15 @@ ${chapterStyles}
407
325
  </header>
408
326
  ${timelineHtml}
409
327
  </article>
410
- </main>
411
- </div>
412
- <script>
413
- ${clientScript()}
414
- </script>
415
- </body>
416
- </html>`;
328
+ `;
329
+
330
+ const headHtml = `<style>${chapterStyles}</style>`;
331
+
332
+ return renderLayout(
333
+ chapter.meta.title,
334
+ chapter.meta.description,
335
+ navHtml,
336
+ contentHtml,
337
+ { ...opts, head: (opts.head ?? "") + headHtml }
338
+ );
417
339
  }
@@ -52,15 +52,16 @@ function mdToHtml(md: string): { html: string; title: string; headings: { id: st
52
52
 
53
53
  // Restore code blocks
54
54
  codeBlocks.forEach((match, id) => {
55
- processedMd = processedMd.replace(`@@BK_CODE_${id}@@`, () => match);
55
+ processedMd = processedMd.replaceAll(`@@BK_CODE_${id}@@`, () => match);
56
56
  });
57
57
 
58
58
  const headings: { id: string; text: string; level: number }[] = [];
59
+ const idPrefix = Math.random().toString(36).substring(2, 6);
59
60
  let headingIdCounter = 0;
60
61
 
61
62
  const renderer = new marked.Renderer();
62
63
  renderer.heading = ({ tokens, depth, text }) => {
63
- const id = `bk-heading-${headingIdCounter++}`;
64
+ const id = `bk-heading-${idPrefix}-${headingIdCounter++}`;
64
65
  if (depth === 1 || depth === 2) {
65
66
  const plainText = text.replace(/<[^>]+>/g, "");
66
67
  headings.push({ id, text: plainText, level: depth });
@@ -77,12 +78,12 @@ function mdToHtml(md: string): { html: string; title: string; headings: { id: st
77
78
  displayMode: true,
78
79
  });
79
80
  // marked might wrap block placeholders in <p>
80
- html = html.replace(
81
+ html = html.replaceAll(
81
82
  `<p>@@BK_MATH_BLOCK_${id}@@</p>`,
82
83
  () => `<div class="bk-math-block">${rendered}</div>`,
83
84
  );
84
85
  // Fallback if not wrapped in <p>
85
- html = html.replace(
86
+ html = html.replaceAll(
86
87
  `@@BK_MATH_BLOCK_${id}@@`,
87
88
  () => `<div class="bk-math-block">${rendered}</div>`,
88
89
  );
@@ -93,7 +94,7 @@ function mdToHtml(md: string): { html: string; title: string; headings: { id: st
93
94
  throwOnError: false,
94
95
  displayMode: false,
95
96
  });
96
- html = html.replace(`@@BK_MATH_INLINE_${id}@@`, () => rendered);
97
+ html = html.replaceAll(`@@BK_MATH_INLINE_${id}@@`, () => rendered);
97
98
  });
98
99
 
99
100
  return { html, title, headings };
@@ -149,7 +150,7 @@ function mdInline(text: string): string {
149
150
  throwOnError: false,
150
151
  displayMode: false,
151
152
  });
152
- html = html.replace(`@@BK_MATH_INLINE_${id}@@`, () => rendered);
153
+ html = html.replaceAll(`@@BK_MATH_INLINE_${id}@@`, () => rendered);
153
154
  });
154
155
 
155
156
  return html;
@@ -161,24 +162,63 @@ function renderSimulationControls(
161
162
  const props = block.props ?? {};
162
163
  const keys = Object.keys(block.tunables ?? props).filter((key) => {
163
164
  const value = props[key];
164
- return typeof value === "number" || typeof value === "boolean";
165
+ return typeof value === "number" || typeof value === "boolean" || typeof value === "string";
165
166
  });
166
167
 
167
168
  if (!keys.length || block.controls === "observe") return "";
168
169
 
170
+ const controls = keys.map((key) => {
171
+ const value = props[key];
172
+ const control = block.tunables?.[key] ?? {};
173
+ let type = control.type;
174
+ if (!type) {
175
+ if (typeof value === "boolean") type = "boolean";
176
+ else if (typeof value === "string") type = "text";
177
+ else type = "range";
178
+ }
179
+ const label = escHtml(control.label ?? key.replace(/([A-Z])/g, " $1"));
180
+ return { key, value, control, type, label };
181
+ });
182
+
183
+ const ranges = controls.filter((c) => c.type === "range");
184
+ const booleans = controls.filter((c) => c.type === "boolean");
185
+ const others = controls.filter((c) => c.type === "text" || c.type === "number");
186
+
187
+ const sortedControls = [...ranges, ...others, ...booleans];
188
+
189
+ let firstBooleanRendered = false;
190
+
169
191
  return `<div class="bk-sim-controls" aria-label="Simulation controls">
170
- ${keys
171
- .map((key) => {
172
- const value = props[key];
173
- const control = block.tunables?.[key] ?? {};
174
- const label = escHtml(control.label ?? key.replace(/([A-Z])/g, " $1"));
175
- if (typeof value === "boolean") {
176
- return `<label class="bk-sim-toggle">
192
+ ${sortedControls
193
+ .map(({ key, value, control, type, label }) => {
194
+ if (type === "boolean") {
195
+ const isFirst = !firstBooleanRendered;
196
+ firstBooleanRendered = true;
197
+ const extraClass = isFirst ? " bk-sim-toggle--first" : "";
198
+ return `<label class="bk-sim-toggle${extraClass}">
177
199
  <input type="checkbox" data-bk-prop="${escAttr(key)}" ${value ? "checked" : ""}>
178
200
  <span>${label}</span>
179
201
  </label>`;
180
202
  }
181
203
 
204
+ if (type === "text") {
205
+ return `<label class="bk-sim-text">
206
+ <span>${label}</span>
207
+ <input type="text" data-bk-prop="${escAttr(key)}" value="${escAttr(String(value))}">
208
+ </label>`;
209
+ }
210
+
211
+ if (type === "number") {
212
+ const min = control.min ?? "";
213
+ const max = control.max ?? "";
214
+ const step = control.step ?? "any";
215
+ return `<label class="bk-sim-number">
216
+ <span>${label}</span>
217
+ <input type="number" data-bk-prop="${escAttr(key)}" min="${min}" max="${max}" step="${step}" value="${value}">
218
+ </label>`;
219
+ }
220
+
221
+ // type === "range"
182
222
  const min = control.min ?? Math.min(0, Number(value));
183
223
  const max = control.max ?? Math.max(10, Number(value) * 2);
184
224
  const step = control.step ?? 1;
@@ -45,6 +45,11 @@ function resolveContent(
45
45
  }
46
46
  }
47
47
 
48
+ const baseDir = path.resolve(options.contentBase ?? ".");
49
+ if (!filePath.startsWith(baseDir) && options.strict !== false) {
50
+ throw new Error(`Security Error: Path traversal attempt outside contentBase: ${filePath}`);
51
+ }
52
+
48
53
  if (fs.existsSync(filePath)) {
49
54
  const stat = fs.statSync(filePath);
50
55
  if (stat.isFile()) {
@@ -74,14 +79,27 @@ function resolveContent(
74
79
  function resolveAssetSrc(src: string, options: BuildOptions): string {
75
80
  if (/^(https?:|data:)/.test(src)) return src;
76
81
 
77
- let isWebAbsolute = src.startsWith("/") && !fs.existsSync(src);
82
+ const hashIndex = src.indexOf("#");
83
+ const queryIndex = src.indexOf("?");
84
+ const breakIndex = hashIndex !== -1 && queryIndex !== -1
85
+ ? Math.min(hashIndex, queryIndex)
86
+ : Math.max(hashIndex, queryIndex);
87
+
88
+ let cleanSrc = src;
89
+ let suffix = "";
90
+ if (breakIndex !== -1) {
91
+ cleanSrc = src.substring(0, breakIndex);
92
+ suffix = src.substring(breakIndex);
93
+ }
78
94
 
79
- let filePath = path.isAbsolute(src)
80
- ? src
81
- : path.resolve(options.contentBase ?? ".", src);
95
+ let isWebAbsolute = cleanSrc.startsWith("/") && !fs.existsSync(cleanSrc);
82
96
 
83
- if (src.startsWith("/") && !fs.existsSync(filePath)) {
84
- const fallbackPath = path.resolve(options.contentBase ?? ".", src.slice(1));
97
+ let filePath = path.isAbsolute(cleanSrc)
98
+ ? cleanSrc
99
+ : path.resolve(options.contentBase ?? ".", cleanSrc);
100
+
101
+ if (cleanSrc.startsWith("/") && !fs.existsSync(filePath)) {
102
+ const fallbackPath = path.resolve(options.contentBase ?? ".", cleanSrc.slice(1));
85
103
  if (fs.existsSync(fallbackPath)) {
86
104
  filePath = fallbackPath;
87
105
  isWebAbsolute = false; // We found it locally, so don't treat it as a web URL
@@ -112,7 +130,7 @@ function resolveAssetSrc(src: string, options: BuildOptions): string {
112
130
  fs.copyFileSync(filePath, outPath);
113
131
  }
114
132
 
115
- return `assets/${filename}`;
133
+ return `assets/${filename}${suffix}`;
116
134
  }
117
135
 
118
136
  export { resolveAssetSrc, resolveContent };