@invisibleloop/pulse 0.1.23 → 0.1.29

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 (117) hide show
  1. package/.claude/settings.local.json +12 -1
  2. package/.github/workflows/publish.yml +11 -19
  3. package/docs/public/.pulse-ui-version +1 -1
  4. package/docs/public/docs.css +19 -1
  5. package/docs/public/pulse-ui.css +1 -0
  6. package/docs/server.js +5 -2
  7. package/docs/src/lib/highlight.js +57 -13
  8. package/docs/src/lib/layout.js +5 -2
  9. package/docs/src/pages/faq.js +5 -2
  10. package/docs/src/pages/home.js +9 -5
  11. package/docs/src/pages/meta.js +21 -0
  12. package/docs/src/pages/routing.js +12 -1
  13. package/package.json +6 -2
  14. package/src/agent/guide-routing.md +20 -0
  15. package/src/agent/guide-spec.md +9 -1
  16. package/src/cli/scaffold.js +63 -2
  17. package/src/server/index.js +21 -6
  18. package/src/server/server.test.js +47 -0
  19. package/docs/public/dist/accessibility.boot-5DVTARJU.js +0 -115
  20. package/docs/public/dist/actions.boot-P66HKQEM.js +0 -164
  21. package/docs/public/dist/auth.boot-IMAJAUPH.js +0 -140
  22. package/docs/public/dist/caching.boot-DVR6KDE7.js +0 -53
  23. package/docs/public/dist/components--accordion.boot-3HVKMNWC.js +0 -11
  24. package/docs/public/dist/components--alert.boot-GCEXOZAC.js +0 -6
  25. package/docs/public/dist/components--app-badge.boot-DVT3GCHJ.js +0 -6
  26. package/docs/public/dist/components--avatar.boot-PSW24EVA.js +0 -5
  27. package/docs/public/dist/components--badge.boot-TYDY2RMK.js +0 -7
  28. package/docs/public/dist/components--banner.boot-EI5PZSZK.js +0 -7
  29. package/docs/public/dist/components--breadcrumbs.boot-SMA2E2GO.js +0 -34
  30. package/docs/public/dist/components--button.boot-J54BQM2E.js +0 -23
  31. package/docs/public/dist/components--card.boot-PZGNDIB6.js +0 -138
  32. package/docs/public/dist/components--carousel.boot-TP6LPFZZ.js +0 -12
  33. package/docs/public/dist/components--charts.boot-2EOYQWKL.js +0 -108
  34. package/docs/public/dist/components--checkbox.boot-DS5BSL6T.js +0 -54
  35. package/docs/public/dist/components--cluster.boot-HHVIBBJG.js +0 -9
  36. package/docs/public/dist/components--code-window.boot-2GR2DV33.js +0 -20
  37. package/docs/public/dist/components--container.boot-7LOOGK2K.js +0 -5
  38. package/docs/public/dist/components--cta.boot-FSNZ5YRT.js +0 -11
  39. package/docs/public/dist/components--divider.boot-3NI2C3QG.js +0 -6
  40. package/docs/public/dist/components--empty.boot-YX2UR3PV.js +0 -7
  41. package/docs/public/dist/components--feature.boot-MUD7NSUO.js +0 -13
  42. package/docs/public/dist/components--fieldset.boot-J7BYHMKF.js +0 -19
  43. package/docs/public/dist/components--fileupload.boot-NIKVTTPD.js +0 -52
  44. package/docs/public/dist/components--footer.boot-EYUK5FRG.js +0 -14
  45. package/docs/public/dist/components--grid.boot-URDQVDDR.js +0 -59
  46. package/docs/public/dist/components--heading.boot-BPQKU43E.js +0 -44
  47. package/docs/public/dist/components--hero.boot-4RAPRGAB.js +0 -17
  48. package/docs/public/dist/components--icons.boot-ZITNU5JP.js +0 -68
  49. package/docs/public/dist/components--image.boot-XEEGHQZF.js +0 -19
  50. package/docs/public/dist/components--input.boot-SGASZG5K.js +0 -7
  51. package/docs/public/dist/components--list.boot-W3XC5MHD.js +0 -55
  52. package/docs/public/dist/components--media.boot-5VFIETZO.js +0 -13
  53. package/docs/public/dist/components--modal.boot-RZUYXBN2.js +0 -47
  54. package/docs/public/dist/components--nav.boot-ODBOHU7O.js +0 -33
  55. package/docs/public/dist/components--pricing.boot-4AQ4ZVBY.js +0 -21
  56. package/docs/public/dist/components--progress.boot-GHAGYZOK.js +0 -30
  57. package/docs/public/dist/components--prose.boot-QANJL6JI.js +0 -67
  58. package/docs/public/dist/components--pullquote.boot-Q2WMNAZU.js +0 -22
  59. package/docs/public/dist/components--radio.boot-TJRDQ2OL.js +0 -75
  60. package/docs/public/dist/components--rating.boot-QBAN6DEL.js +0 -38
  61. package/docs/public/dist/components--search.boot-PXH5O5AG.js +0 -17
  62. package/docs/public/dist/components--section.boot-AQGIYHWW.js +0 -12
  63. package/docs/public/dist/components--segmented.boot-BEVTKEJO.js +0 -33
  64. package/docs/public/dist/components--select.boot-47X5RHOC.js +0 -10
  65. package/docs/public/dist/components--slider.boot-PSRRX7XL.js +0 -47
  66. package/docs/public/dist/components--spinner.boot-MZ5MO2OH.js +0 -22
  67. package/docs/public/dist/components--stack.boot-DI4NJXBF.js +0 -9
  68. package/docs/public/dist/components--stat.boot-QMFUWBQT.js +0 -9
  69. package/docs/public/dist/components--stepper.boot-34PP2NEV.js +0 -22
  70. package/docs/public/dist/components--table.boot-FCQGSFIQ.js +0 -11
  71. package/docs/public/dist/components--testimonial.boot-DWQPDKYG.js +0 -11
  72. package/docs/public/dist/components--textarea.boot-QVXLBOJ5.js +0 -4
  73. package/docs/public/dist/components--timeline.boot-26LN52P2.js +0 -95
  74. package/docs/public/dist/components--toggle.boot-IQQEI76S.js +0 -29
  75. package/docs/public/dist/components--tooltip.boot-LGHCO6NN.js +0 -9
  76. package/docs/public/dist/components.boot-SE6PQ4P7.js +0 -103
  77. package/docs/public/dist/config.boot-DTRRWUE6.js +0 -126
  78. package/docs/public/dist/constraints.boot-DUHDZBMC.js +0 -71
  79. package/docs/public/dist/deploy.boot-SLAD3NI2.js +0 -163
  80. package/docs/public/dist/docs-8e3d4b5c.css +0 -1
  81. package/docs/public/dist/extending.boot-UA3CN243.js +0 -159
  82. package/docs/public/dist/faq.boot-6EQAWLQR.js +0 -43
  83. package/docs/public/dist/getting-started.boot-TDKIFL5U.js +0 -86
  84. package/docs/public/dist/guard.boot-AUHAWTG4.js +0 -80
  85. package/docs/public/dist/home.boot-BVQXRH32.js +0 -383
  86. package/docs/public/dist/how-it-works.boot-LTWAKWKW.js +0 -104
  87. package/docs/public/dist/hydration.boot-JRM6IPJL.js +0 -78
  88. package/docs/public/dist/images.boot-M6ZVKTZS.js +0 -80
  89. package/docs/public/dist/manifest.json +0 -94
  90. package/docs/public/dist/meta.boot-7NXGPHR4.js +0 -79
  91. package/docs/public/dist/mutations.boot-F6F43UDX.js +0 -79
  92. package/docs/public/dist/navigation.boot-AOXWS3ZF.js +0 -57
  93. package/docs/public/dist/performance.boot-C3UPCOBK.js +0 -98
  94. package/docs/public/dist/persist.boot-WT32PQOQ.js +0 -61
  95. package/docs/public/dist/project-structure.boot-FB3LRVJ4.js +0 -63
  96. package/docs/public/dist/prompt-examples.boot-YKR4VDK4.js +0 -31
  97. package/docs/public/dist/pulse-ui-81a85c03.css +0 -1
  98. package/docs/public/dist/raw-responses.boot-M4KA5YXL.js +0 -104
  99. package/docs/public/dist/routing.boot-FNX5FDGH.js +0 -70
  100. package/docs/public/dist/runtime-B73WLANC.js +0 -1
  101. package/docs/public/dist/runtime-KO4BHUQ3.js +0 -49
  102. package/docs/public/dist/runtime-L2HNXIHW.js +0 -59
  103. package/docs/public/dist/runtime-QFURDKA2.js +0 -5
  104. package/docs/public/dist/runtime-UVPXO4IR.js +0 -375
  105. package/docs/public/dist/runtime-VMJA3Z4N.js +0 -10
  106. package/docs/public/dist/runtime-ZJ4FXT5O.js +0 -11
  107. package/docs/public/dist/server-api.boot-K7X3LCFB.js +0 -219
  108. package/docs/public/dist/server-data.boot-Y7HQYC4R.js +0 -157
  109. package/docs/public/dist/slash-commands.boot-V2UV7OW2.js +0 -26
  110. package/docs/public/dist/spec.boot-2WU7ZHCV.js +0 -159
  111. package/docs/public/dist/state.boot-B24GUE3R.js +0 -73
  112. package/docs/public/dist/store.boot-TLIB4XHH.js +0 -150
  113. package/docs/public/dist/streaming.boot-W2DZSMW4.js +0 -80
  114. package/docs/public/dist/stripe.boot-QN3C2GEL.js +0 -164
  115. package/docs/public/dist/supabase.boot-BG4XXLZE.js +0 -303
  116. package/docs/public/dist/testing.boot-6U4WKMTE.js +0 -130
  117. package/docs/public/dist/validation.boot-PQHYGW5B.js +0 -100
