@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.
- package/CLAUDE.md +36 -0
- package/docs/.claude/pulse-checklist.md +7 -5
- package/docs/public/.pulse-ui-version +1 -1
- package/docs/public/pulse-ui.css +27 -0
- package/docs/server.js +1 -0
- package/docs/src/lib/highlight.js +2 -299
- package/docs/src/lib/nav.js +1 -0
- package/docs/src/pages/accessibility.js +0 -1
- package/docs/src/pages/auth.js +0 -2
- package/docs/src/pages/caching.js +0 -1
- package/docs/src/pages/components/accordion.js +0 -1
- package/docs/src/pages/components/alert.js +0 -1
- package/docs/src/pages/components/app-badge.js +0 -1
- package/docs/src/pages/components/avatar.js +0 -1
- package/docs/src/pages/components/badge.js +0 -1
- package/docs/src/pages/components/banner.js +0 -1
- package/docs/src/pages/components/breadcrumbs.js +0 -1
- package/docs/src/pages/components/button.js +0 -1
- package/docs/src/pages/components/card.js +0 -1
- package/docs/src/pages/components/carousel.js +0 -1
- package/docs/src/pages/components/charts.js +0 -1
- package/docs/src/pages/components/checkbox.js +0 -1
- package/docs/src/pages/components/cluster.js +0 -1
- package/docs/src/pages/components/code-window.js +0 -1
- package/docs/src/pages/components/container.js +0 -1
- package/docs/src/pages/components/divider.js +0 -1
- package/docs/src/pages/components/empty.js +0 -1
- package/docs/src/pages/components/feature.js +0 -1
- package/docs/src/pages/components/fieldset.js +0 -1
- package/docs/src/pages/components/footer.js +0 -1
- package/docs/src/pages/components/grid.js +0 -1
- package/docs/src/pages/components/heading.js +0 -1
- package/docs/src/pages/components/icons.js +0 -1
- package/docs/src/pages/components/image.js +0 -1
- package/docs/src/pages/components/input.js +0 -1
- package/docs/src/pages/components/list.js +0 -1
- package/docs/src/pages/components/media.js +0 -1
- package/docs/src/pages/components/modal.js +0 -1
- package/docs/src/pages/components/nav.js +0 -1
- package/docs/src/pages/components/pricing.js +0 -1
- package/docs/src/pages/components/progress.js +0 -1
- package/docs/src/pages/components/prose.js +2 -3
- package/docs/src/pages/components/pullquote.js +0 -1
- package/docs/src/pages/components/radio.js +0 -1
- package/docs/src/pages/components/rating.js +0 -1
- package/docs/src/pages/components/search.js +0 -1
- package/docs/src/pages/components/section.js +0 -1
- package/docs/src/pages/components/segmented.js +0 -1
- package/docs/src/pages/components/select.js +0 -1
- package/docs/src/pages/components/spinner.js +0 -1
- package/docs/src/pages/components/stack.js +0 -1
- package/docs/src/pages/components/stat.js +0 -1
- package/docs/src/pages/components/stepper.js +0 -1
- package/docs/src/pages/components/table.js +0 -1
- package/docs/src/pages/components/testimonial.js +0 -1
- package/docs/src/pages/components/textarea.js +0 -1
- package/docs/src/pages/components/timeline.js +0 -1
- package/docs/src/pages/components/toggle.js +0 -1
- package/docs/src/pages/components/tooltip.js +0 -1
- package/docs/src/pages/components.js +0 -1
- package/docs/src/pages/config.js +0 -1
- package/docs/src/pages/deploy.js +0 -1
- package/docs/src/pages/extending.js +0 -1
- package/docs/src/pages/faq.js +0 -1
- package/docs/src/pages/getting-started.js +0 -1
- package/docs/src/pages/guard.js +0 -3
- package/docs/src/pages/how-it-works.js +0 -1
- package/docs/src/pages/images.js +0 -2
- package/docs/src/pages/markdown.js +155 -0
- package/docs/src/pages/meta.js +0 -2
- package/docs/src/pages/navigation.js +0 -1
- package/docs/src/pages/performance.js +0 -2
- package/docs/src/pages/project-structure.js +0 -2
- package/docs/src/pages/prompt-examples.js +0 -1
- package/docs/src/pages/raw-responses.js +0 -4
- package/docs/src/pages/routing.js +0 -2
- package/docs/src/pages/server-api.js +0 -2
- package/docs/src/pages/server-data.js +18 -1
- package/docs/src/pages/slash-commands.js +0 -1
- package/docs/src/pages/spec.js +2 -1
- package/docs/src/pages/streaming.js +0 -2
- package/docs/src/pages/testing.js +0 -1
- package/docs/src/pages/validation.js +0 -1
- package/package.json +5 -1
- package/public/.pulse-ui-version +1 -1
- package/public/pulse-ui.css +27 -0
- package/scripts/build.js +53 -2
- package/scripts/strip-server.test.js +74 -2
- package/src/agent/checklist.md +20 -0
- package/src/agent/guide-server.md +62 -0
- package/src/agent/guide-spec.md +5 -3
- package/src/agent/guide.md +105 -2
- package/src/cli/dev.js +4 -0
- package/src/cli/scaffold.js +0 -2
- package/src/cli/start.js +1 -0
- package/src/md/highlight.js +369 -0
- package/src/md/index.js +337 -0
- package/src/spec/schema.js +8 -1
- 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, '&')
|
|
25
|
+
.replace(/</g, '<')
|
|
26
|
+
.replace(/>/g, '>')
|
|
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 += '<'; 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 += '>'; 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
|
+
}
|
package/src/md/index.js
ADDED
|
@@ -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, '&')
|
|
23
|
+
.replace(/</g, '<')
|
|
24
|
+
.replace(/>/g, '>')
|
|
25
|
+
.replace(/"/g, '"')
|
|
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
|
+
}
|