granite-mem 0.1.7 → 0.1.8

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.
@@ -10,23 +10,20 @@ const GraphEngine = (() => {
10
10
 
11
11
  // ── Type colors ──
12
12
  const TYPE_COLORS = {
13
- fleeting: { fill: '#fbbf24', glow: 'rgba(251, 191, 36, 0.3)' },
14
- permanent: { fill: '#a5b4fc', glow: 'rgba(165, 180, 252, 0.3)' },
15
- reference: { fill: '#34d399', glow: 'rgba(52, 211, 153, 0.3)' },
16
- person: { fill: '#f9a8d4', glow: 'rgba(249, 168, 212, 0.3)' },
17
- meeting: { fill: '#93c5fd', glow: 'rgba(147, 197, 253, 0.3)' },
18
- project: { fill: '#c4b5fd', glow: 'rgba(196, 181, 253, 0.3)' },
19
- decision: { fill: '#fca5a5', glow: 'rgba(252, 165, 165, 0.3)' },
20
- _default: { fill: '#6e6e7a', glow: 'rgba(110, 110, 122, 0.3)' },
13
+ note: { fill: '#a8aaff', glow: 'rgba(168, 170, 255, 0.25)' },
14
+ source: { fill: '#5cd9a8', glow: 'rgba(92, 217, 168, 0.25)' },
15
+ synthesis: { fill: '#e0a0ff', glow: 'rgba(224, 160, 255, 0.25)' },
16
+ output: { fill: '#ffb86c', glow: 'rgba(255, 184, 108, 0.25)' },
17
+ _default: { fill: '#6b6b7a', glow: 'rgba(107, 107, 122, 0.25)' },
21
18
  };
22
19
 
23
- const BG_COLOR = '#08080a';
24
- const EDGE_COLOR = 'rgba(70, 70, 85, 0.35)';
25
- const EDGE_HIGHLIGHT = 'rgba(165, 180, 252, 0.6)';
26
- const LABEL_COLOR = '#b0b0b8';
27
- const LABEL_DIM = 'rgba(110, 110, 122, 0.3)';
20
+ const BG_COLOR = '#050507';
21
+ const EDGE_COLOR = 'rgba(60, 60, 75, 0.3)';
22
+ const EDGE_HIGHLIGHT = 'rgba(168, 170, 255, 0.5)';
23
+ const LABEL_COLOR = '#a8a8b4';
24
+ const LABEL_DIM = 'rgba(107, 107, 122, 0.25)';
28
25
  const LABEL_FONT = '11px "Instrument Sans", sans-serif';
29
- const NODE_ACTIVE_RING = '#6366f1';
26
+ const NODE_ACTIVE_RING = '#7c7fff';
30
27
 
31
28
  // ── State ──
32
29
  let canvas, ctx;
@@ -11,145 +11,158 @@
11
11
  </head>
12
12
  <body>
13
13
  <div id="app">
14
- <!-- Sidebar rail + panel -->
15
- <aside id="sidebar" class="expanded">
16
- <div id="sidebar-rail">
17
- <button id="brand-mark" title="Granite">
18
- <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
19
- <rect x="1" y="1" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.9"/>
20
- <rect x="11" y="1" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.5"/>
21
- <rect x="1" y="11" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.5"/>
22
- <rect x="11" y="11" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.3"/>
14
+ <!-- Command bar overlay -->
15
+ <div id="command-bar-overlay" class="overlay hidden" role="dialog" aria-label="Search" aria-modal="true">
16
+ <div id="command-bar">
17
+ <div class="command-input-wrap">
18
+ <svg class="command-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
19
+ <circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/>
23
20
  </svg>
24
- </button>
25
- <button id="rail-search" class="rail-btn" title="Search (⌘K)">
26
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
27
- <circle cx="7" cy="7" r="4.5"/>
28
- <path d="M10.5 10.5L14 14"/>
29
- </svg>
30
- </button>
31
- <button id="rail-graph" class="rail-btn" title="Graph (⌘G)">
32
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
33
- <circle cx="4" cy="4" r="2"/>
34
- <circle cx="12" cy="4" r="2"/>
35
- <circle cx="8" cy="13" r="2"/>
36
- <path d="M5.5 5.5L7 11M10.5 5.5L9 11M6 4h4"/>
21
+ <input type="text" id="command-input" placeholder="Search notes..." autocomplete="off" spellcheck="false" aria-label="Search notes" role="combobox" aria-expanded="false" aria-controls="command-results" aria-autocomplete="list">
22
+ <kbd class="command-kbd">esc</kbd>
23
+ </div>
24
+ <div id="command-results" role="listbox" aria-label="Search results" aria-live="polite"></div>
25
+ </div>
26
+ </div>
27
+
28
+ <!-- Create note overlay -->
29
+ <div id="create-overlay" class="overlay hidden" role="dialog" aria-label="Create note" aria-modal="true">
30
+ <div id="create-dialog">
31
+ <div class="create-header">
32
+ <span>New note</span>
33
+ <button id="create-close" class="icon-btn" title="Close">
34
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 4l8 8M12 4l-8 8"/></svg>
35
+ </button>
36
+ </div>
37
+ <div class="create-fields">
38
+ <div class="field-row">
39
+ <label for="create-type">Type</label>
40
+ <select id="create-type"></select>
41
+ </div>
42
+ <div class="field-row">
43
+ <label for="create-title">Title</label>
44
+ <input type="text" id="create-title" placeholder="Note title" autocomplete="off" spellcheck="false" required maxlength="200">
45
+ </div>
46
+ <div class="field-row field-grow">
47
+ <label for="create-body">Content</label>
48
+ <textarea id="create-body" placeholder="Start writing..." spellcheck="false"></textarea>
49
+ </div>
50
+ </div>
51
+ <button id="create-submit">Create</button>
52
+ </div>
53
+ </div>
54
+
55
+ <!-- Mobile header -->
56
+ <header id="mobile-header">
57
+ <button id="mobile-menu" class="icon-btn" aria-label="Open navigation">
58
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 5h12M3 9h12M3 13h12"/></svg>
59
+ </button>
60
+ <span class="mobile-brand">Granite</span>
61
+ <button id="mobile-search" class="icon-btn" aria-label="Search">
62
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/></svg>
63
+ </button>
64
+ </header>
65
+
66
+ <!-- Nav backdrop (mobile) -->
67
+ <div id="nav-backdrop" class="hidden"></div>
68
+
69
+ <!-- Navigation -->
70
+ <nav id="nav">
71
+ <div class="nav-brand">
72
+ <svg width="16" height="16" viewBox="0 0 18 18" fill="none">
73
+ <rect x="1" y="1" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.9"/>
74
+ <rect x="11" y="1" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.45"/>
75
+ <rect x="1" y="11" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.45"/>
76
+ <rect x="11" y="11" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.2"/>
77
+ </svg>
78
+ <span>Granite</span>
79
+ </div>
80
+ <div class="nav-actions">
81
+ <button id="nav-search" class="nav-btn" title="Search (⌘K)">
82
+ <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
83
+ <circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/>
37
84
  </svg>
85
+ <span>Search</span>
86
+ <kbd>⌘K</kbd>
38
87
  </button>
39
- <button id="rail-new" class="rail-btn" title="New note (⌘N)">
40
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
88
+ <button id="nav-new" class="nav-btn" title="New note (⌘N)">
89
+ <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
41
90
  <path d="M8 3v10M3 8h10"/>
42
91
  </svg>
92
+ <span>New</span>
43
93
  </button>
44
- <div style="flex:1"></div>
45
- <button id="rail-toggle" class="rail-btn" title="Toggle sidebar">
46
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
47
- <rect x="1" y="2" width="14" height="12" rx="2"/>
48
- <path d="M5.5 2v12"/>
94
+ <button id="nav-graph" class="nav-btn" title="Graph (⌘G)">
95
+ <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
96
+ <circle cx="4" cy="4" r="2"/><circle cx="12" cy="4" r="2"/><circle cx="8" cy="13" r="2"/>
97
+ <path d="M5.5 5.5L7 11M10.5 5.5L9 11M6 4h4"/>
49
98
  </svg>
99
+ <span>Graph</span>
50
100
  </button>
51
101
  </div>
52
- <div id="sidebar-panel">
53
- <!-- Search -->
54
- <div id="sidebar-search">
55
- <div id="search-wrapper">
56
- <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.4">
57
- <circle cx="7" cy="7" r="4.5"/>
58
- <path d="M10.5 10.5L14 14"/>
59
- </svg>
60
- <input type="text" id="search-box" placeholder="Search…" autocomplete="off" spellcheck="false">
61
- <kbd>⌘K</kbd>
62
- </div>
63
- </div>
102
+ <div id="nav-note-list">
64
103
  <div id="type-filters">
65
- <button class="type-chip active" data-type="">All</button>
66
- </div>
67
-
68
- <!-- Note list (default view) -->
69
- <div id="note-list"></div>
70
-
71
- <!-- Creation panel (hidden by default) -->
72
- <div id="create-panel" style="display:none">
73
- <div id="create-panel-header">
74
- <span class="create-panel-title">New note</span>
75
- <button id="create-panel-close" class="create-panel-close" title="Cancel">
76
- <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
77
- <path d="M4 4l8 8M12 4l-8 8"/>
78
- </svg>
79
- </button>
80
- </div>
81
- <div id="create-form">
82
- <label class="create-label">Type</label>
83
- <select id="create-type" class="create-select"></select>
84
- <label class="create-label">Title</label>
85
- <input type="text" id="create-title" class="create-input" placeholder="Note title…" autocomplete="off" spellcheck="false">
86
- <label class="create-label">Content</label>
87
- <textarea id="create-body" class="create-textarea" placeholder="Start writing… (markdown supported)" spellcheck="false"></textarea>
88
- <button id="create-submit" class="create-submit">Create note</button>
89
- </div>
104
+ <button class="type-pill active" data-type="">All</button>
90
105
  </div>
106
+ <div id="note-list" role="list" aria-label="Notes"></div>
91
107
  </div>
92
- </aside>
108
+ </nav>
93
109
 
94
110
  <!-- Main content -->
95
111
  <main id="main">
96
112
  <!-- Note view -->
97
- <div id="content-wrapper">
98
- <header id="note-header">
99
- <div id="note-breadcrumb"></div>
100
- <h1 id="note-title"></h1>
101
- <div id="note-meta">
102
- <span id="note-type-badge" class="meta-badge"></span>
103
- <span id="note-date" class="meta-date"></span>
104
- <div id="note-tags"></div>
105
- </div>
106
- </header>
107
-
108
- <!-- HTML rendered note body (replaces canvas) -->
109
- <article id="note-body"></article>
110
-
111
- <!-- Backlinks -->
112
- <section id="backlinks-panel">
113
- <div id="backlinks-header">
114
- <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.5">
115
- <path d="M10 2L6 14M4 5L1 8l3 3M12 5l3 3-3 3"/>
116
- </svg>
117
- <span id="backlinks-count">0</span> references
118
- </div>
119
- <div id="backlinks-list"></div>
120
- </section>
113
+ <div id="note-view">
114
+ <div id="note-scroll">
115
+ <article id="note-article">
116
+ <div id="note-type-line"></div>
117
+ <h1 id="note-title"></h1>
118
+ <div id="note-meta">
119
+ <span id="note-date"></span>
120
+ <div id="note-tags"></div>
121
+ </div>
122
+ <div id="note-body"></div>
123
+ <section id="backlinks">
124
+ <div class="backlinks-label">
125
+ <span id="backlinks-count">0</span> references
126
+ </div>
127
+ <div id="backlinks-list"></div>
128
+ </section>
129
+ </article>
130
+ </div>
121
131
  </div>
122
132
 
123
133
  <!-- Graph view -->
124
- <div id="graph-view" style="display:none">
125
- <canvas id="graph-canvas"></canvas>
134
+ <div id="graph-view" class="hidden">
135
+ <canvas id="graph-canvas" aria-label="Knowledge graph visualization"></canvas>
126
136
  <div id="graph-tooltip"></div>
127
137
  </div>
128
138
 
129
139
  <!-- Empty state -->
130
140
  <div id="empty-state">
131
- <div id="empty-icon">
132
- <svg width="40" height="40" viewBox="0 0 40 40" fill="none" stroke="currentColor" stroke-width="0.75" opacity="0.15">
133
- <circle cx="12" cy="12" r="4"/>
134
- <circle cx="28" cy="12" r="4"/>
135
- <circle cx="20" cy="30" r="4"/>
136
- <path d="M15.5 14L18 26M24.5 14L22 26M16 12h8" stroke-dasharray="2 3"/>
141
+ <div class="empty-glyph">
142
+ <svg width="48" height="48" viewBox="0 0 48 48" fill="none">
143
+ <circle cx="14" cy="14" r="3" stroke="currentColor" stroke-width="1" opacity="0.2"/>
144
+ <circle cx="34" cy="14" r="3" stroke="currentColor" stroke-width="1" opacity="0.2"/>
145
+ <circle cx="24" cy="36" r="3" stroke="currentColor" stroke-width="1" opacity="0.2"/>
146
+ <path d="M16.5 16.5L22 32M31.5 16.5L26 32M18 14h12" stroke="currentColor" stroke-width="0.75" opacity="0.12" stroke-dasharray="3 4"/>
137
147
  </svg>
138
148
  </div>
139
- <p>Select a note to begin</p>
140
- <p class="empty-hint"><kbd>⌘K</kbd> search · <kbd>⌘N</kbd> new note · <kbd>⌘G</kbd> graph</p>
149
+ <p class="empty-title">Select a note to begin</p>
150
+ <p class="empty-shortcuts"><kbd>⌘K</kbd> search <span class="dot"></span> <kbd>⌘N</kbd> new <span class="dot"></span> <kbd>⌘G</kbd> graph</p>
141
151
  </div>
142
152
  </main>
143
153
  </div>
144
154
 
145
155
  <!-- Wikilink preview popup -->
146
- <div id="wikilink-preview" class="wikilink-preview">
156
+ <div id="wikilink-preview">
147
157
  <div class="preview-type"></div>
148
158
  <div class="preview-title"></div>
149
159
  <div class="preview-snippet"></div>
150
160
  <div class="preview-tags"></div>
151
161
  </div>
152
162
 
163
+ <!-- Grain overlay -->
164
+ <canvas id="grain" aria-hidden="true"></canvas>
165
+
153
166
  <script src="/markdown-renderer.js"></script>
154
167
  <script src="/graph.js"></script>
155
168
  <script src="/app.js"></script>
@@ -2,12 +2,10 @@
2
2
  * Granite — HTML Markdown Renderer
3
3
  *
4
4
  * Converts markdown + wikilinks to semantic HTML.
5
- * Wikilinks become real <a> elements with hover previews.
5
+ * Supports: headings, lists, blockquotes, code, tables, inline formatting, wikilinks.
6
6
  */
7
-
8
7
  const MarkdownRenderer = (() => {
9
8
 
10
- // ── Inline parser — handles [[wikilinks]], **bold**, *italic*, `code`, [links](url) ──
11
9
  function parseInline(text, outgoingLinks) {
12
10
  const re = /(\[\[([^\]]+)\]\])|(`([^`]+)`)|(\*\*(.+?)\*\*)|(\*(.+?)\*)|(\[([^\]]+)\]\(([^)]+)\))/g;
13
11
  let result = '';
@@ -15,12 +13,9 @@ const MarkdownRenderer = (() => {
15
13
  let match;
16
14
 
17
15
  while ((match = re.exec(text)) !== null) {
18
- if (match.index > lastIndex) {
19
- result += escapeHtml(text.slice(lastIndex, match.index));
20
- }
16
+ if (match.index > lastIndex) result += escapeHtml(text.slice(lastIndex, match.index));
21
17
 
22
18
  if (match[1]) {
23
- // [[wikilink]] or [[target|display]]
24
19
  const inner = match[2];
25
20
  const parts = inner.split('|');
26
21
  const target = parts[0].trim();
@@ -28,37 +23,26 @@ const MarkdownRenderer = (() => {
28
23
  const linkInfo = outgoingLinks?.find(l => l.target === target);
29
24
  const resolved = linkInfo?.resolved ?? false;
30
25
  const slug = linkInfo?.resolved_slug || null;
31
-
32
26
  if (resolved && slug) {
33
- result += `<a class="wikilink" data-slug="${escapeAttr(slug)}" data-resolved="true" href="javascript:void(0)">${escapeHtml(display)}</a>`;
27
+ result += `<a class="wikilink" data-slug="${escapeAttr(slug)}" href="#" role="link">${escapeHtml(display)}</a>`;
34
28
  } else {
35
29
  result += `<span class="wikilink-broken">${escapeHtml(display)}</span>`;
36
30
  }
37
31
  } else if (match[3]) {
38
- // `inline code`
39
32
  result += `<code class="inline-code">${escapeHtml(match[4])}</code>`;
40
33
  } else if (match[5]) {
41
- // **bold**
42
34
  result += `<strong>${escapeHtml(match[6])}</strong>`;
43
35
  } else if (match[7]) {
44
- // *italic*
45
36
  result += `<em>${escapeHtml(match[8])}</em>`;
46
37
  } else if (match[9]) {
47
- // [text](url)
48
38
  result += `<a class="external-link" href="${escapeAttr(match[11])}" target="_blank" rel="noopener">${escapeHtml(match[10])}</a>`;
49
39
  }
50
-
51
40
  lastIndex = match.index + match[0].length;
52
41
  }
53
-
54
- if (lastIndex < text.length) {
55
- result += escapeHtml(text.slice(lastIndex));
56
- }
57
-
42
+ if (lastIndex < text.length) result += escapeHtml(text.slice(lastIndex));
58
43
  return result;
59
44
  }
60
45
 
61
- // ── Block-level markdown parser ──
62
46
  function render(body, outgoingLinks) {
63
47
  const lines = body.split('\n');
64
48
  const blocks = [];
@@ -66,7 +50,7 @@ const MarkdownRenderer = (() => {
66
50
  let codeBuffer = [];
67
51
  let codeLang = '';
68
52
  let inList = false;
69
- let listType = null; // 'ul' or 'ol'
53
+ let listType = null;
70
54
  let listItems = [];
71
55
 
72
56
  function flushList() {
@@ -82,12 +66,11 @@ const MarkdownRenderer = (() => {
82
66
  for (let i = 0; i < lines.length; i++) {
83
67
  const line = lines[i];
84
68
 
85
- // Code block toggle
69
+ // Code block
86
70
  if (line.startsWith('```')) {
87
71
  if (inCodeBlock) {
88
- const langClass = codeLang ? ` data-lang="${escapeAttr(codeLang)}"` : '';
89
72
  const langLabel = codeLang ? `<span class="code-lang">${escapeHtml(codeLang)}</span>` : '';
90
- blocks.push(`<div class="code-block"${langClass}>${langLabel}<pre><code>${escapeHtml(codeBuffer.join('\n'))}</code></pre></div>`);
73
+ blocks.push(`<div class="code-block">${langLabel}<pre><code>${escapeHtml(codeBuffer.join('\n'))}</code></pre></div>`);
91
74
  codeBuffer = [];
92
75
  inCodeBlock = false;
93
76
  } else {
@@ -97,16 +80,25 @@ const MarkdownRenderer = (() => {
97
80
  }
98
81
  continue;
99
82
  }
100
-
101
- if (inCodeBlock) {
102
- codeBuffer.push(line);
103
- continue;
104
- }
83
+ if (inCodeBlock) { codeBuffer.push(line); continue; }
105
84
 
106
85
  // Blank line
107
- if (line.trim() === '') {
86
+ if (line.trim() === '') { flushList(); continue; }
87
+
88
+ // Table detection: line starts with |
89
+ if (line.trim().startsWith('|') && line.trim().endsWith('|')) {
108
90
  flushList();
109
- continue;
91
+ const tableLines = [];
92
+ let j = i;
93
+ while (j < lines.length && lines[j].trim().startsWith('|') && lines[j].trim().endsWith('|')) {
94
+ tableLines.push(lines[j]);
95
+ j++;
96
+ }
97
+ if (tableLines.length >= 2) {
98
+ blocks.push(renderTable(tableLines, outgoingLinks));
99
+ i = j - 1;
100
+ continue;
101
+ }
110
102
  }
111
103
 
112
104
  // Headings
@@ -114,12 +106,11 @@ const MarkdownRenderer = (() => {
114
106
  if (hMatch) {
115
107
  flushList();
116
108
  const level = hMatch[1].length;
117
- const content = parseInline(hMatch[2], outgoingLinks);
118
- blocks.push(`<h${level} class="md-h${level}">${content}</h${level}>`);
109
+ blocks.push(`<h${level} class="md-h${level}">${parseInline(hMatch[2], outgoingLinks)}</h${level}>`);
119
110
  continue;
120
111
  }
121
112
 
122
- // Horizontal rule
113
+ // HR
123
114
  if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
124
115
  flushList();
125
116
  blocks.push('<hr class="md-hr">');
@@ -130,8 +121,7 @@ const MarkdownRenderer = (() => {
130
121
  if (line.startsWith('>')) {
131
122
  flushList();
132
123
  const text = line.replace(/^>\s?/, '');
133
- const content = parseInline(text, outgoingLinks);
134
- blocks.push(`<blockquote class="md-blockquote">${content}</blockquote>`);
124
+ blocks.push(`<blockquote class="md-blockquote">${parseInline(text, outgoingLinks)}</blockquote>`);
135
125
  continue;
136
126
  }
137
127
 
@@ -141,8 +131,7 @@ const MarkdownRenderer = (() => {
141
131
  if (listType !== 'ul') flushList();
142
132
  inList = true;
143
133
  listType = 'ul';
144
- const content = parseInline(bulletMatch[2], outgoingLinks);
145
- listItems.push(`<li>${content}</li>`);
134
+ listItems.push(`<li>${parseInline(bulletMatch[2], outgoingLinks)}</li>`);
146
135
  continue;
147
136
  }
148
137
 
@@ -152,33 +141,42 @@ const MarkdownRenderer = (() => {
152
141
  if (listType !== 'ol') flushList();
153
142
  inList = true;
154
143
  listType = 'ol';
155
- const content = parseInline(numMatch[2], outgoingLinks);
156
- listItems.push(`<li>${content}</li>`);
144
+ listItems.push(`<li>${parseInline(numMatch[2], outgoingLinks)}</li>`);
157
145
  continue;
158
146
  }
159
147
 
160
- // Body paragraph
148
+ // Paragraph
161
149
  flushList();
162
- const content = parseInline(line, outgoingLinks);
163
- blocks.push(`<p class="md-body">${content}</p>`);
150
+ blocks.push(`<p class="md-body">${parseInline(line, outgoingLinks)}</p>`);
164
151
  }
165
152
 
166
- // Flush remaining
167
153
  flushList();
168
154
  if (inCodeBlock && codeBuffer.length > 0) {
169
155
  blocks.push(`<div class="code-block"><pre><code>${escapeHtml(codeBuffer.join('\n'))}</code></pre></div>`);
170
156
  }
171
-
172
157
  return blocks.join('\n');
173
158
  }
174
159
 
175
- // ── Helpers ──
160
+ function renderTable(tableLines, outgoingLinks) {
161
+ const parseRow = (line) => line.trim().replace(/^\||\|$/g, '').split('|').map(cell => cell.trim());
162
+ const headers = parseRow(tableLines[0]);
163
+ // Skip separator row (index 1)
164
+ const rows = tableLines.slice(2).map(parseRow);
165
+
166
+ let html = '<div class="md-table-wrap"><table class="md-table"><thead><tr>';
167
+ for (const h of headers) html += `<th>${parseInline(h, outgoingLinks)}</th>`;
168
+ html += '</tr></thead><tbody>';
169
+ for (const row of rows) {
170
+ html += '<tr>';
171
+ for (const cell of row) html += `<td>${parseInline(cell, outgoingLinks)}</td>`;
172
+ html += '</tr>';
173
+ }
174
+ html += '</tbody></table></div>';
175
+ return html;
176
+ }
177
+
176
178
  function escapeHtml(str) {
177
- return str
178
- .replace(/&/g, '&amp;')
179
- .replace(/</g, '&lt;')
180
- .replace(/>/g, '&gt;')
181
- .replace(/"/g, '&quot;');
179
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
182
180
  }
183
181
 
184
182
  function escapeAttr(str) {