@@ -96,7 +96,18 @@
96
96
  "Bash(git branch:*)",
97
97
  "Bash(gh run:*)",
98
98
  "Bash(xargs -I{} gh run delete {} --repo invisibleloop/pulse-framework)",
99
- "Bash(gh api:*)"
99
+ "Bash(gh api:*)",
100
+ "Bash(npm access:*)",
101
+ "Bash(gh pr:*)",
102
+ "Bash(git fetch:*)",
103
+ "Bash(git rebase:*)",
104
+ "Bash(git rm:*)",
105
+ "Bash(sed -i '' 's/Ship with it built in/Production quality, built in/' docs/src/pages/home.js)",
106
+ "Bash(3 \" -A2 docs/src/pages/home.js)",
107
+ "Bash(git merge:*)",
108
+ "Bash(node -p \"require\\(''./package.json''\\).version\")",
109
+ "Bash(npm view:*)",
110
+ "Bash(git cherry-pick:*)"
100
111
  ]
101
112
  }
102
113
  }
@@ -1,25 +1,17 @@
1
1
  name: Release
2
2
 
3
3
  on:
4
- workflow_dispatch:
5
- inputs:
6
- bump:
7
- description: Version bump
8
- type: choice
9
- options: [patch, minor, major]
10
- default: patch
11
- required: true
4
+ push:
5
+ branches: [main]
12
6
 
