@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 +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/spec/schema.js +3 -1
- package/types/md.d.ts +66 -0
package/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/scripts/build.js
CHANGED
|
@@ -32,7 +32,14 @@ import { renderToString } from '../src/runtime/ssr.js'
|
|
|
32
32
|
* from being shipped to and evaluated in the browser
|
|
33
33
|
* - avoids bundling errors when server imports use Node-only modules
|
|
34
34
|
*/
|
|
35
|
-
const SERVER_ONLY_KEYS = ['server', 'guard', 'serverTimeout', 'contentType', 'render']
|
|
35
|
+
const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render']
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pulse subpath exports that are server-only (use Node built-ins).
|
|
39
|
+
* Imports from these paths are removed from client bundles along with any
|
|
40
|
+
* top-level variable declarations whose right-hand side calls the imported names.
|
|
41
|
+
*/
|
|
42
|
+
const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md']
|
|
36
43
|
|
|
37
44
|
/**
|
|
38
45
|
* Strip all server-only property declarations from a spec source string.
|
|
@@ -46,6 +53,50 @@ function stripServerOnlyKeys(source) {
|
|
|
46
53
|
return source
|
|
47
54
|
}
|
|
48
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Strip import statements for known server-only modules and any top-level
|
|
58
|
+
* variable declarations that called those imported functions.
|
|
59
|
+
*
|
|
60
|
+
* Handles patterns like:
|
|
61
|
+
* import { md } from '@invisibleloop/pulse/md'
|
|
62
|
+
* const fetchPost = md('src/content/blog/:slug.md')
|
|
63
|
+
*/
|
|
64
|
+
function stripServerOnlyImports(source) {
|
|
65
|
+
for (const mod of SERVER_ONLY_IMPORTS) {
|
|
66
|
+
const escaped = mod.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
67
|
+
const importRe = new RegExp(
|
|
68
|
+
`^[ \\t]*import\\s+(?:\\{[^}]*\\}|[\\w*]+(?:\\s+as\\s+\\w+)?)\\s+from\\s+['"]${escaped}['"]\\s*;?[ \\t]*\\n?`,
|
|
69
|
+
'gm'
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
// Collect imported binding names before removing the statement
|
|
73
|
+
const importedNames = new Set()
|
|
74
|
+
for (const m of source.matchAll(importRe)) {
|
|
75
|
+
const named = m[0].match(/\{\s*([^}]*)\s*\}/)
|
|
76
|
+
if (named) {
|
|
77
|
+
for (const part of named[1].split(',')) {
|
|
78
|
+
const name = part.trim().split(/\s+as\s+/).pop().trim()
|
|
79
|
+
if (name) importedNames.add(name)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const def = m[0].match(/import\s+([\w]+)\s+from/)
|
|
83
|
+
if (def) importedNames.add(def[1])
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
source = source.replace(importRe, '')
|
|
87
|
+
|
|
88
|
+
// Remove top-level: const/let/var X = importedFn(...)
|
|
89
|
+
for (const name of importedNames) {
|
|
90
|
+
const declRe = new RegExp(
|
|
91
|
+
`^[ \\t]*(?:const|let|var)\\s+\\w+\\s*=\\s*${name}\\s*\\([^\\n]*\\)\\s*;?[ \\t]*\\n?`,
|
|
92
|
+
'gm'
|
|
93
|
+
)
|
|
94
|
+
source = source.replace(declRe, '')
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return source
|
|
98
|
+
}
|
|
99
|
+
|
|
49
100
|
/**
|
|
50
101
|
* Remove a single named property (key + value + optional trailing comma/newline)
|
|
51
102
|
* from a JS source string. Uses a character-level scanner to correctly handle
|
|
@@ -265,7 +316,7 @@ function createStripServerPlugin(pagesDir) {
|
|
|
265
316
|
args.path !== pagesDir.replace(/\/$/, '') + '.js') return undefined
|
|
266
317
|
|
|
267
318
|
const source = fs.readFileSync(args.path, 'utf8')
|
|
268
|
-
const stripped = stripServerOnlyKeys(source)
|
|
319
|
+
const stripped = stripServerOnlyImports(stripServerOnlyKeys(source))
|
|
269
320
|
return { contents: stripped, loader: 'js' }
|
|
270
321
|
})
|
|
271
322
|
}
|
|
@@ -7,13 +7,49 @@
|
|
|
7
7
|
// Inline the scanner functions (duplicated here to keep test self-contained)
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
|
|
10
|
-
const SERVER_ONLY_KEYS = ['server', 'guard', 'serverTimeout', 'contentType', 'render']
|
|
10
|
+
const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render']
|
|
11
|
+
const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md']
|
|
11
12
|
|
|
12
13
|
function stripServerOnlyKeys(source) {
|
|
13
14
|
for (const key of SERVER_ONLY_KEYS) source = removeObjectKey(source, key)
|
|
14
15
|
return source
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
function stripServerOnlyImports(source) {
|
|
19
|
+
for (const mod of SERVER_ONLY_IMPORTS) {
|
|
20
|
+
const escaped = mod.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
21
|
+
const importRe = new RegExp(
|
|
22
|
+
`^[ \\t]*import\\s+(?:\\{[^}]*\\}|[\\w*]+(?:\\s+as\\s+\\w+)?)\\s+from\\s+['"]${escaped}['"]\\s*;?[ \\t]*\\n?`,
|
|
23
|
+
'gm'
|
|
24
|
+
)
|
|
25
|
+
const importedNames = new Set()
|
|
26
|
+
for (const m of source.matchAll(importRe)) {
|
|
27
|
+
const named = m[0].match(/\{\s*([^}]*)\s*\}/)
|
|
28
|
+
if (named) {
|
|
29
|
+
for (const part of named[1].split(',')) {
|
|
30
|
+
const name = part.trim().split(/\s+as\s+/).pop().trim()
|
|
31
|
+
if (name) importedNames.add(name)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const def = m[0].match(/import\s+([\w]+)\s+from/)
|
|
35
|
+
if (def) importedNames.add(def[1])
|
|
36
|
+
}
|
|
37
|
+
source = source.replace(importRe, '')
|
|
38
|
+
for (const name of importedNames) {
|
|
39
|
+
const declRe = new RegExp(
|
|
40
|
+
`^[ \\t]*(?:const|let|var)\\s+\\w+\\s*=\\s*${name}\\s*\\([^\\n]*\\)\\s*;?[ \\t]*\\n?`,
|
|
41
|
+
'gm'
|
|
42
|
+
)
|
|
43
|
+
source = source.replace(declRe, '')
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return source
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function strip(source) {
|
|
50
|
+
return stripServerOnlyImports(stripServerOnlyKeys(source))
|
|
51
|
+
}
|
|
52
|
+
|
|
17
53
|
function isInsideString(source, pos) {
|
|
18
54
|
let i = 0
|
|
19
55
|
const stack = []
|
|
@@ -124,7 +160,7 @@ function scanTemplateLiteral(src, pos) {
|
|
|
124
160
|
let pass = 0, fail = 0
|
|
125
161
|
|
|
126
162
|
function test(label, input, expected) {
|
|
127
|
-
const got =
|
|
163
|
+
const got = strip(input).trim()
|
|
128
164
|
const exp = expected.trim()
|
|
129
165
|
if (got === exp) {
|
|
130
166
|
console.log(' \u2713 ' + label)
|
|
@@ -221,6 +257,42 @@ test('server: inside a double-quoted string is not stripped',
|
|
|
221
257
|
'export default {\n route: \'/docs\',\n view: () => "<pre>server: { data: async () => {} }</pre>"\n}'
|
|
222
258
|
)
|
|
223
259
|
|
|
260
|
+
test('meta object is stripped (client runtime never reads spec.meta)',
|
|
261
|
+
`export default {\n route: '/blog',\n meta: {\n title: async (ctx) => fetchPost(ctx).frontmatter.title,\n styles: ['/pulse-ui.css'],\n },\n view: () => ''\n}`,
|
|
262
|
+
`export default {\n route: '/blog',\n view: () => ''\n}`
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
test('meta: inside a template literal string is not stripped',
|
|
266
|
+
'export default {\n view: () => `<pre>meta: { title: \'Home\' }</pre>`\n}',
|
|
267
|
+
'export default {\n view: () => `<pre>meta: { title: \'Home\' }</pre>`\n}'
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Server-only import stripping
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
console.log('\nServer-only import stripping\n')
|
|
275
|
+
|
|
276
|
+
test('strips @invisibleloop/pulse/md import and const declaration',
|
|
277
|
+
`import { md } from '@invisibleloop/pulse/md'\nconst fetchPost = md('src/content/blog.md')\nexport default {\n route: '/blog',\n view: () => ''\n}`,
|
|
278
|
+
`export default {\n route: '/blog',\n view: () => ''\n}`
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
test('strips named import with multiple bindings',
|
|
282
|
+
`import { md, parseMd } from '@invisibleloop/pulse/md'\nconst post = md('src/content/post.md')\nconst result = parseMd('# Hello')\nexport default {\n view: () => ''\n}`,
|
|
283
|
+
`export default {\n view: () => ''\n}`
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
test('strips import + const then strips meta that used the const',
|
|
287
|
+
`import { md } from '@invisibleloop/pulse/md'\nconst fetchPost = md('src/content/trees.md')\nexport default {\n route: '/blog',\n meta: {\n title: async (ctx) => (await fetchPost(ctx)).frontmatter.title,\n styles: ['/pulse-ui.css'],\n },\n server: { post: fetchPost },\n view: () => ''\n}`,
|
|
288
|
+
`export default {\n route: '/blog',\n view: () => ''\n}`
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
test('does not strip non-server imports',
|
|
292
|
+
`import { nav, footer } from '@invisibleloop/pulse/ui'\nexport default {\n view: () => nav({})\n}`,
|
|
293
|
+
`import { nav, footer } from '@invisibleloop/pulse/ui'\nexport default {\n view: () => nav({})\n}`
|
|
294
|
+
)
|
|
295
|
+
|
|
224
296
|
// ---------------------------------------------------------------------------
|
|
225
297
|
|
|
226
298
|
console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`)
|
package/src/agent/checklist.md
CHANGED
|
@@ -4,6 +4,20 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
4
4
|
|
|
5
5
|
### Critical
|
|
6
6
|
|
|
7
|
+
- **`meta` must be a plain object — never a function.** Individual fields (`title`, `description`, `styles`) can be `async (ctx) => value` functions, but `meta` itself is always `{}`.
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
// WRONG — meta is not a function factory
|
|
11
|
+
meta: async (ctx) => ({ title: '...', styles: [...] })
|
|
12
|
+
|
|
13
|
+
// CORRECT — meta is a plain object; individual fields are functions
|
|
14
|
+
meta: {
|
|
15
|
+
title: async (ctx) => (await post(ctx)).frontmatter.title,
|
|
16
|
+
description: async (ctx) => (await post(ctx)).frontmatter.description,
|
|
17
|
+
styles: ['/pulse-ui.css', '/app.css'],
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
7
21
|
- **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.
|
|
8
22
|
|
|
9
23
|
### Components first
|
|
@@ -77,6 +91,12 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
77
91
|
```
|
|
78
92
|
Supported selectors: `tag`, `.class`, `#id`, `[attr]`, `[attr="value"]`, and combinations (`button.primary[disabled]`).
|
|
79
93
|
|
|
94
|
+
### Markdown
|
|
95
|
+
|
|
96
|
+
- **Use `md()` from `@invisibleloop/pulse/md` for `.md` file content.** Never read and parse markdown manually. Pass the result's `html` to `prose()`.
|
|
97
|
+
- **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.
|
|
98
|
+
- **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.
|
|
99
|
+
|
|
80
100
|
### View error handling
|
|
81
101
|
|
|
82
102
|
- **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:
|
|
@@ -256,3 +256,65 @@ shutdown()
|
|
|
256
256
|
```
|
|
257
257
|
|
|
258
258
|
`shutdown()` is idempotent — calling it multiple times is safe.
|
|
259
|
+
|
|
260
|
+
## Markdown
|
|
261
|
+
|
|
262
|
+
Pulse has a built-in markdown parser. Use it for any content written in `.md` files — blog posts, documentation, static pages. All parsing happens server-side. Zero browser JS.
|
|
263
|
+
|
|
264
|
+
### `md(pathPattern)` — file helper
|
|
265
|
+
|
|
266
|
+
```js
|
|
267
|
+
import { md } from '@invisibleloop/pulse/md'
|
|
268
|
+
import { prose } from '@invisibleloop/pulse/ui'
|
|
269
|
+
|
|
270
|
+
const page = md('content/about.md')
|
|
271
|
+
|
|
272
|
+
export default {
|
|
273
|
+
route: '/about',
|
|
274
|
+
server: { page },
|
|
275
|
+
view: (state, { page }) => prose({ content: page.html }),
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Returns `{ html, frontmatter }`. `html` goes into `prose()`. `frontmatter` is the parsed `---` block.
|
|
280
|
+
|
|
281
|
+
### Frontmatter for meta tags
|
|
282
|
+
|
|
283
|
+
The same fetcher can be called in both `meta` and `server` — the file is only read once per request (cached on `ctx._mdCache`):
|
|
284
|
+
|
|
285
|
+
```js
|
|
286
|
+
const post = md('content/blog/:slug.md')
|
|
287
|
+
|
|
288
|
+
export default {
|
|
289
|
+
route: '/blog/:slug',
|
|
290
|
+
meta: {
|
|
291
|
+
title: async (ctx) => (await post(ctx)).frontmatter.title,
|
|
292
|
+
description: async (ctx) => (await post(ctx)).frontmatter.description,
|
|
293
|
+
},
|
|
294
|
+
server: { post },
|
|
295
|
+
view: (state, { post }) => `
|
|
296
|
+
<main id="main-content">
|
|
297
|
+
${prose({ content: post.html })}
|
|
298
|
+
</main>
|
|
299
|
+
`,
|
|
300
|
+
onViewError: () => `<main id="main-content"><p>Post not found.</p></main>`,
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Always add `onViewError` on dynamic markdown routes — if the file does not exist the fetcher throws `{ status: 404 }`.
|
|
305
|
+
|
|
306
|
+
### `parseMd(source)` — string parser
|
|
307
|
+
|
|
308
|
+
For markdown from a database or API rather than a file:
|
|
309
|
+
|
|
310
|
+
```js
|
|
311
|
+
import { parseMd } from '@invisibleloop/pulse/md'
|
|
312
|
+
|
|
313
|
+
server: {
|
|
314
|
+
post: async (ctx) => {
|
|
315
|
+
const record = await db.posts.find(ctx.params.id)
|
|
316
|
+
const { html, frontmatter } = parseMd(record.body)
|
|
317
|
+
return { html, title: frontmatter.title ?? record.title }
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
```
|
package/src/agent/guide-spec.md
CHANGED
|
@@ -4,13 +4,15 @@ Pulse is a spec-first frontend framework. Pages are JS files that export a defau
|
|
|
4
4
|
|
|
5
5
|
```js
|
|
6
6
|
export default {
|
|
7
|
+
// meta MUST be a plain object — never make meta itself a function.
|
|
8
|
+
// Individual fields may be strings or (ctx) => value functions (sync or async).
|
|
7
9
|
meta: {
|
|
8
|
-
title: 'Page Title', // string or (ctx) => string
|
|
9
|
-
description: 'Meta description', // string or (ctx) => string
|
|
10
|
+
title: 'Page Title', // string or async (ctx) => string
|
|
11
|
+
description: 'Meta description', // string or async (ctx) => string
|
|
10
12
|
styles: ['/app.css'], // string[] or (ctx) => string[]
|
|
11
13
|
ogTitle: 'Social title', // optional — falls back to title
|
|
12
14
|
ogImage: 'https://…/og.jpg', // optional — 1200×630 recommended
|
|
13
|
-
canonical: 'https://mysite.com/…', // optional — string or (ctx
|
|
15
|
+
canonical: 'https://mysite.com/…', // optional — string or (ctx) => string
|
|
14
16
|
schema: { '@context': 'https://schema.org', '@type': 'WebPage', … }, // optional JSON-LD
|
|
15
17
|
},
|
|
16
18
|
state: { /* initial state */ },
|
package/src/agent/guide.md
CHANGED
|
@@ -874,8 +874,8 @@ import { prose } from '@invisibleloop/pulse/ui'
|
|
|
874
874
|
// CMS rich text field — output directly, fully styled
|
|
875
875
|
prose({ content: server.article.bodyHtml })
|
|
876
876
|
|
|
877
|
-
// Markdown rendered to HTML
|
|
878
|
-
prose({ content:
|
|
877
|
+
// Markdown rendered to HTML (use md() helper — see Markdown section below)
|
|
878
|
+
prose({ content: server.page.html })
|
|
879
879
|
|
|
880
880
|
// Larger text (e.g. hero intro)
|
|
881
881
|
prose({ content: server.page.intro, size: 'lg' })
|
|
@@ -885,6 +885,109 @@ prose({ content: server.page.intro, size: 'lg' })
|
|
|
885
885
|
- **`prose()`** — when the HTML comes from outside your spec (CMS, database, markdown). You don't control the tags.
|
|
886
886
|
- **`heading()` / `list()`** — when you're writing the content yourself inside the view template.
|
|
887
887
|
|
|
888
|
+
---
|
|
889
|
+
|
|
890
|
+
## Markdown
|
|
891
|
+
|
|
892
|
+
Pulse has a built-in markdown parser. Use it for static-site content, blog posts, documentation pages, or any content written in `.md` files. Zero browser JS — all parsing happens server-side.
|
|
893
|
+
|
|
894
|
+
### `md(pathPattern)` — file helper
|
|
895
|
+
|
|
896
|
+
Import from `@invisibleloop/pulse/md`. Returns an async server fetcher. Use it in `server` and optionally `meta`:
|
|
897
|
+
|
|
898
|
+
```js
|
|
899
|
+
import { md } from '@invisibleloop/pulse/md'
|
|
900
|
+
import { prose } from '@invisibleloop/pulse/ui'
|
|
901
|
+
|
|
902
|
+
const page = md('content/about.md')
|
|
903
|
+
|
|
904
|
+
export default {
|
|
905
|
+
route: '/about',
|
|
906
|
+
server: { page },
|
|
907
|
+
view: (state, { page }) => prose({ content: page.html }),
|
|
908
|
+
}
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
The fetcher returns `{ html, frontmatter }`. `html` is ready to pass to `prose()`. `frontmatter` is the parsed `---` block.
|
|
912
|
+
|
|
913
|
+
### Frontmatter for meta tags
|
|
914
|
+
|
|
915
|
+
**`meta` must be a plain object — never a function.** Individual fields can be `async (ctx) => value`. This is the correct pattern for pulling frontmatter into meta:
|
|
916
|
+
|
|
917
|
+
```js
|
|
918
|
+
// WRONG — meta cannot be a function factory
|
|
919
|
+
meta: async (ctx) => {
|
|
920
|
+
const post = await fetchPost(ctx)
|
|
921
|
+
return { title: post.frontmatter.title, styles: ['/app.css'] }
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// CORRECT — meta is a plain object; each field is a function
|
|
925
|
+
meta: {
|
|
926
|
+
title: async (ctx) => (await post(ctx)).frontmatter.title,
|
|
927
|
+
description: async (ctx) => (await post(ctx)).frontmatter.description,
|
|
928
|
+
styles: ['/pulse-ui.css', '/app.css'], // static fields stay as values
|
|
929
|
+
}
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
Call the same fetcher in both `meta` and `server` — it reads the file only once per request (cached on `ctx._mdCache`):
|
|
933
|
+
|
|
934
|
+
```js
|
|
935
|
+
const page = md('content/about.md')
|
|
936
|
+
|
|
937
|
+
export default {
|
|
938
|
+
route: '/about',
|
|
939
|
+
meta: {
|
|
940
|
+
title: async (ctx) => (await page(ctx)).frontmatter.title,
|
|
941
|
+
description: async (ctx) => (await page(ctx)).frontmatter.description,
|
|
942
|
+
},
|
|
943
|
+
server: { page },
|
|
944
|
+
view: (state, { page }) => prose({ content: page.html }),
|
|
945
|
+
}
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
### Dynamic routes — `:param` placeholders
|
|
949
|
+
|
|
950
|
+
```js
|
|
951
|
+
const post = md('content/blog/:slug.md')
|
|
952
|
+
|
|
953
|
+
export default {
|
|
954
|
+
route: '/blog/:slug',
|
|
955
|
+
meta: {
|
|
956
|
+
title: async (ctx) => (await post(ctx)).frontmatter.title,
|
|
957
|
+
description: async (ctx) => (await post(ctx)).frontmatter.description,
|
|
958
|
+
},
|
|
959
|
+
server: { post },
|
|
960
|
+
view: (state, { post }) => `
|
|
961
|
+
<main id="main-content">
|
|
962
|
+
${prose({ content: post.html })}
|
|
963
|
+
</main>
|
|
964
|
+
`,
|
|
965
|
+
onViewError: () => `<main id="main-content"><p>Post not found.</p></main>`,
|
|
966
|
+
}
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
Always add `onViewError` on dynamic markdown routes — if the file doesn't exist the fetcher throws `{ status: 404 }`.
|
|
970
|
+
|
|
971
|
+
### `parseMd(source)` — string parser
|
|
972
|
+
|
|
973
|
+
For markdown that comes from a database or API rather than a file:
|
|
974
|
+
|
|
975
|
+
```js
|
|
976
|
+
import { parseMd } from '@invisibleloop/pulse/md'
|
|
977
|
+
|
|
978
|
+
server: {
|
|
979
|
+
post: async (ctx) => {
|
|
980
|
+
const record = await db.posts.find(ctx.params.id)
|
|
981
|
+
const { html, frontmatter } = parseMd(record.body)
|
|
982
|
+
return { html, title: frontmatter.title ?? record.title }
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
### What the parser supports
|
|
988
|
+
|
|
989
|
+
Headings (with auto anchor IDs), bold/italic/strikethrough, inline code, fenced code blocks with syntax highlighting (js/ts/html/css/bash/json), links, images, ordered + unordered lists (nested), blockquotes, GFM tables, horizontal rules, frontmatter.
|
|
990
|
+
|
|
888
991
|
### Attaching Pulse events to components
|
|
889
992
|
|
|
890
993
|
Pass events via `attrs` — must be an object, not a string:
|
package/src/cli/dev.js
CHANGED
|
@@ -25,6 +25,10 @@ const ROOT = rootArg !== -1
|
|
|
25
25
|
? path.resolve(args[rootArg + 1])
|
|
26
26
|
: process.cwd()
|
|
27
27
|
|
|
28
|
+
// Ensure process.cwd() === ROOT so relative paths in specs (e.g. md('src/content/foo.md'))
|
|
29
|
+
// resolve to the project root, not wherever the parent process was launched from.
|
|
30
|
+
if (process.cwd() !== ROOT) process.chdir(ROOT)
|
|
31
|
+
|
|
28
32
|
let _config = {}
|
|
29
33
|
const configPath = path.join(ROOT, 'pulse.config.js')
|
|
30
34
|
if (fs.existsSync(configPath)) {
|
package/src/cli/scaffold.js
CHANGED
package/src/cli/start.js
CHANGED
|
@@ -18,6 +18,7 @@ const rootArg = args.indexOf('--root')
|
|
|
18
18
|
const portArg = args.indexOf('--port')
|
|
19
19
|
|
|
20
20
|
const ROOT = rootArg !== -1 ? path.resolve(args[rootArg + 1]) : process.cwd()
|
|
21
|
+
if (process.cwd() !== ROOT) process.chdir(ROOT)
|
|
21
22
|
const PUBLIC_DIR = path.join(ROOT, 'public')
|
|
22
23
|
const DIST_DIR = path.join(PUBLIC_DIR, 'dist')
|
|
23
24
|
|