@invisibleloop/pulse 0.5.0 → 0.6.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.
Files changed (99) hide show
  1. package/CLAUDE.md +36 -0
  2. package/docs/.claude/pulse-checklist.md +7 -5
  3. package/docs/public/.pulse-ui-version +1 -1
  4. package/docs/public/pulse-ui.css +27 -0
  5. package/docs/server.js +1 -0
  6. package/docs/src/lib/highlight.js +2 -299
  7. package/docs/src/lib/nav.js +1 -0
  8. package/docs/src/pages/accessibility.js +0 -1
  9. package/docs/src/pages/auth.js +0 -2
  10. package/docs/src/pages/caching.js +0 -1
  11. package/docs/src/pages/components/accordion.js +0 -1
  12. package/docs/src/pages/components/alert.js +0 -1
  13. package/docs/src/pages/components/app-badge.js +0 -1
  14. package/docs/src/pages/components/avatar.js +0 -1
  15. package/docs/src/pages/components/badge.js +0 -1
  16. package/docs/src/pages/components/banner.js +0 -1
  17. package/docs/src/pages/components/breadcrumbs.js +0 -1
  18. package/docs/src/pages/components/button.js +0 -1
  19. package/docs/src/pages/components/card.js +0 -1
  20. package/docs/src/pages/components/carousel.js +0 -1
  21. package/docs/src/pages/components/charts.js +0 -1
  22. package/docs/src/pages/components/checkbox.js +0 -1
  23. package/docs/src/pages/components/cluster.js +0 -1
  24. package/docs/src/pages/components/code-window.js +0 -1
  25. package/docs/src/pages/components/container.js +0 -1
  26. package/docs/src/pages/components/divider.js +0 -1
  27. package/docs/src/pages/components/empty.js +0 -1
  28. package/docs/src/pages/components/feature.js +0 -1
  29. package/docs/src/pages/components/fieldset.js +0 -1
  30. package/docs/src/pages/components/footer.js +0 -1
  31. package/docs/src/pages/components/grid.js +0 -1
  32. package/docs/src/pages/components/heading.js +0 -1
  33. package/docs/src/pages/components/icons.js +0 -1
  34. package/docs/src/pages/components/image.js +0 -1
  35. package/docs/src/pages/components/input.js +0 -1
  36. package/docs/src/pages/components/list.js +0 -1
  37. package/docs/src/pages/components/media.js +0 -1
  38. package/docs/src/pages/components/modal.js +0 -1
  39. package/docs/src/pages/components/nav.js +0 -1
  40. package/docs/src/pages/components/pricing.js +0 -1
  41. package/docs/src/pages/components/progress.js +0 -1
  42. package/docs/src/pages/components/prose.js +2 -3
  43. package/docs/src/pages/components/pullquote.js +0 -1
  44. package/docs/src/pages/components/radio.js +0 -1
  45. package/docs/src/pages/components/rating.js +0 -1
  46. package/docs/src/pages/components/search.js +0 -1
  47. package/docs/src/pages/components/section.js +0 -1
  48. package/docs/src/pages/components/segmented.js +0 -1
  49. package/docs/src/pages/components/select.js +0 -1
  50. package/docs/src/pages/components/spinner.js +0 -1
  51. package/docs/src/pages/components/stack.js +0 -1
  52. package/docs/src/pages/components/stat.js +0 -1
  53. package/docs/src/pages/components/stepper.js +0 -1
  54. package/docs/src/pages/components/table.js +0 -1
  55. package/docs/src/pages/components/testimonial.js +0 -1
  56. package/docs/src/pages/components/textarea.js +0 -1
  57. package/docs/src/pages/components/timeline.js +0 -1
  58. package/docs/src/pages/components/toggle.js +0 -1
  59. package/docs/src/pages/components/tooltip.js +0 -1
  60. package/docs/src/pages/components.js +0 -1
  61. package/docs/src/pages/config.js +0 -1
  62. package/docs/src/pages/deploy.js +0 -1
  63. package/docs/src/pages/extending.js +0 -1
  64. package/docs/src/pages/faq.js +0 -1
  65. package/docs/src/pages/getting-started.js +0 -1
  66. package/docs/src/pages/guard.js +0 -3
  67. package/docs/src/pages/how-it-works.js +0 -1
  68. package/docs/src/pages/images.js +0 -2
  69. package/docs/src/pages/markdown.js +155 -0
  70. package/docs/src/pages/meta.js +0 -2
  71. package/docs/src/pages/navigation.js +0 -1
  72. package/docs/src/pages/performance.js +0 -2
  73. package/docs/src/pages/project-structure.js +0 -2
  74. package/docs/src/pages/prompt-examples.js +0 -1
  75. package/docs/src/pages/raw-responses.js +0 -4
  76. package/docs/src/pages/routing.js +0 -2
  77. package/docs/src/pages/server-api.js +0 -2
  78. package/docs/src/pages/server-data.js +18 -1
  79. package/docs/src/pages/slash-commands.js +0 -1
  80. package/docs/src/pages/spec.js +2 -1
  81. package/docs/src/pages/streaming.js +0 -2
  82. package/docs/src/pages/testing.js +0 -1
  83. package/docs/src/pages/validation.js +0 -1
  84. package/package.json +5 -1
  85. package/public/.pulse-ui-version +1 -1
  86. package/public/pulse-ui.css +27 -0
  87. package/scripts/build.js +53 -2
  88. package/scripts/strip-server.test.js +74 -2
  89. package/src/agent/checklist.md +20 -0
  90. package/src/agent/guide-server.md +62 -0
  91. package/src/agent/guide-spec.md +5 -3
  92. package/src/agent/guide.md +105 -2
  93. package/src/cli/dev.js +4 -0
  94. package/src/cli/scaffold.js +0 -2
  95. package/src/cli/start.js +1 -0
  96. package/src/md/highlight.js +369 -0
  97. package/src/md/index.js +337 -0
  98. package/src/spec/schema.js +8 -1
  99. package/types/md.d.ts +66 -0
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Pulse — Server-side syntax highlighter
3
+ *
4
+ * Character-by-character tokeniser. Zero dependencies, zero client JS.
5
+ * Returns an HTML string of <span class="tok-*"> elements ready to drop
6
+ * inside a <code> element.
7
+ *
8
+ * Supported languages: js, ts, jsx, tsx, html, xml, css, bash/sh, json
9
+ * Unknown languages: plain-text escape (no colour tokens).
10
+ */
11
+
12
+ const JS_KEYWORDS = new Set([
13
+ 'export', 'default', 'import', 'from', 'as',
14
+ 'async', 'await', 'const', 'let', 'var',
15
+ 'return', 'if', 'else', 'for', 'while', 'of', 'in', 'do', 'break', 'continue',
16
+ 'true', 'false', 'null', 'undefined', 'void', 'NaN', 'Infinity',
17
+ 'function', 'class', 'new', 'this', 'super', 'typeof', 'instanceof', 'delete',
18
+ 'try', 'catch', 'finally', 'throw', 'switch', 'case',
19
+ 'type', 'interface', 'enum', 'extends', 'implements', 'static', 'abstract',
20
+ ])
21
+
22
+ function esc(s) {
23
+ return String(s)
24
+ .replace(/&/g, '&amp;')
25
+ .replace(/</g, '&lt;')
26
+ .replace(/>/g, '&gt;')
27
+ }
28
+
29
+ function span(cls, text) {
30
+ return `<span class="${cls}">${esc(text)}</span>`
31
+ }
32
+
33
+ export function highlight(code, lang = '') {
34
+ const l = (lang || '').toLowerCase()
35
+ if (l === 'js' || l === 'javascript' || l === 'ts' || l === 'typescript' || l === 'jsx' || l === 'tsx' || l === 'mjs' || l === 'cjs')
36
+ return highlightJs(code)
37
+ if (l === 'html' || l === 'xml' || l === 'svg' || l === 'vue' || l === 'svelte')
38
+ return highlightHtml(code)
39
+ if (l === 'css' || l === 'scss' || l === 'less')
40
+ return highlightCss(code)
41
+ if (l === 'bash' || l === 'sh' || l === 'shell' || l === 'zsh')
42
+ return highlightBash(code)
43
+ if (l === 'json' || l === 'jsonc')
44
+ return highlightJson(code)
45
+ // Unknown language — escape only, no colour tokens
46
+ return esc(code)
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // JavaScript / TypeScript
51
+ // ---------------------------------------------------------------------------
52
+
53
+ function highlightJs(code) {
54
+ let out = ''
55
+ let i = 0
56
+ const n = code.length
57
+
58
+ while (i < n) {
59
+ const ch = code[i]
60
+ const ch2 = code[i + 1]
61
+
62
+ // Single-line comment
63
+ if (ch === '/' && ch2 === '/') {
64
+ const end = code.indexOf('\n', i)
65
+ const tok = end === -1 ? code.slice(i) : code.slice(i, end)
66
+ out += span('tok-cmt', tok)
67
+ i += tok.length
68
+ continue
69
+ }
70
+
71
+ // Block comment
72
+ if (ch === '/' && ch2 === '*') {
73
+ const end = code.indexOf('*/', i + 2)
74
+ const tok = end === -1 ? code.slice(i) : code.slice(i, end + 2)
75
+ out += span('tok-cmt', tok)
76
+ i += tok.length
77
+ continue
78
+ }
79
+
80
+ // Template literal
81
+ if (ch === '`') {
82
+ let tok = '`'
83
+ i++
84
+ let depth = 0
85
+ while (i < n) {
86
+ if (code[i] === '\\') { tok += code[i] + (code[i + 1] || ''); i += 2; continue }
87
+ if (code[i] === '$' && code[i + 1] === '{') { tok += '${'; i += 2; depth++; continue }
88
+ if (code[i] === '}' && depth > 0) { tok += '}'; i++; depth--; continue }
89
+ if (code[i] === '`' && depth === 0) { tok += '`'; i++; break }
90
+ tok += code[i++]
91
+ }
92
+ out += span('tok-str', tok)
93
+ continue
94
+ }
95
+
96
+ // String
97
+ if (ch === '"' || ch === "'") {
98
+ let tok = ch; i++
99
+ while (i < n && code[i] !== ch && code[i] !== '\n') {
100
+ if (code[i] === '\\') { tok += code[i] + (code[i + 1] || ''); i += 2; continue }
101
+ tok += code[i++]
102
+ }
103
+ if (i < n && code[i] === ch) tok += code[i++]
104
+ out += span('tok-str', tok)
105
+ continue
106
+ }
107
+
108
+ // Number
109
+ if (/\d/.test(ch) && (i === 0 || !/\w/.test(code[i - 1]))) {
110
+ let tok = ''
111
+ while (i < n && /[\d.xXa-fA-F_nN]/.test(code[i])) tok += code[i++]
112
+ out += span('tok-num', tok)
113
+ continue
114
+ }
115
+
116
+ // Identifier / keyword
117
+ if (/[a-zA-Z_$]/.test(ch)) {
118
+ let tok = ''
119
+ while (i < n && /[\w$]/.test(code[i])) tok += code[i++]
120
+ if (JS_KEYWORDS.has(tok)) {
121
+ out += span('tok-kw', tok)
122
+ } else {
123
+ let j = i
124
+ while (j < n && code[j] === ' ') j++
125
+ out += code[j] === '(' ? span('tok-fn', tok) : esc(tok)
126
+ }
127
+ continue
128
+ }
129
+
130
+ // Operators
131
+ if (/[=!<>|&?+\-*/^%~]/.test(ch)) {
132
+ let tok = ch
133
+ if (/[=!<>|&?]/.test(ch) && /[=|&>?]/.test(ch2)) {
134
+ tok = code.slice(i, i + 2)
135
+ if (code[i + 2] === '=') tok = code.slice(i, i + 3)
136
+ }
137
+ out += span('tok-op', tok)
138
+ i += tok.length
139
+ continue
140
+ }
141
+
142
+ // Punctuation
143
+ if (/[{}()[\],;:.]/.test(ch)) { out += span('tok-punct', ch); i++; continue }
144
+
145
+ out += esc(ch); i++
146
+ }
147
+
148
+ return out
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // HTML / XML
153
+ // ---------------------------------------------------------------------------
154
+
155
+ function highlightHtmlTag(tag) {
156
+ let out = ''
157
+ let i = 0
158
+ out += '&lt;'; i++
159
+ if (tag[i] === '/') { out += '/'; i++ }
160
+
161
+ let name = ''
162
+ while (i < tag.length && /[\w:-]/.test(tag[i])) name += tag[i++]
163
+ if (name) out += span('tok-kw', name)
164
+
165
+ while (i < tag.length) {
166
+ const ch = tag[i]
167
+ if (ch === '>') { out += '&gt;'; i++; break }
168
+ if (ch === '/') { out += '/'; i++; continue }
169
+ if (/[\w-]/.test(ch)) {
170
+ let attr = ''
171
+ while (i < tag.length && /[\w:-]/.test(tag[i])) attr += tag[i++]
172
+ if (tag[i] === '=') {
173
+ out += span('tok-fn', attr) + esc('='); i++
174
+ const q = tag[i]
175
+ if (q === '"' || q === "'") {
176
+ let val = q; i++
177
+ while (i < tag.length && tag[i] !== q) {
178
+ if (tag[i] === '\\') { val += tag[i] + (tag[i + 1] || ''); i += 2; continue }
179
+ val += tag[i++]
180
+ }
181
+ if (i < tag.length) val += tag[i++]
182
+ out += span('tok-str', val)
183
+ }
184
+ } else {
185
+ out += esc(attr)
186
+ }
187
+ continue
188
+ }
189
+ out += esc(ch); i++
190
+ }
191
+ return out
192
+ }
193
+
194
+ function highlightHtml(code) {
195
+ let out = ''
196
+ let i = 0
197
+ const n = code.length
198
+ while (i < n) {
199
+ if (code.slice(i, i + 4) === '<!--') {
200
+ const end = code.indexOf('-->', i + 4)
201
+ const tok = end === -1 ? code.slice(i) : code.slice(i, end + 3)
202
+ out += span('tok-cmt', tok); i += tok.length; continue
203
+ }
204
+ if (code[i] === '<') {
205
+ const end = code.indexOf('>', i)
206
+ if (end === -1) { out += esc(code[i++]); continue }
207
+ out += highlightHtmlTag(code.slice(i, end + 1)); i = end + 1; continue
208
+ }
209
+ out += esc(code[i++])
210
+ }
211
+ return out
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // CSS / SCSS
216
+ // ---------------------------------------------------------------------------
217
+
218
+ function highlightCss(code) {
219
+ let out = ''
220
+ let i = 0
221
+ const n = code.length
222
+
223
+ while (i < n) {
224
+ // Block comment
225
+ if (code[i] === '/' && code[i + 1] === '*') {
226
+ const end = code.indexOf('*/', i + 2)
227
+ const tok = end === -1 ? code.slice(i) : code.slice(i, end + 2)
228
+ out += span('tok-cmt', tok); i += tok.length; continue
229
+ }
230
+
231
+ // String
232
+ if (code[i] === '"' || code[i] === "'") {
233
+ const q = code[i]; let tok = q; i++
234
+ while (i < n && code[i] !== q && code[i] !== '\n') {
235
+ if (code[i] === '\\') { tok += code[i] + (code[i + 1] || ''); i += 2; continue }
236
+ tok += code[i++]
237
+ }
238
+ if (i < n && code[i] === q) tok += code[i++]
239
+ out += span('tok-str', tok); continue
240
+ }
241
+
242
+ // At-rule
243
+ if (code[i] === '@') {
244
+ let tok = '@'; i++
245
+ while (i < n && /[\w-]/.test(code[i])) tok += code[i++]
246
+ out += span('tok-kw', tok); continue
247
+ }
248
+
249
+ // Hex colour
250
+ if (code[i] === '#' && /[0-9a-fA-F]/.test(code[i + 1] || '')) {
251
+ let tok = '#'; i++
252
+ while (i < n && /[0-9a-fA-F]/.test(code[i])) tok += code[i++]
253
+ out += span('tok-num', tok); continue
254
+ }
255
+
256
+ // Number + optional unit
257
+ if (/\d/.test(code[i]) || (code[i] === '-' && /\d/.test(code[i + 1] || ''))) {
258
+ let tok = ''
259
+ if (code[i] === '-') tok += code[i++]
260
+ while (i < n && /[\d.]/.test(code[i])) tok += code[i++]
261
+ while (i < n && /[a-z%]/.test(code[i])) tok += code[i++]
262
+ out += span('tok-num', tok); continue
263
+ }
264
+
265
+ // Property name or keyword
266
+ if (/[a-zA-Z_-]/.test(code[i])) {
267
+ let tok = ''
268
+ while (i < n && /[\w-]/.test(code[i])) tok += code[i++]
269
+ let j = i
270
+ while (j < n && code[j] === ' ') j++
271
+ if (code[j] === ':' && code[j + 1] !== ':') {
272
+ out += span('tok-fn', tok)
273
+ } else {
274
+ out += esc(tok)
275
+ }
276
+ continue
277
+ }
278
+
279
+ // Punctuation
280
+ if (/[{}();:,]/.test(code[i])) { out += span('tok-punct', code[i++]); continue }
281
+
282
+ out += esc(code[i++])
283
+ }
284
+ return out
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Bash / Shell
289
+ // ---------------------------------------------------------------------------
290
+
291
+ function highlightBash(code) {
292
+ return code.split('\n').map(line => {
293
+ if (/^\s*#/.test(line)) return span('tok-cmt', line)
294
+ let out = ''; let i = 0
295
+ while (i < line.length) {
296
+ if (line[i] === '#') { out += span('tok-cmt', line.slice(i)); break }
297
+ if (line[i] === '"' || line[i] === "'") {
298
+ const q = line[i]; let tok = q; i++
299
+ while (i < line.length && line[i] !== q) tok += line[i++]
300
+ tok += line[i] === q ? line[i++] : ''
301
+ out += span('tok-str', tok); continue
302
+ }
303
+ if (line[i] === '$') {
304
+ let tok = '$'; i++
305
+ if (line[i] === '{') {
306
+ while (i < line.length && line[i] !== '}') tok += line[i++]
307
+ tok += line[i] === '}' ? line[i++] : ''
308
+ } else {
309
+ while (i < line.length && /[\w]/.test(line[i])) tok += line[i++]
310
+ }
311
+ out += span('tok-kw', tok); continue
312
+ }
313
+ if (line[i] === '-' && /\w/.test(line[i + 1] || '')) {
314
+ let tok = '-'; i++
315
+ while (i < line.length && /[-\w]/.test(line[i])) tok += line[i++]
316
+ out += span('tok-op', tok); continue
317
+ }
318
+ out += esc(line[i++])
319
+ }
320
+ return out
321
+ }).join('\n')
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // JSON
326
+ // ---------------------------------------------------------------------------
327
+
328
+ function highlightJson(code) {
329
+ let out = ''
330
+ let i = 0
331
+ const n = code.length
332
+
333
+ while (i < n) {
334
+ // String (key or value)
335
+ if (code[i] === '"') {
336
+ let tok = '"'; i++
337
+ while (i < n && code[i] !== '"') {
338
+ if (code[i] === '\\') { tok += code[i] + (code[i + 1] || ''); i += 2; continue }
339
+ tok += code[i++]
340
+ }
341
+ tok += code[i] === '"' ? code[i++] : ''
342
+ // Key: followed (after whitespace) by ':'
343
+ let j = i
344
+ while (j < n && /\s/.test(code[j])) j++
345
+ out += code[j] === ':' ? span('tok-fn', tok) : span('tok-str', tok)
346
+ continue
347
+ }
348
+
349
+ // Number
350
+ if (/[-\d]/.test(code[i])) {
351
+ let tok = ''
352
+ while (i < n && /[-\d.eE+]/.test(code[i])) tok += code[i++]
353
+ out += span('tok-num', tok); continue
354
+ }
355
+
356
+ // Keywords: true, false, null
357
+ if (/[a-z]/.test(code[i])) {
358
+ let tok = ''
359
+ while (i < n && /[a-z]/.test(code[i])) tok += code[i++]
360
+ out += span('tok-kw', tok); continue
361
+ }
362
+
363
+ // Punctuation
364
+ if (/[{}[\],:]/.test(code[i])) { out += span('tok-punct', code[i++]); continue }
365
+
366
+ out += esc(code[i++])
367
+ }
368
+ return out
369
+ }
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Pulse — Markdown parser + file helper
3
+ *
4
+ * Zero dependencies. CommonMark subset + GFM tables + frontmatter.
5
+ *
6
+ * Exports:
7
+ * parseMd(source) → { html, frontmatter }
8
+ * md(pathPattern) → async (ctx) => { html, frontmatter }
9
+ */
10
+
11
+ import fs from 'node:fs/promises'
12
+ import path from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+ import { highlight } from './highlight.js'
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // HTML escaping
18
+ // ---------------------------------------------------------------------------
19
+
20
+ function esc(s) {
21
+ return String(s)
22
+ .replace(/&/g, '&amp;')
23
+ .replace(/</g, '&lt;')
24
+ .replace(/>/g, '&gt;')
25
+ .replace(/"/g, '&quot;')
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Frontmatter — --- key: value --- block at top of file
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function extractFrontmatter(source) {
33
+ if (!source.startsWith('---')) return { frontmatter: {}, body: source }
34
+ const end = source.indexOf('\n---', 3)
35
+ if (end === -1) return { frontmatter: {}, body: source }
36
+
37
+ const frontmatter = {}
38
+ for (const line of source.slice(3, end).split('\n')) {
39
+ const colon = line.indexOf(':')
40
+ if (colon === -1) continue
41
+ const key = line.slice(0, colon).trim()
42
+ let val = line.slice(colon + 1).trim()
43
+ if ((val.startsWith('"') && val.endsWith('"')) ||
44
+ (val.startsWith("'") && val.endsWith("'"))) {
45
+ val = val.slice(1, -1)
46
+ }
47
+ if (key) frontmatter[key] = val
48
+ }
49
+ return { frontmatter, body: source.slice(end + 4) }
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Inline rendering
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function renderInline(raw) {
57
+ // 1. Protect inline code from further processing
58
+ const codes = []
59
+ let s = raw.replace(/`([^`\n]+)`/g, (_, code) => {
60
+ const idx = codes.length
61
+ codes.push(`<code>${esc(code)}</code>`)
62
+ return `\x00${idx}\x00`
63
+ })
64
+
65
+ // 2. Escape HTML in the remaining text. The \x00N\x00 placeholders contain
66
+ // only digits and the null byte, so esc() leaves them untouched.
67
+ s = esc(s)
68
+
69
+ // 3. Images (before links — same syntax with leading !)
70
+ s = s.replace(/!\[([^\]]*)\]\(([^)\s"]+)(?:\s+"([^"]*)")?\)/g,
71
+ (_, alt, src, title) =>
72
+ `<img src="${src}" alt="${alt}"${title ? ` title="${title}"` : ''}>`)
73
+
74
+ // 4. Links
75
+ s = s.replace(/\[([^\]]+)\]\(([^)\s"]+)(?:\s+"([^"]*)")?\)/g,
76
+ (_, label, href, title) =>
77
+ `<a href="${href}"${title ? ` title="${title}"` : ''}>${label}</a>`)
78
+
79
+ // 5. Bold + italic (order: *** before ** before *)
80
+ s = s.replace(/\*{3}([^*\n]+)\*{3}/g, (_, t) => `<strong><em>${t}</em></strong>`)
81
+ s = s.replace(/\*{2}([^*\n]+)\*{2}/g, (_, t) => `<strong>${t}</strong>`)
82
+ s = s.replace(/_{2}([^_\n]+)_{2}/g, (_, t) => `<strong>${t}</strong>`)
83
+ s = s.replace(/\*([^*\n]+)\*/g, (_, t) => `<em>${t}</em>`)
84
+ s = s.replace(/_([^_\n]+)_/g, (_, t) => `<em>${t}</em>`)
85
+ s = s.replace(/~~([^~\n]+)~~/g, (_, t) => `<del>${t}</del>`)
86
+
87
+ // 6. Restore code spans
88
+ s = s.replace(/\x00(\d+)\x00/g, (_, i) => codes[+i])
89
+
90
+ return s
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Block parser
95
+ // ---------------------------------------------------------------------------
96
+
97
+ function parseBlocks(text) {
98
+ const lines = text.split('\n')
99
+ const blocks = []
100
+ let i = 0
101
+
102
+ while (i < lines.length) {
103
+ const line = lines[i]
104
+
105
+ // Blank line
106
+ if (line.trim() === '') { i++; continue }
107
+
108
+ // Fenced code block (``` or ~~~)
109
+ const fenceMatch = line.match(/^(`{3,}|~{3,})(\S*)/)
110
+ if (fenceMatch) {
111
+ const fence = fenceMatch[1]
112
+ const lang = fenceMatch[2]
113
+ const codeLines = []
114
+ i++
115
+ while (i < lines.length && !lines[i].startsWith(fence)) {
116
+ codeLines.push(lines[i]); i++
117
+ }
118
+ i++ // skip closing fence
119
+ blocks.push({ type: 'code', lang, content: codeLines.join('\n') })
120
+ continue
121
+ }
122
+
123
+ // ATX heading (# through ######)
124
+ const headMatch = line.match(/^(#{1,6})\s+(.*?)(?:\s+#+\s*)?$/)
125
+ if (headMatch) {
126
+ blocks.push({ type: 'heading', level: headMatch[1].length, content: headMatch[2] })
127
+ i++; continue
128
+ }
129
+
130
+ // Horizontal rule
131
+ if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) {
132
+ blocks.push({ type: 'hr' }); i++; continue
133
+ }
134
+
135
+ // Blockquote
136
+ if (/^>\s?/.test(line)) {
137
+ const quoteLines = []
138
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
139
+ quoteLines.push(lines[i].replace(/^>\s?/, '')); i++
140
+ }
141
+ blocks.push({ type: 'blockquote', content: quoteLines.join('\n') })
142
+ continue
143
+ }
144
+
145
+ // Unordered list
146
+ if (/^[-*+]\s/.test(line)) {
147
+ const items = []
148
+ while (i < lines.length && /^[-*+]\s/.test(lines[i])) {
149
+ const itemLines = [lines[i].replace(/^[-*+]\s/, '')]
150
+ i++
151
+ while (i < lines.length && /^[ \t]{2}/.test(lines[i])) {
152
+ itemLines.push(lines[i].replace(/^[ \t]{2}/, '')); i++
153
+ }
154
+ items.push(itemLines.join('\n'))
155
+ }
156
+ blocks.push({ type: 'ul', items }); continue
157
+ }
158
+
159
+ // Ordered list
160
+ if (/^\d+[.)]\s/.test(line)) {
161
+ const items = []
162
+ while (i < lines.length && /^\d+[.)]\s/.test(lines[i])) {
163
+ const itemLines = [lines[i].replace(/^\d+[.)]\s/, '')]
164
+ i++
165
+ while (i < lines.length && /^[ \t]{3}/.test(lines[i])) {
166
+ itemLines.push(lines[i].replace(/^[ \t]{3}/, '')); i++
167
+ }
168
+ items.push(itemLines.join('\n'))
169
+ }
170
+ blocks.push({ type: 'ol', items }); continue
171
+ }
172
+
173
+ // Table (GFM) — header row followed by a separator row (|---|---|)
174
+ if (line.includes('|') && /^[\s|:\-]+$/.test(lines[i + 1] || '')) {
175
+ const tableLines = []
176
+ while (i < lines.length && lines[i].trim() !== '' && lines[i].includes('|')) {
177
+ tableLines.push(lines[i]); i++
178
+ }
179
+ blocks.push({ type: 'table', lines: tableLines }); continue
180
+ }
181
+
182
+ // Paragraph — accumulate until a blank line or the start of a new block
183
+ const paraLines = []
184
+ while (i < lines.length) {
185
+ const l = lines[i]
186
+ if (l.trim() === '') break
187
+ if (/^#{1,6}\s/.test(l)) break
188
+ if (/^(`{3,}|~{3,})/.test(l)) break
189
+ if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(l)) break
190
+ if (/^>\s?/.test(l)) break
191
+ if (/^[-*+]\s/.test(l)) break
192
+ if (/^\d+[.)]\s/.test(l)) break
193
+ paraLines.push(l); i++
194
+ }
195
+ if (paraLines.length > 0) {
196
+ blocks.push({ type: 'paragraph', content: paraLines.join(' ') })
197
+ }
198
+ }
199
+
200
+ return blocks
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Block renderer
205
+ // ---------------------------------------------------------------------------
206
+
207
+ function renderListItem(content) {
208
+ const lines = content.split('\n')
209
+ // Single-line item → inline only
210
+ if (lines.length === 1) return renderInline(lines[0])
211
+ // Multi-line item (nested list or block content) → recurse
212
+ return markdownToHtml(content)
213
+ }
214
+
215
+ function renderTable(tableLines) {
216
+ const rows = tableLines.map(l =>
217
+ l.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim())
218
+ )
219
+ // rows[0] = header, rows[1] = separator, rows[2..] = body
220
+ const [header, , ...body] = rows
221
+ const thead = header.map(h => `<th>${renderInline(h)}</th>`).join('')
222
+ const tbody = body.map(row =>
223
+ `<tr>${row.map(cell => `<td>${renderInline(cell)}</td>`).join('')}</tr>`
224
+ ).join('')
225
+ return `<table><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>`
226
+ }
227
+
228
+ function renderBlocks(blocks) {
229
+ return blocks.map(block => {
230
+ switch (block.type) {
231
+ case 'heading': {
232
+ const id = block.content
233
+ .toLowerCase()
234
+ .replace(/[^\w\s-]/g, '')
235
+ .trim()
236
+ .replace(/\s+/g, '-')
237
+ return `<h${block.level} id="${id}">${renderInline(block.content)}</h${block.level}>`
238
+ }
239
+ case 'code': {
240
+ const hi = highlight(block.content, block.lang)
241
+ const cls = block.lang ? ` class="language-${esc(block.lang)}"` : ''
242
+ return `<pre><code${cls}>${hi}</code></pre>`
243
+ }
244
+ case 'hr':
245
+ return '<hr>'
246
+ case 'blockquote':
247
+ return `<blockquote>${markdownToHtml(block.content)}</blockquote>`
248
+ case 'ul':
249
+ return `<ul>\n${block.items.map(item => `<li>${renderListItem(item)}</li>`).join('\n')}\n</ul>`
250
+ case 'ol':
251
+ return `<ol>\n${block.items.map(item => `<li>${renderListItem(item)}</li>`).join('\n')}\n</ol>`
252
+ case 'table':
253
+ return renderTable(block.lines)
254
+ case 'paragraph':
255
+ return `<p>${renderInline(block.content)}</p>`
256
+ default:
257
+ return ''
258
+ }
259
+ }).join('\n')
260
+ }
261
+
262
+ function markdownToHtml(text) {
263
+ return renderBlocks(parseBlocks(text))
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Public API
268
+ // ---------------------------------------------------------------------------
269
+
270
+ /**
271
+ * Parse a markdown string into HTML + frontmatter.
272
+ *
273
+ * @param {string} source - Raw markdown text
274
+ * @returns {{ html: string, frontmatter: Record<string, string> }}
275
+ */
276
+ export function parseMd(source) {
277
+ const { frontmatter, body } = extractFrontmatter(source)
278
+ return { html: markdownToHtml(body), frontmatter }
279
+ }
280
+
281
+ /**
282
+ * Create a server fetcher that reads and parses a markdown file.
283
+ * Use in spec.server — the result ({ html, frontmatter }) is passed to view.
284
+ *
285
+ * Path patterns support :param placeholders resolved from ctx.params:
286
+ * md('content/blog/:slug.md')
287
+ *
288
+ * The result is cached per request on ctx._mdCache so meta + server can
289
+ * both call the same fetcher without reading the file twice.
290
+ *
291
+ * @param {string | URL} pathPattern - File path or URL, supports :param segments
292
+ * @returns {(ctx?: object) => Promise<{ html: string, frontmatter: Record<string, string> }>}
293
+ */
294
+ export function md(pathPattern) {
295
+ const pattern = pathPattern instanceof URL
296
+ ? fileURLToPath(pathPattern)
297
+ : String(pathPattern)
298
+
299
+ const isAbsolute = path.isAbsolute(pattern)
300
+
301
+ return async function mdFetcher(ctx) {
302
+ // Resolve :param placeholders — strip any path-traversal chars from values
303
+ let resolved = pattern
304
+ if (ctx?.params) {
305
+ for (const [k, v] of Object.entries(ctx.params)) {
306
+ const safe = String(v).replace(/\.\.|[/\\]/g, '')
307
+ resolved = resolved.replace(`:${k}`, safe)
308
+ }
309
+ }
310
+
311
+ const absPath = isAbsolute
312
+ ? path.normalize(resolved)
313
+ : path.join(process.cwd(), resolved)
314
+
315
+ // Request-scoped cache — avoid reading the same file twice per request
316
+ if (ctx) {
317
+ ctx._mdCache ??= {}
318
+ if (ctx._mdCache[absPath]) return ctx._mdCache[absPath]
319
+ }
320
+
321
+ let source
322
+ try {
323
+ source = await fs.readFile(absPath, 'utf8')
324
+ } catch (err) {
325
+ if (err.code === 'ENOENT') {
326
+ const e = new Error(`Markdown file not found: ${absPath}`)
327
+ e.status = 404
328
+ throw e
329
+ }
330
+ throw err
331
+ }
332
+
333
+ const result = parseMd(source)
334
+ if (ctx) ctx._mdCache[absPath] = result
335
+ return result
336
+ }
337
+ }