13
7
  jobs:
14
8
  release:
15
9
  runs-on: ubuntu-latest
16
10
  permissions:
17
- contents: write
11
+ contents: read
18
12
  id-token: write
19
13
  steps:
20
14
  - uses: actions/checkout@v4
21
- with:
22
- token: ${{ secrets.GITHUB_TOKEN }}
23
15
 
24
16
  - uses: actions/setup-node@v4
25
17
  with:
@@ -31,12 +23,12 @@ jobs:
31
23
 
32
24
  - run: npm test
33
25
 
34
- - name: Bump version and tag
26
+ - name: Publish if version is new
35
27
  run: |
36
- git config user.name "github-actions[bot]"
37
- git config user.email "github-actions[bot]@users.noreply.github.com"
38
- git pull --rebase
39
- npm version ${{ inputs.bump }} -m "chore: release %s [skip ci]"
40
- git push --follow-tags
41
-
42
- - run: npm publish --provenance --access public
28
+ VERSION=$(node -p "require('./package.json').version")
29
+ EXISTS=$(npm view @invisibleloop/pulse@$VERSION version 2>/dev/null || echo "")
30
+ if [ -z "$EXISTS" ]; then
31
+ npm publish --provenance --access public
32
+ else
33
+ echo "Version $VERSION already published — skipping."
34
+ fi
@@ -1 +1 @@
1
- 0.1.20
1
+ 0.1.28
@@ -167,6 +167,15 @@ a:hover {
167
167
  margin-bottom: 1.25rem;
168
168
  }
169
169
 
