@invisibleloop/pulse 0.5.1 → 0.6.1
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/components/prose.js +2 -2
- package/docs/src/pages/markdown.js +155 -0
- package/docs/src/pages/server-data.js +18 -0
- package/docs/src/pages/spec.js +1 -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/runtime/ssr.js +4 -3
- package/src/runtime/ssr.test.js +5 -2
- package/src/spec/schema.js +3 -1
- package/types/md.d.ts +66 -0
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
|
-
-
|
|
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.
|
|
1
|
+
0.5.0
|
package/docs/public/pulse-ui.css
CHANGED
|
@@ -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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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 += '<'
|
|
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 += '>'; 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'
|
package/docs/src/lib/nav.js
CHANGED
|
@@ -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
|
|
46
|
-
prose({ content:
|
|
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><h1></code>–<code><h6></code> with auto <code>id</code> anchors'],
|
|
109
|
+
['<code>**bold**</code>, <code>*italic*</code>, <code>~~strike~~</code>', '<code><strong></code>, <code><em></code>, <code><del></code>'],
|
|
110
|
+
['<code>`inline code`</code>', '<code><code></code>'],
|
|
111
|
+
['Fenced code block with language tag', '<code><pre><code class="language-X"></code> with syntax highlighting'],
|
|
112
|
+
['<code>[text](url)</code>, <code></code>', '<code><a></code>, <code><img></code>'],
|
|
113
|
+
['<code>- item</code>, <code>1. item</code>', '<code><ul></code>, <code><ol></code> (nested lists supported)'],
|
|
114
|
+
['<code>> quote</code>', '<code><blockquote></code>'],
|
|
115
|
+
['GFM table (<code>| col | col |</code>)', '<code><table></code> with <code><thead></code> and <code><tbody></code>'],
|
|
116
|
+
['<code>---</code> (three or more)', '<code><hr></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><span class="tok-*"></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
|
}
|
package/docs/src/pages/spec.js
CHANGED
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "0.6.1",
|
|
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": {
|
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.6.1
|