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,401 @@
1
+ import type { BuildOptions, Chapter, Lesson } from "../types.js";
2
+ import { escAttr, escHtml, renderBlock } from "./blocks.js";
3
+ import { clientScript, pageCSS, renderPage } from "./html.js"; // Used in renderChapter
4
+ import type { NavItem } from "./utils.js";
5
+
6
+ // ─── Main render function ─────────────────────────────────────────────────────
7
+
8
+ export function render(lesson: Lesson, opts: BuildOptions = {}): string {
9
+ const bodyItems: string[] = [];
10
+ const structuredNavItems: NavItem[] = [];
11
+
12
+ lesson.blocks.forEach((block, idx) => {
13
+ const { html, navItem } = renderBlock(block, idx, opts);
14
+ bodyItems.push(html);
15
+ if (navItem) {
16
+ structuredNavItems.push(navItem);
17
+ }
18
+ });
19
+
20
+ return renderPage(lesson, structuredNavItems, bodyItems.join("\n"), opts);
21
+ }
22
+
23
+ // ─── Chapter Rendering ────────────────────────────────────────────────────────
24
+
25
+ export function renderChapter(
26
+ chapter: Chapter,
27
+ opts: BuildOptions = {},
28
+ ): string {
29
+ const theme = opts.theme ?? "auto";
30
+ const schemeAttr = theme === "auto" ? "" : `data-theme="${theme}"`;
31
+ const preset = opts.preset ?? {};
32
+ const layout = preset.layout ?? "lesson";
33
+ const density = preset.density ?? "comfortable";
34
+ const tone = preset.tone ?? "scholarly";
35
+ const palette = opts.palette ?? "ink";
36
+
37
+ const navHtml = chapter.lessons
38
+ .map(
39
+ (l) =>
40
+ `<a href="${escAttr(l.meta.slug)}.html" class="bk-nav-item bk-nav-chapter">${escHtml(l.meta.title)}</a>`,
41
+ )
42
+ .join("\n");
43
+
44
+ const timelineHtml = `
45
+ <div class="bk-chapter-timeline-wrapper">
46
+ <div class="bk-chapter-timeline">
47
+ ${chapter.lessons
48
+ .map(
49
+ (lesson, idx) => `
50
+ <a href="${escAttr(lesson.meta.slug)}.html" class="bk-timeline-card bk-status-${lesson.meta.status ?? "unread"}">
51
+ <div class="bk-timeline-node"></div>
52
+ <div class="bk-timeline-content">
53
+ <h3 class="bk-timeline-title" style="view-transition-name: title-${escAttr(lesson.meta.slug)}">${escHtml(lesson.meta.title)}</h3>
54
+ ${lesson.meta.description ? `<p class="bk-timeline-desc">${escHtml(lesson.meta.description)}</p>` : ""}
55
+ <span class="bk-timeline-action">${lesson.meta.status === "read" ? "Read again" : "Start Lesson"} <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="M5 12h14m-7-7 7 7-7 7"/></svg></span>
56
+ </div>
57
+ </a>
58
+ `,
59
+ )
60
+ .join("")}
61
+ </div>
62
+ </div>
63
+ <script>
64
+ if (!CSS.supports('(animation-timeline: view()) and (animation-range: entry)')) {
65
+ const observer = new IntersectionObserver(
66
+ (entries) => {
67
+ for (const entry of entries) {
68
+ if (entry.isIntersecting) {
69
+ entry.target.classList.add('js-visible');
70
+ entry.target.classList.remove('js-hidden');
71
+ observer.unobserve(entry.target);
72
+ }
73
+ }
74
+ },
75
+ { threshold: 0.1 }
76
+ );
77
+ document.querySelectorAll('.bk-timeline-card').forEach((el) => {
78
+ el.classList.add('js-hidden');
79
+ observer.observe(el);
80
+ });
81
+ }
82
+ </script>`;
83
+
84
+ const chapterStyles = `
85
+ .bk-chapter-timeline-wrapper {
86
+ position: relative;
87
+ width: 100%;
88
+ padding: 1rem 0;
89
+ z-index: 1;
90
+ }
91
+
92
+ .bk-chapter-timeline {
93
+ display: flex;
94
+ flex-direction: column;
95
+ gap: 3rem;
96
+ padding: 3rem 0;
97
+ position: relative;
98
+ max-width: 800px;
99
+ margin: 0 auto;
100
+ }
101
+
102
+ /* Minimalist vertical connecting line */
103
+ .bk-chapter-timeline::before {
104
+ content: '';
105
+ position: absolute;
106
+ left: 20px;
107
+ top: 3rem;
108
+ bottom: 3rem;
109
+ width: 1px;
110
+ background: var(--line);
111
+ z-index: 0;
112
+ transform: translateX(-50%);
113
+ }
114
+
115
+ .bk-timeline-card {
116
+ display: flex;
117
+ align-items: stretch;
118
+ gap: 2rem;
119
+ text-decoration: none !important;
120
+ border: none !important;
121
+ color: inherit;
122
+ position: relative;
123
+ z-index: 1;
124
+ opacity: 1;
125
+ transform: none;
126
+ }
127
+
128
+ /* Subtle scroll-driven animations */
129
+ @media (prefers-reduced-motion: no-preference) {
130
+ @supports ((animation-timeline: view()) and (animation-range: entry)) {
131
+ .bk-timeline-card {
132
+ animation-name: slide-fade-in;
133
+ animation-fill-mode: both;
134
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
135
+ animation-timeline: view(block);
136
+ animation-range: entry 5% cover 20%;
137
+ }
138
+ @keyframes slide-fade-in {
139
+ 0% { opacity: 0; transform: translateY(20px); }
140
+ 100% { opacity: 1; transform: translateY(0); }
141
+ }
142
+ }
143
+ }
144
+
145
+ /* Fallback for browsers without animation-timeline support */
146
+ .bk-timeline-card.js-hidden {
147
+ opacity: 0;
148
+ transform: translateY(20px);
149
+ }
150
+ .bk-timeline-card.js-visible {
151
+ opacity: 1;
152
+ transform: translateY(0);
153
+ transition: opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1), transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
154
+ }
155
+
156
+ .bk-timeline-node {
157
+ width: 40px;
158
+ height: 40px;
159
+ border-radius: 50%;
160
+ background: var(--paper);
161
+ border: 1px solid var(--accent);
162
+ display: flex;
163
+ align-items: center;
164
+ justify-content: center;
165
+ flex-shrink: 0;
166
+ transition: all 0.3s ease;
167
+ position: relative;
168
+ box-shadow: 0 2px 4px rgba(0,0,0,0.02);
169
+ }
170
+ .bk-timeline-node::after {
171
+ content: '';
172
+ width: 10px;
173
+ height: 10px;
174
+ border-radius: 50%;
175
+ background: var(--accent);
176
+ transition: all 0.3s ease;
177
+ }
178
+ .bk-timeline-card:hover .bk-timeline-node {
179
+ background: var(--accent);
180
+ }
181
+ .bk-timeline-card:hover .bk-timeline-node::after {
182
+ background: var(--paper);
183
+ }
184
+
185
+ .bk-timeline-content {
186
+ background: var(--paper);
187
+ padding: 2rem;
188
+ border-radius: 12px;
189
+ border: 1px solid var(--line);
190
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
191
+ flex: 1;
192
+ transition: all 0.3s ease;
193
+ display: flex;
194
+ flex-direction: column;
195
+ justify-content: center;
196
+ position: relative;
197
+ }
198
+ .bk-timeline-card:hover .bk-timeline-content {
199
+ border-color: color-mix(in srgb, var(--accent) 30%, var(--line));
200
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
201
+ transform: translateY(-2px);
202
+ }
203
+
204
+ .bk-timeline-title {
205
+ margin: 0 0 0.5rem 0;
206
+ font-family: var(--font-display);
207
+ font-size: 1.6rem;
208
+ color: var(--ink);
209
+ width: fit-content;
210
+ transition: color 0.2s ease;
211
+ }
212
+ .bk-timeline-card:hover .bk-timeline-title {
213
+ color: var(--accent);
214
+ }
215
+
216
+ .bk-timeline-desc {
217
+ margin: 0 0 1.5rem 0;
218
+ color: var(--muted);
219
+ line-height: 1.6;
220
+ font-size: 1.05rem;
221
+ }
222
+
223
+ .bk-timeline-action {
224
+ font-weight: 500;
225
+ color: var(--accent);
226
+ display: inline-flex;
227
+ align-items: center;
228
+ gap: 0.4rem;
229
+ font-size: 0.95rem;
230
+ align-self: flex-start;
231
+ transition: all 0.2s ease;
232
+ border-bottom: 1px solid transparent;
233
+ }
234
+ .bk-timeline-card:hover .bk-timeline-action {
235
+ gap: 0.6rem;
236
+ border-bottom-color: var(--accent);
237
+ }
238
+
239
+ .bk-status-unread .bk-timeline-node {
240
+ border: 1px solid var(--line-strong);
241
+ }
242
+ .bk-status-unread .bk-timeline-node::after {
243
+ background: var(--line-strong);
244
+ transform: scale(0.8);
245
+ }
246
+ .bk-status-unread .bk-timeline-content {
247
+ background: transparent;
248
+ box-shadow: none;
249
+ border: 1px solid transparent;
250
+ }
251
+ .bk-status-unread .bk-timeline-title {
252
+ color: var(--muted);
253
+ }
254
+ .bk-status-unread .bk-timeline-desc {
255
+ color: color-mix(in srgb, var(--muted) 80%, transparent);
256
+ }
257
+ .bk-status-unread .bk-timeline-action {
258
+ color: var(--muted);
259
+ }
260
+ .bk-status-unread:hover .bk-timeline-node {
261
+ border-color: var(--ink);
262
+ background: var(--paper);
263
+ }
264
+ .bk-status-unread:hover .bk-timeline-node::after {
265
+ background: var(--ink);
266
+ transform: scale(1);
267
+ }
268
+ .bk-status-unread:hover .bk-timeline-content {
269
+ background: var(--paper);
270
+ border-color: var(--line);
271
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
272
+ }
273
+ .bk-status-unread:hover .bk-timeline-title {
274
+ color: var(--ink);
275
+ }
276
+ .bk-status-unread:hover .bk-timeline-action {
277
+ color: var(--ink);
278
+ border-bottom-color: var(--ink);
279
+ }
280
+
281
+ @media (prefers-color-scheme: dark) {
282
+ .bk-timeline-content {
283
+ background: var(--panel);
284
+ }
285
+ .bk-status-unread .bk-timeline-content {
286
+ background: transparent;
287
+ }
288
+ .bk-status-unread:hover .bk-timeline-content {
289
+ background: var(--panel);
290
+ }
291
+ }
292
+
293
+ @media (max-width: 600px) {
294
+ .bk-chapter-timeline::before {
295
+ left: 16px;
296
+ }
297
+ .bk-timeline-card {
298
+ gap: 1.5rem;
299
+ }
300
+ .bk-timeline-node {
301
+ width: 32px;
302
+ height: 32px;
303
+ }
304
+ .bk-timeline-node::after {
305
+ width: 8px;
306
+ height: 8px;
307
+ }
308
+ .bk-timeline-content {
309
+ padding: 1.5rem;
310
+ border-radius: 10px;
311
+ }
312
+ .bk-timeline-title {
313
+ font-size: 1.4rem;
314
+ }
315
+ }
316
+ `;
317
+
318
+ return `<!DOCTYPE html>
319
+ <html lang="en" data-palette="${palette}" ${schemeAttr}>
320
+ <head>
321
+ <meta charset="UTF-8">
322
+ <meta name="viewport" content="width=device-width, initial-scale=1">
323
+ <title>${escHtml(chapter.meta.title)}</title>
324
+ ${chapter.meta.description ? `<meta name="description" content="${escHtml(chapter.meta.description)}">` : ""}
325
+ ${opts.head ?? ""}
326
+ <style>
327
+ ${opts.font ? `:root { --font-sans: ${opts.font}, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }` : ""}
328
+ ${pageCSS()}
329
+ ${chapterStyles}
330
+ </style>
331
+ </head>
332
+ <body class="bk-layout-${layout} bk-density-${density} bk-tone-${tone}">
333
+ <div class="bk-shell">
334
+ <aside class="bk-sidebar">
335
+ <div class="bk-sidebar-inner">
336
+ <div class="bk-sidebar-header">
337
+ <div style="margin-top: 8px;"></div>
338
+ <div class="bk-sidebar-title">${escHtml(chapter.meta.title)}</div>
339
+ </div>
340
+ <nav class="bk-nav">${navHtml}</nav>
341
+ <div class="bk-sidebar-footer">
342
+ <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">
343
+ <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>
344
+ <span class="bk-sr-only">Display settings</span>
345
+ </button>
346
+ <div class="bk-theme-panel" id="bk-theme-panel" aria-label="Display settings" hidden>
347
+ <div class="bk-theme-row">
348
+ <span>Theme</span>
349
+ <div class="bk-segmented-control" id="bk-theme-icons">
350
+ <button type="button" class="bk-segment-btn ${theme === "light" ? "active" : ""}" data-theme="light" title="Light" aria-label="Light theme">
351
+ <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>
352
+ </button>
353
+ <button type="button" class="bk-segment-btn ${theme === "dark" ? "active" : ""}" data-theme="dark" title="Dark" aria-label="Dark theme">
354
+ <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>
355
+ </button>
356
+ <button type="button" class="bk-segment-btn ${theme === "auto" ? "active" : ""}" data-theme="auto" title="System" aria-label="System theme">
357
+ <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>
358
+ </button>
359
+ </div>
360
+ </div>
361
+ <div class="bk-theme-row">
362
+ <span>Palette</span>
363
+ <div class="bk-segmented-control" id="bk-palette-icons">
364
+ <button type="button" class="bk-segment-btn ${palette === "ink" ? "active" : ""}" data-palette="ink" title="Ink" aria-label="Ink palette">
365
+ <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>
366
+ </button>
367
+ <button type="button" class="bk-segment-btn ${palette === "field" ? "active" : ""}" data-palette="field" title="Field" aria-label="Field palette">
368
+ <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>
369
+ </button>
370
+ <button type="button" class="bk-segment-btn ${palette === "ember" ? "active" : ""}" data-palette="ember" title="Ember" aria-label="Ember palette">
371
+ <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>
372
+ </button>
373
+ </div>
374
+ </div>
375
+ </div>
376
+ </div>
377
+ </div>
378
+ </aside>
379
+ <button class="bk-sidebar-collapse-floating" id="bk-sidebar-collapse" aria-label="Collapse sidebar">
380
+ <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>
381
+ </button>
382
+ <main class="bk-main">
383
+ <button class="bk-sidebar-expand" id="bk-sidebar-expand" type="button" aria-label="Expand sidebar">
384
+ <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>
385
+ </button>
386
+ <article class="bk-content" style="max-width: 1000px; margin: 0 auto;">
387
+ <header class="bk-hero" style="border-bottom: none;">
388
+ <p class="bk-eyebrow">Chapter</p>
389
+ <h1>${escHtml(chapter.meta.title)}</h1>
390
+ ${chapter.meta.description ? `<p class="bk-deck">${escHtml(chapter.meta.description)}</p>` : ""}
391
+ </header>
392
+ ${timelineHtml}
393
+ </article>
394
+ </main>
395
+ </div>
396
+ <script>
397
+ ${clientScript()}
398
+ </script>
399
+ </body>
400
+ </html>`;
401
+ }
@@ -0,0 +1,193 @@
1
+ import katex from "katex";
2
+ import { marked } from "marked";
3
+ import { markedHighlight } from "marked-highlight";
4
+ import hljs from "highlight.js";
5
+ import type { Block } from "../types.js";
6
+
7
+ marked.use(markedHighlight({
8
+ langPrefix: 'hljs language-',
9
+ highlight(code, lang) {
10
+ const language = hljs.getLanguage(lang) ? lang : 'plaintext';
11
+ return hljs.highlight(code, { language }).value;
12
+ }
13
+ }));
14
+
15
+ // ─── Markdown Rendering (using Marked + KaTeX) ───────────────────────────────
16
+
17
+ function mdToHtml(md: string): { html: string; title: string } {
18
+ let title = "";
19
+
20
+ // Extract first H1 or H2 as title
21
+ const titleMatch = md.match(/^(?:#|##)\s+(.+)$/m);
22
+ if (titleMatch) {
23
+ title = titleMatch[1].trim();
24
+ }
25
+
26
+ const mathBlocks: string[] = [];
27
+ const mathInlines: string[] = [];
28
+
29
+ // Temporarily mask code blocks so we don't extract math from them
30
+ const codeBlocks: string[] = [];
31
+ let processedMd = md.replace(/```[\s\S]+?```|`[^`\n]+`/g, (match) => {
32
+ const id = codeBlocks.length;
33
+ codeBlocks.push(match);
34
+ return `@@BK_CODE_${id}@@`;
35
+ });
36
+
37
+ // Extract math and replace with placeholders
38
+ processedMd = processedMd.replace(/\$\$([\s\S]+?)\$\$/g, (_, tex) => {
39
+ const id = mathBlocks.length;
40
+ mathBlocks.push(tex);
41
+ return `@@BK_MATH_BLOCK_${id}@@`;
42
+ });
43
+
44
+ processedMd = processedMd.replace(
45
+ /\$(?!\s)([^$\n]+?)(?<!\s)\$/g,
46
+ (_, tex) => {
47
+ const id = mathInlines.length;
48
+ mathInlines.push(tex);
49
+ return `@@BK_MATH_INLINE_${id}@@`;
50
+ },
51
+ );
52
+
53
+ // Restore code blocks
54
+ codeBlocks.forEach((match, id) => {
55
+ processedMd = processedMd.replace(`@@BK_CODE_${id}@@`, () => match);
56
+ });
57
+
58
+ let html = marked.parse(processedMd) as string;
59
+
60
+ // Restore math
61
+ mathBlocks.forEach((tex, id) => {
62
+ const rendered = katex.renderToString(tex, {
63
+ throwOnError: false,
64
+ displayMode: true,
65
+ });
66
+ // marked might wrap block placeholders in <p>
67
+ html = html.replace(
68
+ `<p>@@BK_MATH_BLOCK_${id}@@</p>`,
69
+ () => `<div class="bk-math-block">${rendered}</div>`,
70
+ );
71
+ // Fallback if not wrapped in <p>
72
+ html = html.replace(
73
+ `@@BK_MATH_BLOCK_${id}@@`,
74
+ () => `<div class="bk-math-block">${rendered}</div>`,
75
+ );
76
+ });
77
+
78
+ mathInlines.forEach((tex, id) => {
79
+ const rendered = katex.renderToString(tex, {
80
+ throwOnError: false,
81
+ displayMode: false,
82
+ });
83
+ html = html.replace(`@@BK_MATH_INLINE_${id}@@`, () => rendered);
84
+ });
85
+
86
+ return { html, title };
87
+ }
88
+
89
+ function escHtml(s: string): string {
90
+ return s
91
+ .replace(/&/g, "&amp;")
92
+ .replace(/</g, "&lt;")
93
+ .replace(/>/g, "&gt;")
94
+ .replace(/"/g, "&quot;")
95
+ .replace(/'/g, "&#39;");
96
+ }
97
+
98
+ function escAttr(s: string): string {
99
+ return escHtml(s);
100
+ }
101
+
102
+ function blockChrome(
103
+ kind: string,
104
+ label: string | undefined,
105
+ caption: string | undefined,
106
+ body: string,
107
+ accent = "neutral",
108
+ allowMaximize = true,
109
+ ): string {
110
+ return `<figure class="bk-object bk-object--${escAttr(accent)}">
111
+ <div class="bk-object-header">
112
+ <span class="bk-object-kicker">${escHtml(kind)}</span>
113
+ ${label ? `<span class="bk-object-title">${escHtml(label)}</span>` : ""}
114
+ ${allowMaximize ? `<button type="button" class="bk-object-maximize" aria-label="Maximize" title="Maximize">
115
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
116
+ </button>` : ""}
117
+ </div>
118
+ ${body}
119
+ ${caption ? `<figcaption class="bk-caption">${mdInline(caption)}</figcaption>` : ""}
120
+ </figure>`;
121
+ }
122
+
123
+ function mdInline(text: string): string {
124
+ const mathInlines: string[] = [];
125
+ const processedMd = text.replace(/\$(?!\s)([^$\n]+?)(?<!\s)\$/g, (_, tex) => {
126
+ const id = mathInlines.length;
127
+ mathInlines.push(tex);
128
+ return `@@BK_MATH_INLINE_${id}@@`;
129
+ });
130
+
131
+ let html = marked.parseInline(processedMd) as string;
132
+
133
+ mathInlines.forEach((tex, id) => {
134
+ const rendered = katex.renderToString(tex, {
135
+ throwOnError: false,
136
+ displayMode: false,
137
+ });
138
+ html = html.replace(`@@BK_MATH_INLINE_${id}@@`, () => rendered);
139
+ });
140
+
141
+ return html;
142
+ }
143
+
144
+ function renderSimulationControls(
145
+ block: Extract<Block, { type: "simulation" }>,
146
+ ): string {
147
+ const props = block.props ?? {};
148
+ const keys = Object.keys(block.tunables ?? props).filter((key) => {
149
+ const value = props[key];
150
+ return typeof value === "number" || typeof value === "boolean";
151
+ });
152
+
153
+ if (!keys.length || block.controls === "observe") return "";
154
+
155
+ return `<div class="bk-sim-controls" aria-label="Simulation controls">
156
+ ${keys
157
+ .map((key) => {
158
+ const value = props[key];
159
+ const control = block.tunables?.[key] ?? {};
160
+ const label = escHtml(control.label ?? key.replace(/([A-Z])/g, " $1"));
161
+ if (typeof value === "boolean") {
162
+ return `<label class="bk-sim-toggle">
163
+ <input type="checkbox" data-bk-prop="${escAttr(key)}" ${value ? "checked" : ""}>
164
+ <span>${label}</span>
165
+ </label>`;
166
+ }
167
+
168
+ const min = control.min ?? Math.min(0, Number(value));
169
+ const max = control.max ?? Math.max(10, Number(value) * 2);
170
+ const step = control.step ?? 1;
171
+ return `<label class="bk-sim-range">
172
+ <span>${label}</span>
173
+ <input type="range" data-bk-prop="${escAttr(key)}" min="${min}" max="${max}" step="${step}" value="${value}">
174
+ <output>${value}</output>
175
+ </label>`;
176
+ })
177
+ .join("")}
178
+ </div>`;
179
+ }
180
+
181
+ function escapeScriptJson(value: unknown): string {
182
+ return JSON.stringify(value)
183
+ .replace(/</g, "\\u003c")
184
+ .replace(/>/g, "\\u003e");
185
+ }
186
+
187
+ export {
188
+ blockChrome,
189
+ escapeScriptJson,
190
+ mdInline,
191
+ mdToHtml,
192
+ renderSimulationControls,
193
+ };
@@ -0,0 +1,84 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import type { BuildOptions } from "../types.js";
4
+
5
+ export interface NavItem {
6
+ id: string;
7
+ label: string;
8
+ kind: "heading" | "section" | "quiz";
9
+ }
10
+
11
+ // ─── Smart Content Resolution ────────────────────────────────────────────────
12
+
13
+ function resolveContent(
14
+ src: string,
15
+ options: BuildOptions,
16
+ expectedType: "md" | "js" | "json" | "text" = "text",
17
+ ): string {
18
+ if (src.includes("\n")) return src;
19
+
20
+ const isLikelyFilePath =
21
+ (expectedType !== "text" && src.endsWith(`.${expectedType}`)) ||
22
+ src.startsWith("/") ||
23
+ src.startsWith("./") ||
24
+ src.startsWith("../");
25
+
26
+ const filePath = path.isAbsolute(src)
27
+ ? src
28
+ : path.resolve(options.contentBase ?? ".", src);
29
+
30
+ if (fs.existsSync(filePath)) {
31
+ const stat = fs.statSync(filePath);
32
+ if (stat.isFile()) {
33
+ return fs.readFileSync(filePath, "utf-8");
34
+ }
35
+ }
36
+
37
+ if (isLikelyFilePath && options.strict !== false) {
38
+ throw new Error(
39
+ `Missing ${expectedType.toUpperCase()} content: ${filePath}`,
40
+ );
41
+ }
42
+
43
+ // If it's not a valid file path, or the file doesn't exist, treat it as raw text
44
+ return src;
45
+ }
46
+
47
+ function resolveAssetSrc(src: string, options: BuildOptions): string {
48
+ if (/^(https?:|data:|\/)/.test(src)) return src;
49
+
50
+ const filePath = path.resolve(options.contentBase ?? ".", src);
51
+ if (!fs.existsSync(filePath)) {
52
+ if (options.strict !== false)
53
+ throw new Error(`Missing media asset: ${filePath}`);
54
+ return src;
55
+ }
56
+
57
+ const ext = path.extname(filePath).toLowerCase();
58
+ const mime =
59
+ ext === ".svg"
60
+ ? "image/svg+xml"
61
+ : ext === ".png"
62
+ ? "image/png"
63
+ : ext === ".jpg" || ext === ".jpeg"
64
+ ? "image/jpeg"
65
+ : ext === ".webp"
66
+ ? "image/webp"
67
+ : ext === ".gif"
68
+ ? "image/gif"
69
+ : ext === ".avif"
70
+ ? "image/avif"
71
+ : ext === ".mp4"
72
+ ? "video/mp4"
73
+ : ext === ".webm"
74
+ ? "video/webm"
75
+ : ext === ".mp3"
76
+ ? "audio/mpeg"
77
+ : ext === ".wav"
78
+ ? "audio/wav"
79
+ : "application/octet-stream";
80
+
81
+ return `data:${mime};base64,${fs.readFileSync(filePath).toString("base64")}`;
82
+ }
83
+
84
+ export { resolveAssetSrc, resolveContent };