mr-md 1.0.1 → 1.0.3

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.
@@ -29,7 +29,7 @@ function renderBlock(
29
29
  block: Block,
30
30
  idx: number,
31
31
  options: BuildOptions,
32
- ): { html: string; navItem?: NavItem } {
32
+ ): { html: string; navItems?: NavItem[] } {
33
33
  try {
34
34
  const result = renderBlockInner(block, idx, options);
35
35
  if (
@@ -58,33 +58,38 @@ function renderBlockInner(
58
58
  block: Block,
59
59
  idx: number,
60
60
  options: BuildOptions,
61
- ): { html: string; navItem?: NavItem } {
61
+ ): { html: string; navItems?: NavItem[] } {
62
62
  switch (block.type) {
63
63
  case "heading": {
64
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");
65
+ const { html, title, headings } = mdToHtml(md);
66
+ const label = block.title || title || headings[0]?.text || (typeof block.src === "string" && !block.src.includes(".md") ? block.src.replace(/^#+\s*/, '') : "Heading");
67
67
  const id = `heading-${idx}`;
68
68
  return {
69
69
  html: `<section id="${id}" class="bk-section bk-heading">${html}</section>`,
70
- navItem: { id, label, kind: "heading" },
70
+ navItems: [{ id, label, kind: "heading" }],
71
71
  };
72
72
  }
73
73
 
74
74
  case "markdown": {
75
75
  const md = resolveContent(block.src, options, "md");
76
- const { html } = mdToHtml(md);
77
- return { html: `<div class="bk-markdown">${html}</div>` };
76
+ const { html, headings } = mdToHtml(md);
77
+ const navItems: NavItem[] = headings.map(h => ({
78
+ id: h.id,
79
+ label: h.text,
80
+ kind: h.level === 2 ? "heading" : "section"
81
+ }));
82
+ return { html: `<div class="bk-markdown">${html}</div>`, navItems: navItems.length > 0 ? navItems : undefined };
78
83
  }
79
84
 
80
85
  case "section": {
81
86
  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");
87
+ const { html, title, headings } = mdToHtml(md);
88
+ const label = block.label || title || headings[0]?.text || (typeof block.src === "string" && !block.src.includes(".md") ? block.src.replace(/^#+\s*/, '') : "Section");
84
89
  const id = `section-${idx}`;
85
90
  return {
86
91
  html: `<section id="${id}" class="bk-section bk-subsection">${html}</section>`,
87
- navItem: { id, label, kind: "section" },
92
+ navItems: [{ id, label, kind: "section" }],
88
93
  };
89
94
  }
90
95
 
@@ -283,7 +288,8 @@ function renderBlockInner(
283
288
  if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
284
289
  throw new Error("Quiz file not found or invalid JSON format");
285
290
  }
286
- quiz = JSON.parse(trimmed);
291
+ const parsed = JSON.parse(trimmed);
292
+ quiz = Array.isArray(parsed) ? { questions: parsed } : parsed;
287
293
  } catch (e) {
288
294
  const msg = e instanceof Error ? e.message : String(e);
289
295
  if (options.strict !== false) {
@@ -305,11 +311,11 @@ function renderBlockInner(
305
311
  ${quiz.questions.map((q, qi) => renderQuestion(q, `quiz-${idx}`, qi)).join("\n")}
306
312
  </div>
307
313
  </div>`,
308
- navItem: {
314
+ navItems: [{
309
315
  id: `quiz-${idx}`,
310
316
  label: block.label ?? "Questions",
311
317
  kind: "quiz",
312
- },
318
+ }],
313
319
  };
314
320
  }
315
321
 
@@ -346,7 +352,7 @@ function renderQuestion(q: QuizQuestion, quizId: string, qi: number): string {
346
352
 
347
353
  // Wraps a JS string in a minimal iframe document
348
354
  function iframeDoc(js: string, props: string, loop?: boolean, dependencies?: string[]): string {
349
- const scriptTags = (dependencies ?? []).map((url) => `<script src="${escAttr(url)}"></script>`).join("\\n");
355
+ const scriptTags = (dependencies ?? []).map((url) => `<script src="${escAttr(url)}"></script>`).join("\n");
350
356
  const doc = `<!DOCTYPE html><html><head>
351
357
  ${scriptTags}
352
358
  <style>
@@ -1,4 +1,4 @@
1
- import type { BuildOptions, Chapter, Lesson } from "../types.js";
1
+ import type { BuildOptions, Chapter, Course, Lesson } from "../types.js";
2
2
  import { escAttr, escHtml, renderBlock } from "./blocks.js";
3
3
  import { clientScript, pageCSS, renderPage } from "./html.js"; // Used in renderChapter
4
4
  import type { NavItem } from "./utils.js";
@@ -10,10 +10,10 @@ export function render(lesson: Lesson, opts: BuildOptions = {}): string {
10
10
  const structuredNavItems: NavItem[] = [];
11
11
 
12
12
  lesson.blocks.forEach((block, idx) => {
13
- const { html, navItem } = renderBlock(block, idx, opts);
13
+ const { html, navItems } = renderBlock(block, idx, opts);
14
14
  bodyItems.push(html);
15
- if (navItem) {
16
- structuredNavItems.push(navItem);
15
+ if (navItems) {
16
+ structuredNavItems.push(...navItems);
17
17
  }
18
18
  });
19
19
 
@@ -399,3 +399,383 @@ ${clientScript()}
399
399
  </body>
400
400
  </html>`;
401
401
  }
402
+
403
+ // ─── Course Rendering ────────────────────────────────────────────────────────
404
+
405
+ export function renderCourse(
406
+ course: Course,
407
+ opts: BuildOptions = {},
408
+ ): string {
409
+ const theme = opts.theme ?? "auto";
410
+ const schemeAttr = theme === "auto" ? "" : `data-theme="${theme}"`;
411
+ const preset = opts.preset ?? {};
412
+ const layout = preset.layout ?? "lesson";
413
+ const density = preset.density ?? "comfortable";
414
+ const tone = preset.tone ?? "scholarly";
415
+ const palette = opts.palette ?? "ink";
416
+
417
+ const navHtml = course.chapters
418
+ .map(
419
+ (c) =>
420
+ `<a href="${escAttr(c.meta.slug)}.html" class="bk-nav-item bk-nav-chapter">${escHtml(c.meta.title)}</a>`,
421
+ )
422
+ .join("\n");
423
+
424
+ const timelineHtml = `
425
+ <div class="bk-chapter-timeline-wrapper">
426
+ <div class="bk-chapter-timeline">
427
+ ${course.chapters
428
+ .map(
429
+ (chapter, idx) => `
430
+ <a href="${escAttr(chapter.meta.slug)}.html" class="bk-timeline-card bk-status-${chapter.meta.status ?? "unread"}">
431
+ <div class="bk-timeline-node"></div>
432
+ <div class="bk-timeline-content">
433
+ <h3 class="bk-timeline-title" style="view-transition-name: title-${escAttr(chapter.meta.slug)}">${escHtml(chapter.meta.title)}</h3>
434
+ ${chapter.meta.description ? `<p class="bk-timeline-desc">${escHtml(chapter.meta.description)}</p>` : ""}
435
+ <span class="bk-timeline-action">${chapter.meta.status === "completed" ? "Review" : "Start Chapter"} <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>
436
+ </div>
437
+ </a>
438
+ `,
439
+ )
440
+ .join("")}
441
+ </div>
442
+ </div>
443
+ <script>
444
+ if (!CSS.supports('(animation-timeline: view()) and (animation-range: entry)')) {
445
+ const observer = new IntersectionObserver(
446
+ (entries) => {
447
+ for (const entry of entries) {
448
+ if (entry.isIntersecting) {
449
+ entry.target.classList.add('js-visible');
450
+ entry.target.classList.remove('js-hidden');
451
+ observer.unobserve(entry.target);
452
+ }
453
+ }
454
+ },
455
+ { threshold: 0.1 }
456
+ );
457
+ document.querySelectorAll('.bk-timeline-card').forEach((el) => {
458
+ el.classList.add('js-hidden');
459
+ observer.observe(el);
460
+ });
461
+ }
462
+ </script>`;
463
+
464
+ const chapterStyles = `
465
+ .bk-chapter-timeline-wrapper {
466
+ position: relative;
467
+ width: 100%;
468
+ padding: 1rem 0;
469
+ z-index: 1;
470
+ }
471
+
472
+ .bk-chapter-timeline {
473
+ display: flex;
474
+ flex-direction: column;
475
+ gap: 3rem;
476
+ padding: 3rem 0;
477
+ position: relative;
478
+ max-width: 800px;
479
+ margin: 0 auto;
480
+ }
481
+
482
+ /* Minimalist vertical connecting line */
483
+ .bk-chapter-timeline::before {
484
+ content: '';
485
+ position: absolute;
486
+ left: 20px;
487
+ top: 3rem;
488
+ bottom: 3rem;
489
+ width: 1px;
490
+ background: var(--line);
491
+ z-index: 0;
492
+ transform: translateX(-50%);
493
+ }
494
+
495
+ .bk-timeline-card {
496
+ display: flex;
497
+ align-items: stretch;
498
+ gap: 2rem;
499
+ text-decoration: none !important;
500
+ border: none !important;
501
+ color: inherit;
502
+ position: relative;
503
+ z-index: 1;
504
+ opacity: 1;
505
+ transform: none;
506
+ }
507
+
508
+ /* Subtle scroll-driven animations */
509
+ @media (prefers-reduced-motion: no-preference) {
510
+ @supports ((animation-timeline: view()) and (animation-range: entry)) {
511
+ .bk-timeline-card {
512
+ animation-name: slide-fade-in;
513
+ animation-fill-mode: both;
514
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
515
+ animation-timeline: view(block);
516
+ animation-range: entry 5% cover 20%;
517
+ }
518
+ @keyframes slide-fade-in {
519
+ 0% { opacity: 0; transform: translateY(20px); }
520
+ 100% { opacity: 1; transform: translateY(0); }
521
+ }
522
+ }
523
+ }
524
+
525
+ /* Fallback for browsers without animation-timeline support */
526
+ .bk-timeline-card.js-hidden {
527
+ opacity: 0;
528
+ transform: translateY(20px);
529
+ }
530
+ .bk-timeline-card.js-visible {
531
+ opacity: 1;
532
+ transform: translateY(0);
533
+ transition: opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1), transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
534
+ }
535
+
536
+ .bk-timeline-node {
537
+ width: 40px;
538
+ height: 40px;
539
+ border-radius: 50%;
540
+ background: var(--paper);
541
+ border: 1px solid var(--accent);
542
+ display: flex;
543
+ align-items: center;
544
+ justify-content: center;
545
+ flex-shrink: 0;
546
+ transition: all 0.3s ease;
547
+ position: relative;
548
+ box-shadow: 0 2px 4px rgba(0,0,0,0.02);
549
+ }
550
+ .bk-timeline-node::after {
551
+ content: '';
552
+ width: 10px;
553
+ height: 10px;
554
+ border-radius: 50%;
555
+ background: var(--accent);
556
+ transition: all 0.3s ease;
557
+ }
558
+ .bk-timeline-card:hover .bk-timeline-node {
559
+ background: var(--accent);
560
+ }
561
+ .bk-timeline-card:hover .bk-timeline-node::after {
562
+ background: var(--paper);
563
+ }
564
+
565
+ .bk-timeline-content {
566
+ background: var(--paper);
567
+ padding: 2rem;
568
+ border-radius: 12px;
569
+ border: 1px solid var(--line);
570
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
571
+ flex: 1;
572
+ transition: all 0.3s ease;
573
+ display: flex;
574
+ flex-direction: column;
575
+ justify-content: center;
576
+ position: relative;
577
+ }
578
+ .bk-timeline-card:hover .bk-timeline-content {
579
+ border-color: color-mix(in srgb, var(--accent) 30%, var(--line));
580
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
581
+ transform: translateY(-2px);
582
+ }
583
+
584
+ .bk-timeline-title {
585
+ margin: 0 0 0.5rem 0;
586
+ font-family: var(--font-display);
587
+ font-size: 1.6rem;
588
+ color: var(--ink);
589
+ width: fit-content;
590
+ transition: color 0.2s ease;
591
+ }
592
+ .bk-timeline-card:hover .bk-timeline-title {
593
+ color: var(--accent);
594
+ }
595
+
596
+ .bk-timeline-desc {
597
+ margin: 0 0 1.5rem 0;
598
+ color: var(--muted);
599
+ line-height: 1.6;
600
+ font-size: 1.05rem;
601
+ }
602
+
603
+ .bk-timeline-action {
604
+ font-weight: 500;
605
+ color: var(--accent);
606
+ display: inline-flex;
607
+ align-items: center;
608
+ gap: 0.4rem;
609
+ font-size: 0.95rem;
610
+ align-self: flex-start;
611
+ transition: all 0.2s ease;
612
+ border-bottom: 1px solid transparent;
613
+ }
614
+ .bk-timeline-card:hover .bk-timeline-action {
615
+ gap: 0.6rem;
616
+ border-bottom-color: var(--accent);
617
+ }
618
+
619
+ .bk-status-unread .bk-timeline-node {
620
+ border: 1px solid var(--line-strong);
621
+ }
622
+ .bk-status-unread .bk-timeline-node::after {
623
+ background: var(--line-strong);
624
+ transform: scale(0.8);
625
+ }
626
+ .bk-status-unread .bk-timeline-content {
627
+ background: transparent;
628
+ box-shadow: none;
629
+ border: 1px solid transparent;
630
+ }
631
+ .bk-status-unread .bk-timeline-title {
632
+ color: var(--muted);
633
+ }
634
+ .bk-status-unread .bk-timeline-desc {
635
+ color: color-mix(in srgb, var(--muted) 80%, transparent);
636
+ }
637
+ .bk-status-unread .bk-timeline-action {
638
+ color: var(--muted);
639
+ }
640
+ .bk-status-unread:hover .bk-timeline-node {
641
+ border-color: var(--ink);
642
+ background: var(--paper);
643
+ }
644
+ .bk-status-unread:hover .bk-timeline-node::after {
645
+ background: var(--ink);
646
+ transform: scale(1);
647
+ }
648
+ .bk-status-unread:hover .bk-timeline-content {
649
+ background: var(--paper);
650
+ border-color: var(--line);
651
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
652
+ }
653
+ .bk-status-unread:hover .bk-timeline-title {
654
+ color: var(--ink);
655
+ }
656
+ .bk-status-unread:hover .bk-timeline-action {
657
+ color: var(--ink);
658
+ border-bottom-color: var(--ink);
659
+ }
660
+
661
+ @media (prefers-color-scheme: dark) {
662
+ .bk-timeline-content {
663
+ background: var(--panel);
664
+ }
665
+ .bk-status-unread .bk-timeline-content {
666
+ background: transparent;
667
+ }
668
+ .bk-status-unread:hover .bk-timeline-content {
669
+ background: var(--panel);
670
+ }
671
+ }
672
+
673
+ @media (max-width: 600px) {
674
+ .bk-chapter-timeline::before {
675
+ left: 16px;
676
+ }
677
+ .bk-timeline-card {
678
+ gap: 1.5rem;
679
+ }
680
+ .bk-timeline-node {
681
+ width: 32px;
682
+ height: 32px;
683
+ }
684
+ .bk-timeline-node::after {
685
+ width: 8px;
686
+ height: 8px;
687
+ }
688
+ .bk-timeline-content {
689
+ padding: 1.5rem;
690
+ border-radius: 10px;
691
+ }
692
+ .bk-timeline-title {
693
+ font-size: 1.4rem;
694
+ }
695
+ }
696
+ `;
697
+
698
+ return `<!DOCTYPE html>
699
+ <html lang="en" data-palette="${palette}" ${schemeAttr}>
700
+ <head>
701
+ <meta charset="UTF-8">
702
+ <meta name="viewport" content="width=device-width, initial-scale=1">
703
+ <title>${escHtml(course.meta.title)}</title>
704
+ ${course.meta.description ? `<meta name="description" content="${escHtml(course.meta.description)}">` : ""}
705
+ ${opts.head ?? ""}
706
+ <style>
707
+ ${opts.font ? `:root { --font-sans: ${opts.font}, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }` : ""}
708
+ ${pageCSS()}
709
+ ${chapterStyles}
710
+ </style>
711
+ </head>
712
+ <body class="bk-layout-${layout} bk-density-${density} bk-tone-${tone}">
713
+ <div class="bk-shell">
714
+ <aside class="bk-sidebar">
715
+ <div class="bk-sidebar-inner">
716
+ <div class="bk-sidebar-header">
717
+ <div style="margin-top: 8px;"></div>
718
+ <div class="bk-sidebar-title">${escHtml(course.meta.title)}</div>
719
+ </div>
720
+ <nav class="bk-nav">${navHtml}</nav>
721
+ <div class="bk-sidebar-footer">
722
+ <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">
723
+ <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>
724
+ <span class="bk-sr-only">Display settings</span>
725
+ </button>
726
+ <div class="bk-theme-panel" id="bk-theme-panel" aria-label="Display settings" hidden>
727
+ <div class="bk-theme-row">
728
+ <span>Theme</span>
729
+ <div class="bk-segmented-control" id="bk-theme-icons">
730
+ <button type="button" class="bk-segment-btn ${theme === "light" ? "active" : ""}" data-theme="light" title="Light" aria-label="Light theme">
731
+ <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>
732
+ </button>
733
+ <button type="button" class="bk-segment-btn ${theme === "dark" ? "active" : ""}" data-theme="dark" title="Dark" aria-label="Dark theme">
734
+ <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>
735
+ </button>
736
+ <button type="button" class="bk-segment-btn ${theme === "auto" ? "active" : ""}" data-theme="auto" title="System" aria-label="System theme">
737
+ <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>
738
+ </button>
739
+ </div>
740
+ </div>
741
+ <div class="bk-theme-row">
742
+ <span>Palette</span>
743
+ <div class="bk-segmented-control" id="bk-palette-icons">
744
+ <button type="button" class="bk-segment-btn ${palette === "ink" ? "active" : ""}" data-palette="ink" title="Ink" aria-label="Ink palette">
745
+ <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>
746
+ </button>
747
+ <button type="button" class="bk-segment-btn ${palette === "field" ? "active" : ""}" data-palette="field" title="Field" aria-label="Field palette">
748
+ <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>
749
+ </button>
750
+ <button type="button" class="bk-segment-btn ${palette === "ember" ? "active" : ""}" data-palette="ember" title="Ember" aria-label="Ember palette">
751
+ <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>
752
+ </button>
753
+ </div>
754
+ </div>
755
+ </div>
756
+ </div>
757
+ </div>
758
+ </aside>
759
+ <button class="bk-sidebar-collapse-floating" id="bk-sidebar-collapse" aria-label="Collapse sidebar">
760
+ <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>
761
+ </button>
762
+ <main class="bk-main">
763
+ <button class="bk-sidebar-expand" id="bk-sidebar-expand" type="button" aria-label="Expand sidebar">
764
+ <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>
765
+ </button>
766
+ <article class="bk-content" style="max-width: 1000px; margin: 0 auto;">
767
+ <header class="bk-hero" style="border-bottom: none;">
768
+ <p class="bk-eyebrow">Course</p>
769
+ <h1>${escHtml(course.meta.title)}</h1>
770
+ ${course.meta.description ? `<p class="bk-deck">${escHtml(course.meta.description)}</p>` : ""}
771
+ </header>
772
+ ${timelineHtml}
773
+ </article>
774
+ </main>
775
+ </div>
776
+ <script>
777
+ ${clientScript()}
778
+ </script>
779
+ </body>
780
+ </html>`;
781
+ }
@@ -14,7 +14,7 @@ marked.use(markedHighlight({
14
14
 
15
15
  // ─── Markdown Rendering (using Marked + KaTeX) ───────────────────────────────
16
16
 
17
- function mdToHtml(md: string): { html: string; title: string } {
17
+ function mdToHtml(md: string): { html: string; title: string; headings: { id: string; text: string; level: number }[] } {
18
18
  let title = "";
19
19
 
20
20
  // Extract first H1 or H2 as title
@@ -55,7 +55,20 @@ function mdToHtml(md: string): { html: string; title: string } {
55
55
  processedMd = processedMd.replace(`@@BK_CODE_${id}@@`, () => match);
56
56
  });
57
57
 
58
- let html = marked.parse(processedMd) as string;
58
+ const headings: { id: string; text: string; level: number }[] = [];
59
+ let headingIdCounter = 0;
60
+
61
+ const renderer = new marked.Renderer();
62
+ renderer.heading = ({ tokens, depth, text }) => {
63
+ const id = `bk-heading-${headingIdCounter++}`;
64
+ if (depth === 2 || depth === 3) {
65
+ const plainText = text.replace(/<[^>]+>/g, "");
66
+ headings.push({ id, text: plainText, level: depth });
67
+ }
68
+ return `<h${depth} id="${id}" class="bk-heading-${depth}">${text}</h${depth}>`;
69
+ };
70
+
71
+ let html = marked.parse(processedMd, { renderer }) as string;
59
72
 
60
73
  // Restore math
61
74
  mathBlocks.forEach((tex, id) => {
@@ -83,7 +96,7 @@ function mdToHtml(md: string): { html: string; title: string } {
83
96
  html = html.replace(`@@BK_MATH_INLINE_${id}@@`, () => rendered);
84
97
  });
85
98
 
86
- return { html, title };
99
+ return { html, title, headings };
87
100
  }
88
101
 
89
102
  function escHtml(s: string): string {
package/src/types.ts CHANGED
@@ -286,3 +286,14 @@ export interface Chapter {
286
286
  meta: ChapterMeta;
287
287
  lessons: Lesson[];
288
288
  }
289
+
290
+ export interface CourseMeta {
291
+ title: string;
292
+ slug: string;
293
+ description?: string;
294
+ }
295
+
296
+ export interface Course {
297
+ meta: CourseMeta;
298
+ chapters: Chapter[];
299
+ }