@invisibleloop/pulse 0.5.1 → 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 CHANGED
@@ -448,6 +448,42 @@ Pulse views are plain JS template literals, not JSX. These React patterns are **
448
448
 
449
449
  Also: mutations must be pure (no fetch, no DOM access). Only `actions` can be async.
450
450
 
451
+ ## Markdown
452
+
453
+ Pulse has a built-in markdown parser. Use it for blog posts, docs pages, and any static content written in `.md` files. Import from `@invisibleloop/pulse/md`. All parsing is server-side — zero browser JS.
454
+
455
+ ```js
456
+ import { md } from '@invisibleloop/pulse/md'
457
+ import { prose } from '@invisibleloop/pulse/ui'
458
+
459
+ // Static path
460
+ const page = md('content/about.md')
461
+
462
+ // Dynamic route — :param resolved from ctx.params
463
+ const post = md('content/blog/:slug.md')
464
+
465
+ export default {
466
+ route: '/blog/:slug',
467
+ meta: {
468
+ title: async (ctx) => (await post(ctx)).frontmatter.title,
469
+ description: async (ctx) => (await post(ctx)).frontmatter.description,
470
+ },
471
+ server: { post },
472
+ view: (state, { post }) => `
473
+ <main id="main-content">
474
+ ${prose({ content: post.html })}
475
+ </main>
476
+ `,
477
+ onViewError: () => `<main id="main-content"><p>Post not found.</p></main>`,
478
+ }
479
+ ```
480
+
481
+ - `md(path)` returns an async fetcher — call it in both `meta` and `server`, the file is read only once per request
482
+ - Result is `{ html, frontmatter }` — `html` goes into `prose()`, `frontmatter` holds the `---` key/value pairs
483
+ - Missing file throws `{ status: 404 }` — always add `onViewError` on dynamic markdown routes
484
+ - For markdown from a database/API use `parseMd(source)` directly: `import { parseMd } from '@invisibleloop/pulse/md'`
485
+ - Use `new URL('./content/file.md', import.meta.url)` to resolve relative to the spec file rather than `process.cwd()`
486
+
451
487
  ## Check Components Before Building
452
488
 
453
489
  Before writing any UI HTML by hand, check `src/ui/index.js` — there are 50+ components available. Use them. Do not reinvent `button`, `card`, `alert`, `modal`, `spinner`, `badge`, `input`, etc.
@@ -4,11 +4,7 @@ Before finishing any spec, verify every point below. Fix anything that fails.
4
4
 
5
5
  ### Critical
6
6
 
7
- - **`hydrate` is set on every interactive page.** Without it, `data-event` / `data-action` bindings do nothing, `persist` never runs, and client-side navigation cannot re-mount the page. Every spec with `mutations`, `actions`, or `persist` must include:
8
- ```js
9
- hydrate: '/src/pages/my-page.js', // browser-importable path to this file
10
- ```
11
- Omit `hydrate` only for purely server-rendered pages with zero client interactivity.
7
+ - **Do not set `hydrate` in specs.** It is auto-derived by the framework from the URL entry passed to `createServer`. Specs with `mutations`, `actions`, or `persist` are hydrated automatically. Purely server-rendered specs get zero JavaScript — no configuration needed.
12
8
 
13
9
  ### Components first
14
10
 
@@ -81,6 +77,12 @@ Before finishing any spec, verify every point below. Fix anything that fails.
81
77
  ```
82
78
  Supported selectors: `tag`, `.class`, `#id`, `[attr]`, `[attr="value"]`, and combinations (`button.primary[disabled]`).
83
79
 
80
+ ### Markdown
81
+
82
+ - **Use `md()` from `@invisibleloop/pulse/md` for `.md` file content.** Never read and parse markdown manually. Pass the result's `html` to `prose()`.
83
+ - **Call the same `md()` fetcher in both `meta` and `server`** — it caches on `ctx._mdCache` so the file is only read once per request. Do not create two separate fetchers for the same file.
84
+ - **Always add `onViewError` on dynamic markdown routes** (routes with `:param` segments loading `.md` files). A missing file throws `{ status: 404 }` which must be caught gracefully.
85
+
84
86
  ### View error handling
85
87
 
86
88
  - **Define `onViewError` on any page where the view could throw due to bad or missing data.** Without it, a runtime view error returns a 500 on the server and shows a generic inline message on the client. With it, the server returns 200 with your fallback HTML instead of a 500, and the client renders your fallback:
