@invisibleloop/pulse 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CLAUDE.md +36 -0
  2. package/docs/.claude/pulse-checklist.md +7 -5
  3. package/docs/public/.pulse-ui-version +1 -1
  4. package/docs/public/pulse-ui.css +27 -0
  5. package/docs/server.js +1 -0
  6. package/docs/src/lib/highlight.js +2 -299
  7. package/docs/src/lib/nav.js +1 -0
  8. package/docs/src/pages/accessibility.js +0 -1
  9. package/docs/src/pages/auth.js +0 -2
  10. package/docs/src/pages/caching.js +0 -1
  11. package/docs/src/pages/components/accordion.js +0 -1
  12. package/docs/src/pages/components/alert.js +0 -1
  13. package/docs/src/pages/components/app-badge.js +0 -1
  14. package/docs/src/pages/components/avatar.js +0 -1
  15. package/docs/src/pages/components/badge.js +0 -1
  16. package/docs/src/pages/components/banner.js +0 -1
  17. package/docs/src/pages/components/breadcrumbs.js +0 -1
  18. package/docs/src/pages/components/button.js +0 -1
  19. package/docs/src/pages/components/card.js +0 -1
  20. package/docs/src/pages/components/carousel.js +0 -1
  21. package/docs/src/pages/components/charts.js +0 -1
  22. package/docs/src/pages/components/checkbox.js +0 -1
  23. package/docs/src/pages/components/cluster.js +0 -1
  24. package/docs/src/pages/components/code-window.js +0 -1
  25. package/docs/src/pages/components/container.js +0 -1
  26. package/docs/src/pages/components/divider.js +0 -1
  27. package/docs/src/pages/components/empty.js +0 -1
  28. package/docs/src/pages/components/feature.js +0 -1
  29. package/docs/src/pages/components/fieldset.js +0 -1
  30. package/docs/src/pages/components/footer.js +0 -1
  31. package/docs/src/pages/components/grid.js +0 -1
  32. package/docs/src/pages/components/heading.js +0 -1
  33. package/docs/src/pages/components/icons.js +0 -1
  34. package/docs/src/pages/components/image.js +0 -1
  35. package/docs/src/pages/components/input.js +0 -1
  36. package/docs/src/pages/components/list.js +0 -1
  37. package/docs/src/pages/components/media.js +0 -1
  38. package/docs/src/pages/components/modal.js +0 -1
  39. package/docs/src/pages/components/nav.js +0 -1
  40. package/docs/src/pages/components/pricing.js +0 -1
  41. package/docs/src/pages/components/progress.js +0 -1
  42. package/docs/src/pages/components/prose.js +2 -3
  43. package/docs/src/pages/components/pullquote.js +0 -1
  44. package/docs/src/pages/components/radio.js +0 -1
  45. package/docs/src/pages/components/rating.js +0 -1
  46. package/docs/src/pages/components/search.js +0 -1
  47. package/docs/src/pages/components/section.js +0 -1
  48. package/docs/src/pages/components/segmented.js +0 -1
  49. package/docs/src/pages/components/select.js +0 -1
  50. package/docs/src/pages/components/spinner.js +0 -1
  51. package/docs/src/pages/components/stack.js +0 -1
  52. package/docs/src/pages/components/stat.js +0 -1
  53. package/docs/src/pages/components/stepper.js +0 -1
  54. package/docs/src/pages/components/table.js +0 -1
  55. package/docs/src/pages/components/testimonial.js +0 -1
  56. package/docs/src/pages/components/textarea.js +0 -1
  57. package/docs/src/pages/components/timeline.js +0 -1
  58. package/docs/src/pages/components/toggle.js +0 -1
  59. package/docs/src/pages/components/tooltip.js +0 -1
  60. package/docs/src/pages/components.js +0 -1
  61. package/docs/src/pages/config.js +0 -1
  62. package/docs/src/pages/deploy.js +0 -1
  63. package/docs/src/pages/extending.js +0 -1
  64. package/docs/src/pages/faq.js +0 -1
  65. package/docs/src/pages/getting-started.js +0 -1
  66. package/docs/src/pages/guard.js +0 -3
  67. package/docs/src/pages/how-it-works.js +0 -1
  68. package/docs/src/pages/images.js +0 -2
  69. package/docs/src/pages/markdown.js +155 -0
  70. package/docs/src/pages/meta.js +0 -2
  71. package/docs/src/pages/navigation.js +0 -1
  72. package/docs/src/pages/performance.js +0 -2
  73. package/docs/src/pages/project-structure.js +0 -2
  74. package/docs/src/pages/prompt-examples.js +0 -1
  75. package/docs/src/pages/raw-responses.js +0 -4
  76. package/docs/src/pages/routing.js +0 -2
  77. package/docs/src/pages/server-api.js +0 -2
  78. package/docs/src/pages/server-data.js +18 -1
  79. package/docs/src/pages/slash-commands.js +0 -1
  80. package/docs/src/pages/spec.js +2 -1
  81. package/docs/src/pages/streaming.js +0 -2
  82. package/docs/src/pages/testing.js +0 -1
  83. package/docs/src/pages/validation.js +0 -1
  84. package/package.json +5 -1
  85. package/public/.pulse-ui-version +1 -1
  86. package/public/pulse-ui.css +27 -0
  87. package/scripts/build.js +53 -2
  88. package/scripts/strip-server.test.js +74 -2
  89. package/src/agent/checklist.md +20 -0
  90. package/src/agent/guide-server.md +62 -0
  91. package/src/agent/guide-spec.md +5 -3
  92. package/src/agent/guide.md +105 -2
  93. package/src/cli/dev.js +4 -0
  94. package/src/cli/scaffold.js +0 -2
  95. package/src/cli/start.js +1 -0
  96. package/src/md/highlight.js +369 -0
  97. package/src/md/index.js +337 -0
  98. package/src/spec/schema.js +8 -1
  99. package/types/md.d.ts +66 -0
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Complete reference for createServer — all options, hooks, and response behaviour.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/server-api',
17
16
  prev,