170
+ .hero-kicker {
171
+ font-size: 0.95rem;
172
+ font-weight: 500;
173
+ color: #0a0a0a;
174
+ opacity: 0.55;
175
+ margin-bottom: 1rem;
176
+ letter-spacing: 0.01em;
177
+ }
178
+
170
179
  .hero-badge {
171
180
  display: inline-flex;
172
181
  align-items: center;
@@ -432,6 +441,7 @@ a:hover {
432
441
  .home-code {
433
442
  background: #111114;
434
443
  padding: 5rem 2rem 6rem;
444
+ --muted: #9090a0;
435
445
  }
436
446
 
437
447
  .home-code-inner {
@@ -1467,7 +1477,11 @@ a:hover {
1467
1477
  border: none;
1468
1478
  color: var(--muted);
1469
1479
  cursor: pointer;
1470
- padding: 0.25rem;
1480
+ min-width: 44px;
1481
+ min-height: 44px;
1482
+ align-items: center;
1483
+ justify-content: center;
1484
+ padding: 0;
1471
1485
  }
1472
1486
  .mobile-menu-btn:hover {
1473
1487
  color: var(--text);
@@ -1476,6 +1490,10 @@ a:hover {
1476
1490
  .header-logo-mobile {
1477
1491
  display: none;
1478
1492
  color: var(--text);
1493
+ min-width: 44px;
1494
+ min-height: 44px;
1495
+ align-items: center;
1496
+ justify-content: center;
1479
1497
  }
1480
1498
 
1481
1499
  .header-github {
@@ -1635,6 +1635,7 @@ hr.ui-divider {
1635
1635
  font-family: var(--ui-mono);
1636
1636
  color: var(--ui-muted);
1637
1637
  margin-left: auto;
1638
+ opacity: .7;
1638
1639
  }
1639
1640
 
1640
1641
  .ui-code-window-pre {
package/docs/server.js CHANGED
@@ -37,6 +37,7 @@ import meta from './src/pages/meta.js'
37
37
  import performance from './src/pages/performance.js'
38
38
  import accessibility from './src/pages/accessibility.js'
39
39
  import testing from './src/pages/testing.js'
40
+ import store from './src/pages/store.js'
40
41
 
41
42
  // Component pages
42
43
  import compButton from './src/pages/components/button.js'
@@ -130,6 +131,7 @@ createServer(
130
131
  performance,
131
132
  accessibility,
132
133
  testing,
134
+ store,
133
135
  // Component pages
134
136
  compButton,
135
137
  compBadge,
@@ -186,7 +188,8 @@ createServer(
186
188
  compIcons,
187
189
  ],
188
190
  {
189
- port: process.env.PORT ? Number(process.env.PORT) : 4000,
190
- staticDir: new URL('./public', import.meta.url).pathname,
191
+ port: process.env.PORT ? Number(process.env.PORT) : 4000,
192
+ staticDir: new URL('./public', import.meta.url).pathname,
193
+ defaultCache: true,
191
194
  }
192
195
  )
@@ -212,6 +212,62 @@ function highlightBash(code) {
212
212
  // HTML tokeniser (minimal)
213
213
  // ---------------------------------------------------------------------------
214
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
+
215
271
  function highlightHtml(code) {
216
272
  let out = ''
217
273
  let i = 0
@@ -231,19 +287,7 @@ function highlightHtml(code) {
231
287
  if (code[i] === '<') {
232
288
  const end = code.indexOf('>', i)
233
289
  if (end === -1) { out += esc(code[i++]); continue }
234
- const tag = code.slice(i, end + 1)
235
- // Simple: highlight tag name + attributes
236
- const highlighted = tag.replace(
237
- /^(<\/?)([\w-]+)/,
238
- (_, lt, name) => esc(lt) + span('tok-kw', name)
239
- ).replace(
240
- /([\w-]+)(=)/g,
241
- (_, attr, eq) => span('tok-fn', attr) + esc(eq)
242
- ).replace(
243
- /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g,
244
- (_, str) => span('tok-str', str)
245
- )
246
- out += highlighted
290
+ out += highlightHtmlTag(code.slice(i, end + 1))
247
291
  i = end + 1
248
292
  continue
249
293
  }
@@ -6,6 +6,9 @@
6
6
  */
7
7
 
8
8
  import { NAV } from './nav.js'
9
+ import pkg from '../../../package.json' with { type: 'json' }
10
+
11
+ const { version } = pkg
9
12
 
10
13
  function esc(s) {
11
14
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
@@ -33,7 +36,7 @@ function sidebar(currentHref) {
33
36
  </svg>
34
37
  <span class="logo-name">Pulse</span>
35
38
  </a>
36
- <span class="version-badge">v0.1</span>
39
+ <span class="version-badge">v${version}</span>
37
40
  </div>
38
41
  <nav class="sidebar-nav">
39
42
  ${sections}
@@ -74,7 +77,7 @@ export function renderLayout({ currentHref, content, prev = null, next = null })
74
77
  <path d="M13 2L4.5 13.5H11L10 22L19.5 10.5H13L13 2Z" fill="var(--accent)" stroke="var(--accent)" stroke-width="1" stroke-linejoin="round"/>
75
78
  </svg>
76
79
  </a>
77
- <a href="https://github.com/invisibleloop/pulse" class="header-github" aria-label="View on GitHub" target="_blank" rel="noopener noreferrer">
80
+ <a href="https://github.com/invisibleloop/pulse-framework" class="header-github" aria-label="View on GitHub" target="_blank" rel="noopener noreferrer">
78
81
  <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
79
82
  <path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/>
80
83
  </svg>
@@ -1,5 +1,8 @@
1
1
  import { renderLayout, h1, lead } from '../lib/layout.js'
2
2
  import { prevNext } from '../lib/nav.js'
3
+ import pkg from '../../../package.json' with { type: 'json' }
4
+
5
+ const { version } = pkg
3
6
 
4
7
  const { prev, next } = prevNext('/faq')
5
8
 
@@ -30,7 +33,7 @@ export default {
30
33
  ${q(
31
34
  'Is Pulse ready for production?',
32
35
  `<p>The architecture is production-quality — streaming SSR, security headers, immutable caching, and zero runtime dependencies are built in and have been running reliably in real deployments. The framework itself targets Lighthouse 100 on every scaffolded page.</p>
33
- <p>That said, Pulse is v0.1 early access. The core spec format is stable, but some APIs may change before v1. It is best suited to new projects where you control the stack, and to teams who are comfortable building on something that is still evolving. If you need a framework with a five-year stability guarantee, wait for v1.</p>`
36
+ <p>That said, Pulse is v${version} early access. The core spec format is stable, but some APIs may change before v1. It is best suited to new projects where you control the stack, and to teams who are comfortable building on something that is still evolving. If you need a framework with a five-year stability guarantee, wait for v1.</p>`
34
37
  )}
35
38
 
36
39
  ${q(
@@ -42,7 +45,7 @@ export default {
42
45
  ${q(
43
46
  'Why no virtual DOM?',
44
47
  `<p>A virtual DOM solves the problem of efficient incremental updates to a large, complex component tree. Pulse pages are server-rendered HTML strings — the client runtime re-renders a bounded section of the page when state changes, which is fast enough for the kinds of interactions Pulse is designed for.</p>
45
- <p>Eliminating the virtual DOM means eliminating the ~40–100 kB runtime that comes with it. Pulse ships ~2 kB brotli to the browser on first visit. That is not a compression trick — there is genuinely no framework runtime on the client.</p>`
48
+ <p>Eliminating the virtual DOM means eliminating the ~40–100 kB runtime that comes with it. Pulse ships ~3.5 kB brotli to the browser on first visit (shared runtime + page bundle). That is not a compression trick — there is genuinely no framework runtime on the client.</p>`
46
49
  )}
47
50
 
48
51
  ${q(
@@ -1,6 +1,9 @@
1
1
  import { highlight } from '../lib/highlight.js'
2
2
  import { metricsStore } from '../lib/metrics-store.js'
3
3
  import { codeWindow } from '../../../src/ui/code-window.js'
4
+ import pkg from '../../../package.json' with { type: 'json' }
5
+
6
+ const { version } = pkg
4
7
 
5
8
  const exampleSpec = highlight(`export default {
6
9
  route: '/dashboard',
@@ -52,7 +55,7 @@ export default {
52
55
  </a>
53
56
  <div class="home-nav-links">
54
57
  <a href="/getting-started">Docs</a>
55
- <a href="https://github.com/invisibleloop/pulse" target="_blank" rel="noopener">GitHub</a>
58
+ <a href="https://github.com/invisibleloop/pulse-framework" target="_blank" rel="noopener">GitHub</a>
56
59
  </div>
57
60
  </nav>
58
61
 
@@ -63,9 +66,10 @@ export default {
63
66
  <path d="M13 2L4.5 13.5H11L10 22L19.5 10.5H13L13 2Z" fill="var(--accent)" stroke="var(--accent)" stroke-width="1" stroke-linejoin="round"/>
64
67
  </svg>
65
68
  </div>
66
- <div class="hero-badge">v0.1 — EARLY ACCESS</div>
69
+ <div class="hero-badge">v${version} — EARLY ACCESS</div>
70
+ <p class="hero-kicker">A Node.js framework for building server-rendered web apps</p>
67
71
  <h1 class="hero-title">Describe the outcome. Pulse guarantees it.</h1>
68
- <p class="hero-subtitle">One spec object per page — server data, state, mutations, and view, co-located in plain JS. Streaming SSR, security headers, and production caching are enforced by the framework, not left to configuration.<br><br>Designed for AI agents. Production-quality architecture.</p>
72
+ <p class="hero-subtitle">One spec object per page — server data, state, mutations, and view in plain JS. Streaming SSR, security headers, and production caching are enforced by the framework, not left to configuration. Designed for AI agents.</p>
69
73
  <div class="hero-ctas">
70
74
  <a href="/getting-started" class="btn-primary">Get Started</a>
71
75
  <a href="/spec" class="btn-secondary">Read the Spec</a>
@@ -112,7 +116,7 @@ export default {
112
116
  <div class="how-connector" aria-hidden="true"></div>
113
117
  <div class="how-step">
114
118
  <div class="how-step-num">3</div>
115
- <h3>Ship with it built in</h3>
119
+ <h3>Production quality, built in</h3>
116
120
  <p>Streaming SSR, security headers, and immutable caching come from the framework — not your config. Follow the spec, and the results follow.</p>
117
121
  </div>
118
122
  </div>
@@ -394,7 +398,7 @@ export default {
394
398
 
395
399
  </main>
396
400
  <footer class="home-footer">
397
- <p>MIT License · <a href="https://github.com/invisibleloop/pulse" target="_blank" rel="noopener">GitHub</a> · <a href="/getting-started">Get started in 2 minutes</a></p>
401
+ <p>MIT License · <a href="https://github.com/invisibleloop/pulse-framework" target="_blank" rel="noopener">GitHub</a> · <a href="/getting-started">Get started in 2 minutes</a></p>
398
402
  </footer>
399
403
  </div>
400
404
  `,
@@ -46,6 +46,7 @@ export default {
46
46
  ['<code>ogTitle</code>', '<code>string</code>', 'Open Graph title. If omitted, falls back to <code>title</code>.'],
47
47
  ['<code>ogImage</code>', '<code>string</code>', 'Open Graph image URL — shown when the page is shared on social media.'],
48
48
  ['<code>schema</code>', '<code>object</code>', 'JSON-LD structured data object — emitted as a <code>&lt;script type="application/ld+json"&gt;</code> tag.'],
49
+ ['<code>canonical</code>', '<code>string | (ctx, serverState) => string</code>', 'Canonical URL — overrides the auto-derived canonical. Accepts a function for dynamic values.'],
49
50
  ]
50
51
  )}
51
52
 
@@ -96,6 +97,26 @@ export default {
96
97
  ]
97
98
  )}
98
99
 
100
+ ${section('canonical', 'Canonical URLs')}
101
+ <p>Pulse automatically derives a canonical URL from the request and emits it as a <code>&lt;link rel="canonical"&gt;</code> tag on every page. In most cases no configuration is needed.</p>
102
+ <p>Use <code>meta.canonical</code> to override — for example, on paginated pages or when content is accessible at more than one URL. A plain string is resolved once at startup:</p>
103
+ ${codeBlock(highlight(`// Paginated blog — pages 2, 3, … all canonicalise to the first page
104
+ meta: {
105
+ title: 'Blog — Page 2',
106
+ canonical: 'https://mysite.com/blog',
107
+ }`, 'js'))}
108
+ <p>Pass a function to derive the canonical from the request context or server data. The function receives <code>(ctx, serverState)</code>:</p>
109
+ ${codeBlock(highlight(`// Canonical from a URL param
110
+ meta: {
111
+ canonical: (ctx) => \`https://mysite.com/products/\${ctx.params.slug}\`,
112
+ }
113
+
114
+ // Canonical from a server fetcher result (e.g. canonical slug from a database lookup)
115
+ meta: {
116
+ canonical: (ctx, serverState) => \`https://mysite.com/products/\${serverState.product.slug}\`,
117
+ }`, 'js'))}
118
+ ${callout('note', '<strong>Streaming caveat:</strong> when <code>stream: true</code> (the default), the <code>&lt;head&gt;</code> is written before server fetchers resolve, so <code>serverState</code> will be <code>null</code> for streaming responses. If your canonical depends on server data, set <code>stream: false</code> on that spec, or derive it from <code>ctx.params</code> instead.')}
119
+
99
120
  ${section('styles', 'Stylesheets')}
100
121
  <p>The <code>styles</code> array accepts any number of stylesheet URLs. They are emitted as <code>&lt;link rel="stylesheet"&gt;</code> tags in the <code>&lt;head&gt;</code> in the order declared:</p>
101
122
  ${codeBlock(highlight(`meta: {
@@ -27,7 +27,18 @@ export default {
27
27
  state: {},
28
28
  view: () => \`<h1>About</h1>\`,
29
29
  }`, 'js'))}
30
- <p>Pulse matches the exact path. Trailing slashes are normalised — <code>/about</code> and <code>/about/</code> are treated the same.</p>
30
+ <p>Pulse matches the exact path. By default, trailing slashes are removed — <code>/about/</code> redirects to <code>/about</code> with a 301. This is controlled by the <code>trailingSlash</code> option in <code>createServer</code>:</p>
31
+ ${table(
32
+ ['Value', 'Behaviour'],
33
+ [
34
+ ['<code>"remove"</code> (default)', 'Redirects <code>/about/</code> → <code>/about</code> (301)'],
35
+ ['<code>"add"</code>', 'Redirects <code>/about</code> → <code>/about/</code> (301)'],
36
+ ['<code>"allow"</code>', 'Serves both — no redirect'],
37
+ ]
38
+ )}
39
+ ${codeBlock(highlight(`createServer(specs, {
40
+ trailingSlash: 'add', // enforce trailing slashes
41
+ })`, 'js'))}
31
42
 
32
43
  ${section('dynamic', 'Dynamic segments')}
33
44
  <p>Use a colon prefix for dynamic path segments. Named segments are captured and available in <code>ctx.params</code> in <a href="/server-data">server data</a>:</p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.1.23",
3
+ "version": "0.1.29",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -65,6 +65,10 @@
65
65
  "@modelcontextprotocol/sdk": "^1.27.1",
66
66
  "esbuild": "^0.27.4"
67
67
  },
68
+ "repository": {
69
+ "type": "git",
70
+ "url": "https://github.com/invisibleloop/pulse-framework"
71
+ },
68
72
  "publishConfig": {
69
73
  "access": "public"
70
74
  },
@@ -72,4 +76,4 @@
72
76
  "@types/node": "^25.5.0",
73
77
  "typescript": "^5.9.3"
74
78
  }
75
- }
79
+ }
@@ -34,3 +34,23 @@ Convention: name dynamic-route files [param].js inside a subfolder:
34
34
  src/pages/blog/[slug].js with route: '/blog/:slug'
35
35
 
36
36
  This is purely a human readability convention. Pulse does not process [ ] in filenames.
37
+
38
+ ## Canonical URLs
39
+
40
+ Pulse auto-derives a canonical URL from every request and emits `<link rel="canonical">` in the `<head>`. No config needed in most cases.
41
+
42
+ Override with `meta.canonical` when content is accessible at multiple URLs, or for paginated series:
43
+
44
+ ```js
45
+ // Static override
46
+ meta: { canonical: 'https://mysite.com/blog' }
47
+
48
+ // From URL params (works in both streaming and string mode)
49
+ meta: { canonical: (ctx) => `https://mysite.com/products/${ctx.params.slug}` }
50
+
51
+ // From server data — e.g. canonical slug from a DB lookup
52
+ // Only works when stream: false. In streaming mode serverState is null at head-write time.
53
+ meta: { canonical: (ctx, serverState) => `https://mysite.com/products/${serverState.product.slug}` }
54
+ ```
55
+
56
+ The function signature is `(ctx, serverState) => string`. `serverState` is only populated on the string (non-streaming) path — if your canonical depends on server fetcher results, add `stream: false` to that spec.
@@ -4,7 +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: { title, description, styles: ['/app.css'] },
7
+ meta: {
8
+ title: 'Page Title', // string or (ctx) => string
9
+ description: 'Meta description', // string or (ctx) => string
10
+ styles: ['/app.css'], // string[] or (ctx) => string[]
11
+ ogTitle: 'Social title', // optional — falls back to title
12
+ ogImage: 'https://…/og.jpg', // optional — 1200×630 recommended
13
+ canonical: 'https://mysite.com/…', // optional — string or (ctx, serverState) => string
14
+ schema: { '@context': 'https://schema.org', '@type': 'WebPage', … }, // optional JSON-LD
15
+ },
8
16
  state: { /* initial state */ },
9
17
  mutations: {
10
18
  // Synchronous. Return partial state. First arg is state, second is the DOM event.
@@ -53,8 +53,9 @@ export async function scaffold(targetDir, options = {}) {
53
53
  ${port !== 3000 ? ` port: ${port},\n` : ''}}
54
54
  `)
55
55
 
56
- // Home page — working counter proves the app runs
57
- write(targetDir, 'src/pages/home.js', homePage(name))
56
+ // Home page + tests — working counter proves the app runs
57
+ write(targetDir, 'src/pages/home.js', homePage(name))
58
+ write(targetDir, 'src/pages/home.test.js', homePageTest(name))
58
59
 
59
60
  // Minimal stylesheet
60
61
  write(targetDir, 'public/app.css', baseCSS())
@@ -184,6 +185,15 @@ ${port !== 3000 ? ` port: ${port},\n` : ''}}
184
185
  } catch {
185
186
  execSync('npm install', { cwd: targetDir, stdio: 'inherit' })
186
187
  }
188
+
189
+ // Initialise git so the Stop hook can use `git status` to track only changed files.
190
+ // Without this the hook falls back to listing all src/pages/*.js and flags home.js.
191
+ try {
192
+ execSync('git init && git add -A && git commit -m "init"', { cwd: targetDir, stdio: 'pipe' })
193
+ console.log(' ✓ Git repository initialised')
194
+ } catch {
195
+ // Non-fatal — project still works without git, stop hook falls back gracefully.
196
+ }
187
197
  }
188
198
 
189
199
  // ---------------------------------------------------------------------------
@@ -244,6 +254,51 @@ export default {
244
254
  `
245
255
  }
246
256
 
257
+ function homePageTest(_appName) {
258
+ return `\
259
+ import assert from 'node:assert/strict'
260
+ import { test } from 'node:test'
261
+ import { renderSync } from '@invisibleloop/pulse/testing'
262
+ import spec from './home.js'
263
+
264
+ test('home page renders app name', () => {
265
+ const r = renderSync(spec)
266
+ assert(r.has('main#main-content'))
267
+ assert(r.has('h1'))
268
+ })
269
+
270
+ test('home page renders counter at 0', () => {
271
+ const r = renderSync(spec)
272
+ assert(r.has('span[aria-live]'))
273
+ assert.equal(r.get('span[aria-live]').text, '0')
274
+ })
275
+
276
+ test('home page decrement disabled at min', () => {
277
+ const r = renderSync(spec, { state: { count: 0 } })
278
+ const buttons = r.findAll('button')
279
+ const dec = buttons.find(b => b.attr('aria-label') === 'Decrease count')
280
+ assert(dec, 'decrement button not found')
281
+ assert(dec.attrs.disabled !== undefined, 'decrement should be disabled at 0')
282
+ })
283
+
284
+ test('home page increment disabled at max', () => {
285
+ const r = renderSync(spec, { state: { count: 10 } })
286
+ const buttons = r.findAll('button')
287
+ const inc = buttons.find(b => b.attr('aria-label') === 'Increase count')
288
+ assert(inc, 'increment button not found')
289
+ assert(inc.attrs.disabled !== undefined, 'increment should be disabled at 10')
290
+ })
291
+
292
+ test('increment mutation adds 1', () => {
293
+ assert.deepEqual(spec.mutations.increment({ count: 4 }), { count: 5 })
294
+ })
295
+
296
+ test('decrement mutation subtracts 1', () => {
297
+ assert.deepEqual(spec.mutations.decrement({ count: 4 }), { count: 3 })
298
+ })
299
+ `
300
+ }
301
+
247
302
  function baseCSS() {
248
303
  return `\
249
304
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -280,6 +335,12 @@ public/app.css ← global stylesheet
280
335
 
281
336
  The MCP guide is the single source of truth. Follow it for all technical decisions, component usage, and the mandatory verification workflow.
282
337
 
338
+ ## Before writing any code
339
+
340
+ **Always present a plan and wait for the user to confirm before writing a single line of code.**
341
+
342
+ The plan must include: route, page sections, components used, state shape, and whether hydration is needed. End the plan with an explicit question — "Shall I go ahead?" — and stop. Do not proceed until the user says yes (or equivalent). This applies to every new page or significant change, no matter how clear the task seems.
343
+
283
344
  ## After completing any feature
284
345
 
285
346
  Run these steps in order — do not declare work done without them:
@@ -541,14 +541,16 @@ export function createServer(specs, options = {}) {
541
541
  }
542
542
  }
543
543
 
544
- // Build canonical URL — prefer spec.meta.canonical, otherwise derive from request.
544
+ // Derive the canonical base URL from the request.
545
545
  // Canonical path follows the trailingSlash mode so the <link> is consistent with redirects.
546
+ // spec.meta.canonical (string or function) is resolved inside each handler, where serverState
547
+ // is available — allowing dynamic canonicals from server fetcher results.
546
548
  const proto = req.headers['x-forwarded-proto'] || 'http'
547
549
  const host = req.headers['x-forwarded-host'] || req.headers.host || `localhost:${port}`
548
550
  const canonicalPath = trailingSlash === 'add' && pathname !== '/'
549
551
  ? (pathname.endsWith('/') ? pathname : pathname + '/')
550
552
  : (pathname !== '/' && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname)
551
- const canonicalUrl = spec.meta?.canonical || `${proto}://${host}${canonicalPath}`
553
+ const canonicalBase = `${proto}://${host}${canonicalPath}`
552
554
 
553
555
  // Raw content spec (RSS, sitemap, JSON API, webhooks) — bypass HTML pipeline
554
556
  if (spec.contentType) {
@@ -563,9 +565,9 @@ export function createServer(specs, options = {}) {
563
565
  }
564
566
 
565
567
  if (stream) {
566
- await handleStreamResponse(spec, ctx, req, res, extraBody, dev, canonicalUrl, nonce, runtimeBundle, defaultCache, store, csp)
568
+ await handleStreamResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp)
567
569
  } else {
568
- await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalUrl, nonce, runtimeBundle, defaultCache, store, csp)
570
+ await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp)
569
571
  }
570
572
 
571
573
  } catch (err) {
@@ -676,7 +678,7 @@ async function handleNavResponse(spec, ctx, res, dev = false) {
676
678
  * Render to a complete string then send — simpler, easier to cache.
677
679
  * Checks the in-process page cache before rendering; stores result after.
678
680
  */
679
- async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalUrl = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}) {
681
+ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}) {
680
682
  const cacheKey = spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
681
683
  // Pages with server data or store data embed a nonce'd __PULSE_SERVER__ script — don't cache them
682
684
  const ttl = (!spec.server && !spec.store?.length) ? pageCacheTtl(spec, dev, defaultCache) : 0
@@ -689,6 +691,12 @@ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = f
689
691
  fromCache = true
690
692
  } else {
691
693
  const { html: content, serverState, timing } = await cachedRenderToString(spec, ctx, dev)
694
+ // Resolve canonical — supports a function receiving (ctx, serverState) so the URL can be
695
+ // derived from server fetcher results (e.g. a canonical slug from a database lookup).
696
+ const canonicalRaw = spec.meta?.canonical
697
+ const canonicalUrl = typeof canonicalRaw === 'function'
698
+ ? (canonicalRaw(ctx, serverState) || canonicalBase)
699
+ : (canonicalRaw || canonicalBase)
692
700
  const canonicalTag = canonicalUrl ? `<link rel="canonical" href="${escHtml(canonicalUrl)}">` : ''
693
701
  const resolvedSpec = { ...spec, meta: resolveMeta(spec.meta, ctx) }
694
702
  const resolvedExtraBody = typeof extraBody === 'function' ? extraBody(nonce) : extraBody
@@ -720,7 +728,7 @@ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = f
720
728
  * Stream the response — shell first, deferred segments follow.
721
729
  * On a page-cache hit, serves the buffered HTML as a string (no streaming needed).
722
730
  */
723
- async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalUrl = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}) {
731
+ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}) {
724
732
  // Serve from in-process page cache when available — skip streaming overhead.
725
733
  // Pages with spec.server or spec.store embed a nonce'd __PULSE_SERVER__ script so are never cached.
726
734
  const cacheKey = spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
@@ -748,6 +756,13 @@ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = f
748
756
  // Write the document opening immediately so the browser starts parsing
749
757
  const meta = resolveMeta(spec.meta, ctx)
750
758
  const title = meta.title || 'Pulse'
759
+ // Resolve canonical — supports a string or a function receiving (ctx).
760
+ // Note: server fetcher results are not yet available when the <head> is written in streaming mode.
761
+ // If your canonical depends on server data, use stream: false on that spec, or set it as a string.
762
+ const canonicalRaw = spec.meta?.canonical
763
+ const canonicalUrl = typeof canonicalRaw === 'function'
764
+ ? (canonicalRaw(ctx) || canonicalBase)
765
+ : (canonicalRaw || canonicalBase)
751
766
 
752
767
  const stylePreloads = (meta.styles || [])
753
768
  .map(href => ` <link rel="preload" as="style" href="${escHtml(href)}">`)