@@ -1 +1 @@
1
- 0.2.14
1
+ 0.5.0
@@ -2806,6 +2806,26 @@ fieldset[disabled] .ui-rating-stars .ui-rating-star:hover ~ .ui-rating-star { co
2806
2806
  color: var(--ui-text);
2807
2807
  }
2808
2808
 
2809
+ /* ─── Syntax highlighting tokens ────────────────────────────────────────────
2810
+ Used by ui-prose pre code blocks rendered via @invisibleloop/pulse/md.
2811
+ Dark theme defaults; light theme overrides below. */
2812
+ :root {
2813
+ --tok-kw: #c792ea;
2814
+ --tok-str: #c3e88d;
2815
+ --tok-cmt: #7a8a9a;
2816
+ --tok-num: #f78c6c;
2817
+ --tok-fn: #82aaff;
2818
+ --tok-op: #89ddff;
2819
+ --tok-punct: #7e8899;
2820
+ }
2821
+ .tok-kw { color: var(--tok-kw); }
2822
+ .tok-str { color: var(--tok-str); }
2823
+ .tok-cmt { color: var(--tok-cmt); font-style: italic; }
2824
+ .tok-num { color: var(--tok-num); }
2825
+ .tok-fn { color: var(--tok-fn); }
2826
+ .tok-op { color: var(--tok-op); }
2827
+ .tok-punct { color: var(--tok-punct); }
2828
+
2809
2829
  /* ─── Light theme ────────────────────────────────────────────────────────────
2810
2830
  Apply to any ancestor element: <body data-theme="light"> or .ui-theme-light
2811
2831
  All --ui-* tokens are overridden; component markup stays identical. */
@@ -2829,4 +2849,11 @@ fieldset[disabled] .ui-rating-stars .ui-rating-star:hover ~ .ui-rating-star { co
2829
2849
  --ui-blue: #1e40af;
2830
2850
  --ui-blue-dim: rgba( 30, 64, 175, .12);
2831
2851
  --ui-nav-sticky-bg: rgba(255, 255, 255, .85);
2852
+ --tok-kw: #7c3aed;
2853
+ --tok-str: #15803d;
2854
+ --tok-cmt: #6b7280;
2855
+ --tok-num: #c2410c;
2856
+ --tok-fn: #1d4ed8;
2857
+ --tok-op: #0891b2;
2858
+ --tok-punct: #6b7280;
2832
2859
  }