@@ -176,7 +175,6 @@ await createServer(specs, {
176
175
  }
177
176
  },
178
177
 
179
- state: {},
180
178
  view: () => \`<form method="POST">...</form>\`,
181
179
  }`, 'js'))}
182
180
  ${callout('note', 'Raw response specs (<code>contentType</code> set) accept any HTTP method without <code>spec.methods</code> — they are always method-agnostic.')}
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Fetch, transform, and combine data on the server before rendering — external APIs, multiple fetchers, parallel requests.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/server-data',
17
16
  prev,
@@ -180,6 +179,24 @@ view: (state, server) => server.notFound
180
179
  featured: async () => fetch('https://api.example.com/featured').then(r => r.json()),
181
180
  },
182
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'))}
183
200
  `,
184
201
  }),
185
202
  }
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'The built-in slash commands available in the Pulse AI agent session.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/slash-commands',
17
16
  prev,
@@ -34,7 +34,8 @@ 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
+ ['<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>.'],
38
39
  ['<code>store</code>', '<code>string[]</code>', 'No', 'Global store keys this page subscribes to. See <a href="/store">Global Store</a>.'],
39
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.'],
40
41
  ['<code>stream</code>', '<code>object</code>', 'No', 'Streaming SSR config: <code>shell</code> + <code>deferred</code> segment names.'],
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'How streaming server-side rendering works in Pulse — shell, deferred segments, and when to use it.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/streaming',
17
16
  prev,
@@ -28,7 +27,6 @@ export default {
28
27
  <p>To use streaming, the <code>view</code> is an <strong>object of named segment functions</strong> rather than a single function. The spec declares which segments are in the shell and which are deferred:</p>
29
28
  ${codeBlock(highlight(`export default {
30
29
  route: '/dashboard',
31
- state: {},
32
30
  server: {
33
31
  data: async (ctx) => ({
34
32
  user: await auth.getUser(ctx.cookies.sessionId), // fast
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Test Pulse view functions with renderSync and render — query the HTML output with CSS-like selectors, no DOM required.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/testing',
17
16
  prev,
@@ -11,7 +11,6 @@ export default {
11
11
  description: 'Declarative validation rules in Pulse — syntax, formats, dot-path notation, and error handling.',
12
12
  styles: ['/docs.css'],
13
13
  },
14
- state: {},
15
14
  view: () => renderLayout({
16
15
  currentHref: '/validation',
17
16
  prev,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.5.0",
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.0
1
+ 0.6.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/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 = stripServerOnlyKeys(input).trim()
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`)
@@ -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
+ ```
@@ -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, serverState) => string
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 */ },
@@ -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: renderMarkdown(server.post.body) })
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)) {
@@ -220,8 +220,6 @@ import { button, heading, iconMinus, iconPlus } from '@invisibleloop/pulse/ui'
220
220
  export default {
221
221
  route: '/',
222
222
 
223
- hydrate: '/src/pages/home.js',
224
-
225
223
  meta: {
226
224
  title: '${appName}',
227
225
  description: '${appName} — built with Pulse.',
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