package/docs/server.js CHANGED
@@ -33,6 +33,7 @@ await createServer(
33
33
  new URL('./src/pages/persist.js', import.meta.url),
34
34
  // Server
35
35
  new URL('./src/pages/server-data.js', import.meta.url),
36
+ new URL('./src/pages/markdown.js', import.meta.url),
36
37
  new URL('./src/pages/store.js', import.meta.url),
37
38
  new URL('./src/pages/routing.js', import.meta.url),
38
39
  new URL('./src/pages/streaming.js', import.meta.url),
@@ -1,299 +1,2 @@
1
- /**
2
- * Pulse Docs — Server-side syntax highlighter
3
- *
4
- * Character-by-character tokeniser. No dependencies, no client JS.
5
- * Returns an HTML string of <span> elements ready to drop inside <code>.
6
- */
7
-
8
- const KEYWORDS = new Set([
9
- 'export', 'default', 'import', 'from', 'as',
10
- 'async', 'await', 'const', 'let', 'var',
11
- 'return', 'if', 'else', 'for', 'while', 'of', 'in',
12
- 'true', 'false', 'null', 'undefined',
13
- 'function', 'class', 'new', 'this', 'typeof', 'instanceof',
14
- 'try', 'catch', 'finally', 'throw',
15
- ])
16
-
17
- function esc(s) {
18
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
19
- }
20
-
21
- function span(cls, text) {
22
- return `<span class="${cls}">${esc(text)}</span>`
23
- }
24
-
25
- export function highlight(code, lang = 'js') {
26
- if (lang === 'bash') return highlightBash(code)
27
- if (lang === 'html') return highlightHtml(code)
28
- return highlightJs(code)
29
- }
30
-
31
- // ---------------------------------------------------------------------------
32
- // JavaScript tokeniser
33
- // ---------------------------------------------------------------------------
34
-
35
- function highlightJs(code) {
36
- let out = ''
37
- let i = 0
38
- const n = code.length
39
-
40
- while (i < n) {
41
- const ch = code[i]
42
- const ch2 = code[i + 1]
43
-
44
- // Single-line comment
45
- if (ch === '/' && ch2 === '/') {
46
- const end = code.indexOf('\n', i)
47
- const tok = end === -1 ? code.slice(i) : code.slice(i, end)
48
- out += span('tok-cmt', tok)
49
- i += tok.length
50
- continue
51
- }
52
-
53
- // Block comment
54
- if (ch === '/' && ch2 === '*') {
55
- const end = code.indexOf('*/', i + 2)
56
- const tok = end === -1 ? code.slice(i) : code.slice(i, end + 2)
57
- out += span('tok-cmt', tok)
58
- i += tok.length
59
- continue
60
- }
61
-
62
- // Template literal
63
- if (ch === '`') {
64
- let tok = '`'
65
- i++
66
- let depth = 0
67
- while (i < n) {
68
- if (code[i] === '\\') {
69
- tok += code[i] + (code[i + 1] || '')
70
- i += 2
71
- continue
72
- }
73
- if (code[i] === '$' && code[i + 1] === '{') {
74
- tok += '${'
75
- i += 2
76
- depth++
77
- continue
78
- }
79
- if (code[i] === '}' && depth > 0) {
80
- tok += '}'
81
- i++
82
- depth--
83
- continue
84
- }
85
- if (code[i] === '`' && depth === 0) {
86
- tok += '`'
87
- i++
88
- break
89
- }
90
- tok += code[i++]
91
- }
92
- out += span('tok-str', tok)
93
- continue
94
- }
95
-
96
- // Single or double quoted string
97
- if (ch === '"' || ch === "'") {
98
- let tok = ch
99
- i++
100
- while (i < n && code[i] !== ch && code[i] !== '\n') {
101
- if (code[i] === '\\') {
102
- tok += code[i] + (code[i + 1] || '')
103
- i += 2
104
- continue
105
- }
106
- tok += code[i++]
107
- }
108
- if (i < n && code[i] === ch) tok += code[i++]
109
- out += span('tok-str', tok)
110
- continue
111
- }
112
-
113
- // Number
114
- if (/\d/.test(ch) && (i === 0 || !/\w/.test(code[i - 1]))) {
115
- let tok = ''
116
- while (i < n && /[\d.xXa-fA-F]/.test(code[i])) tok += code[i++]
117
- out += span('tok-num', tok)
118
- continue
119
- }
120
-
121
- // Identifier / keyword
122
- if (/[a-zA-Z_$]/.test(ch)) {
123
- let tok = ''
124
- while (i < n && /[\w$]/.test(code[i])) tok += code[i++]
125
-
126
- if (KEYWORDS.has(tok)) {
127
- out += span('tok-kw', tok)
128
- } else {
129
- // Look ahead: followed by ( → function call
130
- let j = i
131
- while (j < n && code[j] === ' ') j++
132
- if (code[j] === '(') {
133
- out += span('tok-fn', tok)
134
- } else {
135
- out += esc(tok)
136
- }
137
- }
138
- continue
139
- }
140
-
141
- // Operators
142
- if (/[=!<>|&?.]/.test(ch)) {
143
- let tok = ch
144
- // Grab two-char operators
145
- if (/[=!<>|&?]/.test(ch) && /[=|&>?]/.test(ch2)) {
146
- tok = code.slice(i, i + 2)
147
- // Three-char: ===, !==, ...
148
- if (code[i + 2] === '=') tok = code.slice(i, i + 3)
149
- }
150
- out += span('tok-op', tok)
151
- i += tok.length
152
- continue
153
- }
154
-
155
- // Punctuation
156
- if (/[{}()[\],;:]/.test(ch)) {
157
- out += span('tok-punct', ch)
158
- i++
159
- continue
160
- }
161
-
162
- // Everything else (whitespace, newlines, etc.)
163
- out += esc(ch)
164
- i++
165
- }
166
-
167
- return out
168
- }
169
-
170
- // ---------------------------------------------------------------------------
171
- // Bash tokeniser (minimal)
172
- // ---------------------------------------------------------------------------
173
-
174
- function highlightBash(code) {
175
- return code.split('\n').map(line => {
176
- // Comment line
177
- if (/^\s*#/.test(line)) return span('tok-cmt', line)
178
-
179
- let out = ''
180
- let i = 0
181
- while (i < line.length) {
182
- // Inline comment
183
- if (line[i] === '#') {
184
- out += span('tok-cmt', line.slice(i))
185
- break
186
- }
187
- // String
188
- if (line[i] === '"' || line[i] === "'") {
189
- const q = line[i]
190
- let tok = q
191
- i++
192
- while (i < line.length && line[i] !== q) tok += line[i++]
193
- tok += line[i] === q ? line[i++] : ''
194
- out += span('tok-str', tok)
195
- continue
196
- }
197
- // Flag/option
198
- if (line[i] === '-' && /\w/.test(line[i + 1] || '')) {
199
- let tok = '-'
200
- i++
201
- while (i < line.length && /[-\w]/.test(line[i])) tok += line[i++]
202
- out += span('tok-op', tok)
203
- continue
204
- }
205
- out += esc(line[i++])
206
- }
207
- return out
208
- }).join('\n')
209
- }
210
-
211
- // ---------------------------------------------------------------------------
212
- // HTML tokeniser (minimal)
213
- // ---------------------------------------------------------------------------
214
-
215
- function highlightHtmlTag(tag) {
216
- // Single-pass tag highlighter — avoids the chained .replace() trap where a
217
- // later pass re-processes class="tok-fn" strings already injected by an
218
- // earlier pass, producing broken HTML like <span class=<span …>"tok-fn"</span>>.
219
- let out = ''
220
- let i = 0
221
-
222
- // Opening < or </
223
- out += '&lt;'
224
- i++ // skip <
225
- if (tag[i] === '/') { out += '/'; i++ }
226
-
227
- // Tag name
228
- let name = ''
229
- while (i < tag.length && /[\w-]/.test(tag[i])) name += tag[i++]
230
- if (name) out += span('tok-kw', name)
231
-
232
- // Attributes and remainder up to >
233
- while (i < tag.length) {
234
- const ch = tag[i]
235
- if (ch === '>') { out += '&gt;'; i++; break }
236
- if (ch === '/') { out += '/'; i++; continue }
237
-
238
- if (/[\w-]/.test(ch)) {
239
- // Attribute name
240
- let attr = ''
241
- while (i < tag.length && /[\w-]/.test(tag[i])) attr += tag[i++]
242
-
243
- if (tag[i] === '=') {
244
- out += span('tok-fn', attr) + esc('=')
245
- i++ // skip =
246
- // Attribute value
247
- const q = tag[i]
248
- if (q === '"' || q === "'") {
249
- let val = q
250
- i++
251
- while (i < tag.length && tag[i] !== q) {
252
- if (tag[i] === '\\') { val += tag[i] + (tag[i + 1] || ''); i += 2; continue }
253
- val += tag[i++]
254
- }
255
- if (i < tag.length) val += tag[i++] // closing quote
256
- out += span('tok-str', val)
257
- }
258
- } else {
259
- out += esc(attr)
260
- }
261
- continue
262
- }
263
-
264
- out += esc(ch)
265
- i++
266
- }
267
-
268
- return out
269
- }
270
-
271
- function highlightHtml(code) {
272
- let out = ''
273
- let i = 0
274
- const n = code.length
275
-
276
- while (i < n) {
277
- // Comment
278
- if (code.slice(i, i + 4) === '<!--') {
279
- const end = code.indexOf('-->', i + 4)
280
- const tok = end === -1 ? code.slice(i) : code.slice(i, end + 3)
281
- out += span('tok-cmt', tok)
282
- i += tok.length
283
- continue
284
- }
285
-
286
- // Tag
287
- if (code[i] === '<') {
288
- const end = code.indexOf('>', i)
289
- if (end === -1) { out += esc(code[i++]); continue }
290
- out += highlightHtmlTag(code.slice(i, end + 1))
291
- i = end + 1
292
- continue
293
- }
294
-
295
- out += esc(code[i++])
296
- }
297
-
298
- return out
299
- }
1
+ // Re-export from the framework's canonical highlight implementation.
2
+ export { highlight } from '../../../src/md/highlight.js'
@@ -38,6 +38,7 @@ export const NAV = [
38
38
  section: 'Server',
39
39
  items: [
40
40
  { label: 'Server Data', href: '/server-data' },
41
+ { label: 'Markdown', href: '/markdown' },
41
42
  { label: 'Global Store', href: '/store' },
42
43
  { label: 'Routing', href: '/routing' },
43
44
  { label: 'Streaming SSR', href: '/streaming' },
@@ -42,8 +42,8 @@ export default {
42
42
  // CMS rich text field — output directly, fully styled
43
43
  prose({ content: server.article.bodyHtml })
44
44
 
45
- // Markdown rendered to HTML
46
- prose({ content: renderMarkdown(server.post.body) })`,
45
+ // Markdown file via md() helper
46
+ prose({ content: server.post.html }) // see /markdown`,
47
47
  { col: true }
48
48
  )}
49
49
 
@@ -0,0 +1,155 @@
1
+ import { renderLayout, h1, lead, section, codeBlock, callout, table } from '../lib/layout.js'
2
+ import { prevNext } from '../lib/nav.js'
3
+ import { highlight } from '../lib/highlight.js'
4
+
5
+ const { prev, next } = prevNext('/markdown')
6
+
7
+ export default {
8
+ route: '/markdown',
9
+ meta: {
10
+ title: 'Markdown — Pulse Docs',
11
+ description: 'Render markdown files as pages with syntax-highlighted code blocks, frontmatter, and the prose component.',
12
+ styles: ['/docs.css'],
13
+ },
14
+ view: () => renderLayout({
15
+ currentHref: '/markdown',
16
+ prev,
17
+ next,
18
+ content: `
19
+ ${h1('Markdown')}
20
+ ${lead('Pulse includes a built-in markdown parser. Write content in <code>.md</code> files, load them with the <code>md()</code> helper, and render with the <code>prose</code> component. No external dependencies — zero browser bytes.')}
21
+
22
+ ${section('basic', 'Basic usage')}
23
+ <p>Import <code>md</code> from <code>@invisibleloop/pulse/md</code> and call it with a file path. It returns an async server fetcher — use it in <code>server</code> and optionally in <code>meta</code>:</p>
24
+ ${codeBlock(highlight(`import { md } from '@invisibleloop/pulse/md'
25
+ import { prose } from '@invisibleloop/pulse/ui'
26
+
27
+ const page = md('content/about.md')
28
+
29
+ export default {
30
+ route: '/about',
31
+ server: { page },
32
+ view: (state, { page }) => prose({ content: page.html }),
33
+ }`, 'js'))}
34
+ <p>The fetcher returns <code>{ html, frontmatter }</code>. The <code>html</code> is ready to pass directly to <code>prose()</code>. The <code>frontmatter</code> object contains the parsed key/value pairs from the <code>---</code> block at the top of the file.</p>
35
+
36
+ ${section('frontmatter', 'Frontmatter')}
37
+ <p>Add a <code>---</code> block at the top of any <code>.md</code> file to define metadata. Use the values in <code>meta</code> for SEO:</p>
38
+ ${codeBlock(highlight(`---
39
+ title: About Us
40
+ description: Learn about our company
41
+ date: 2024-01-15
42
+ ---
43
+
44
+ # About Us
45
+
46
+ Welcome to our company…`, 'bash'))}
47
+ ${codeBlock(highlight(`const page = md('content/about.md')
48
+
49
+ export default {
50
+ route: '/about',
51
+ meta: {
52
+ title: async (ctx) => (await page(ctx)).frontmatter.title,
53
+ description: async (ctx) => (await page(ctx)).frontmatter.description,
54
+ },
55
+ server: { page },
56
+ view: (state, { page }) => prose({ content: page.html }),
57
+ }`, 'js'))}
58
+ ${callout('tip', 'Calling <code>page(ctx)</code> in both <code>meta</code> and <code>server</code> reads the file only once per request — the result is cached on <code>ctx._mdCache</code> automatically.')}
59
+
60
+ ${section('dynamic', 'Dynamic routes')}
61
+ <p>Use <code>:param</code> placeholders in the path — they are resolved from <code>ctx.params</code> at request time:</p>
62
+ ${codeBlock(highlight(`import { md } from '@invisibleloop/pulse/md'
63
+ import { prose } from '@invisibleloop/pulse/ui'
64
+
65
+ const post = md('content/blog/:slug.md')
66
+
67
+ export default {
68
+ route: '/blog/:slug',
69
+ meta: {
70
+ title: async (ctx) => (await post(ctx)).frontmatter.title,
71
+ description: async (ctx) => (await post(ctx)).frontmatter.description,
72
+ },
73
+ server: { post },
74
+ view: (state, { post }) => \`
75
+ <main id="main-content">
76
+ \${prose({ content: post.html })}
77
+ </main>
78
+ \`,
79
+ onViewError: (err) => \`
80
+ <main id="main-content">
81
+ <p>Post not found.</p>
82
+ </main>
83
+ \`,
84
+ }`, 'js'))}
85
+ ${callout('note', 'If the file does not exist, the fetcher throws with <code>{ status: 404 }</code>. Define <code>onViewError</code> on dynamic-route pages to render a friendly not-found message instead of a 500.')}
86
+
87
+ ${section('parseMd', 'parseMd()')}
88
+ <p>For cases where you already have a markdown string (from a database, API, or generated content), use <code>parseMd</code> directly instead of the file helper:</p>
89
+ ${codeBlock(highlight(`import { parseMd } from '@invisibleloop/pulse/md'
90
+ import { prose } from '@invisibleloop/pulse/ui'
91
+
92
+ export default {
93
+ route: '/post/:id',
94
+ server: {
95
+ post: async (ctx) => {
96
+ const record = await db.posts.find(ctx.params.id)
97
+ const { html, frontmatter } = parseMd(record.body)
98
+ return { html, title: frontmatter.title ?? record.title }
99
+ }
100
+ },
101
+ view: (state, { post }) => prose({ content: post.html }),
102
+ }`, 'js'))}
103
+
104
+ ${section('what-renders', 'What the parser renders')}
105
+ ${table(
106
+ ['Markdown', 'Output'],
107
+ [
108
+ ['<code># Heading</code> through <code>###### Heading</code>', '<code>&lt;h1&gt;</code>–<code>&lt;h6&gt;</code> with auto <code>id</code> anchors'],
109
+ ['<code>**bold**</code>, <code>*italic*</code>, <code>~~strike~~</code>', '<code>&lt;strong&gt;</code>, <code>&lt;em&gt;</code>, <code>&lt;del&gt;</code>'],
110
+ ['<code>`inline code`</code>', '<code>&lt;code&gt;</code>'],
111
+ ['Fenced code block with language tag', '<code>&lt;pre&gt;&lt;code class="language-X"&gt;</code> with syntax highlighting'],
112
+ ['<code>[text](url)</code>, <code>![alt](url)</code>', '<code>&lt;a&gt;</code>, <code>&lt;img&gt;</code>'],
113
+ ['<code>- item</code>, <code>1. item</code>', '<code>&lt;ul&gt;</code>, <code>&lt;ol&gt;</code> (nested lists supported)'],
114
+ ['<code>&gt; quote</code>', '<code>&lt;blockquote&gt;</code>'],
115
+ ['GFM table (<code>| col | col |</code>)', '<code>&lt;table&gt;</code> with <code>&lt;thead&gt;</code> and <code>&lt;tbody&gt;</code>'],
116
+ ['<code>---</code> (three or more)', '<code>&lt;hr&gt;</code>'],
117
+ ]
118
+ )}
119
+
120
+ ${section('syntax-highlighting', 'Syntax highlighting')}
121
+ <p>Fenced code blocks with a language tag are syntax-highlighted automatically — no client JS required. The highlighting runs on the server and emits <code>&lt;span class="tok-*"&gt;</code> elements. The colour tokens are defined in <code>pulse-ui.css</code> and respect the active theme.</p>
122
+ ${codeBlock(highlight(`\`\`\`js
123
+ const greeting = 'Hello, world'
124
+ console.log(greeting)
125
+ \`\`\``, 'bash'))}
126
+ <p>Supported languages:</p>
127
+ ${table(
128
+ ['Tag(s)', 'Highlighter'],
129
+ [
130
+ ['<code>js</code>, <code>javascript</code>, <code>ts</code>, <code>typescript</code>, <code>jsx</code>, <code>tsx</code>', 'JavaScript / TypeScript'],
131
+ ['<code>html</code>, <code>xml</code>, <code>svg</code>, <code>vue</code>, <code>svelte</code>', 'HTML / XML'],
132
+ ['<code>css</code>, <code>scss</code>, <code>less</code>', 'CSS'],
133
+ ['<code>bash</code>, <code>sh</code>, <code>shell</code>, <code>zsh</code>', 'Bash / Shell'],
134
+ ['<code>json</code>, <code>jsonc</code>', 'JSON'],
135
+ ['Any other tag', 'Plain text (escaped, no colour tokens)'],
136
+ ]
137
+ )}
138
+
139
+ ${section('prose', 'The prose component')}
140
+ <p>The <code>prose</code> component is designed specifically for markdown output. It applies typographic styles to all descendant elements — headings, paragraphs, lists, blockquotes, code blocks, tables, images — without requiring any classes on the elements themselves:</p>
141
+ ${codeBlock(highlight(`import { prose } from '@invisibleloop/pulse/ui'
142
+
143
+ // Wrap the html from parseMd or md()
144
+ prose({ content: page.html })
145
+
146
+ // Optional size modifier
147
+ prose({ content: page.html, size: 'lg' }) // 'sm' | 'base' | 'lg'`, 'js'))}
148
+ ${callout('note', 'The <code>content</code> slot is injected as raw HTML — only pass server-side content that you trust. Never pass user-submitted HTML directly; sanitise it first.')}
149
+
150
+ ${section('url-paths', 'Using URL paths')}
151
+ <p>Pass a <code>URL</code> object to resolve paths relative to the spec file rather than the process working directory:</p>
152
+ ${codeBlock(highlight(`const page = md(new URL('./content/about.md', import.meta.url))`, 'js'))}
153
+ `,
154
+ }),
155
+ }
@@ -179,6 +179,24 @@ view: (state, server) => server.notFound
179
179
  featured: async () => fetch('https://api.example.com/featured').then(r => r.json()),
180
180
  },
181
181
  }`, 'js'))}
182
+
183
+ ${section('markdown', 'Markdown files')}
184
+ <p>Use the built-in <code>md()</code> helper to load and parse <code>.md</code> files as server data. The result (<code>{ html, frontmatter }</code>) is ready to pass to the <code>prose</code> component — no external dependencies, no client JS. See <a href="/markdown">Markdown</a> for the full reference.</p>
185
+ ${codeBlock(highlight(`import { md } from '@invisibleloop/pulse/md'
186
+ import { prose } from '@invisibleloop/pulse/ui'
187
+
188
+ const post = md('content/blog/:slug.md')
189
+
190
+ export default {
191
+ route: '/blog/:slug',
192
+ meta: {
193
+ title: async (ctx) => (await post(ctx)).frontmatter.title,
194
+ description: async (ctx) => (await post(ctx)).frontmatter.description,
195
+ },
196
+ server: { post },
197
+ view: (state, { post }) => prose({ content: post.html }),
198
+ onViewError: () => \`<p>Post not found.</p>\`,
199
+ }`, 'js'))}
182
200
  `,
183
201
  }),
184
202
  }
@@ -34,7 +34,7 @@ export default {
34
34
  ['<code>validation</code>', '<code>object</code>', 'No', 'Declarative validation rules keyed by dot-path state keys.'],
35
35
  ['<code>constraints</code>', '<code>object</code>', 'No', 'Min/max bounds enforced after every mutation.'],
36
36
  ['<code>persist</code>', '<code>string[]</code>', 'No', 'State keys to save in <code>localStorage</code>.'],
37
- ['<code>server</code>', '<code>object</code>', 'No', 'Server-side data fetcher. Result passed to <code>view</code> as second arg.'],
37
+ ['<code>server</code>', '<code>object</code>', 'No', 'Server-side data fetchers. Result passed to <code>view</code> as second arg. Use the <a href="/markdown"><code>md()</code> helper</a> to load <code>.md</code> files.'],
38
38
  ['<code>guard</code>', '<code>function</code>', 'No', 'Async pre-render hook. Return <code>{ redirect }</code> or <code>{ status, json }</code> to short-circuit. See <a href="/guard">Guard</a>.'],
39
39
  ['<code>store</code>', '<code>string[]</code>', 'No', 'Global store keys this page subscribes to. See <a href="/store">Global Store</a>.'],
40
40
  ['<code>methods</code>', '<code>string[]</code>', 'No', 'HTTP methods this page accepts. Default <code>[\'GET\', \'HEAD\']</code>. Add <code>\'POST\'</code> etc. to opt in.'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -48,6 +48,10 @@
48
48
  "./testing": {
49
49
  "types": "./types/testing.d.ts",
50
50
  "default": "./src/testing/index.js"
51
+ },
52
+ "./md": {
53
+ "types": "./types/md.d.ts",
54
+ "default": "./src/md/index.js"
51
55
  }
52
56
  },
53
57
  "scripts": {
@@ -1 +1 @@
1
- 0.5.1
1
+ 0.6.0