@invisibleloop/pulse 0.1.28 → 0.1.30
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/.github/workflows/publish.yml +11 -20
- package/README.md +1 -1
- package/docs/public/.pulse-ui-version +1 -1
- package/docs/public/docs.css +19 -1
- package/docs/public/pulse-ui.css +1 -0
- package/docs/server.js +5 -2
- package/docs/src/lib/highlight.js +57 -13
- package/docs/src/lib/layout.js +5 -2
- package/docs/src/pages/faq.js +5 -2
- package/docs/src/pages/home.js +9 -5
- package/docs/src/pages/meta.js +21 -0
- package/docs/src/pages/routing.js +12 -1
- package/package.json +1 -1
- package/public/pulse-ui.css +2 -2
- package/src/agent/guide-components.md +1 -1
- package/src/agent/guide-routing.md +20 -0
- package/src/agent/guide-spec.md +10 -1
- package/src/agent/guide-styles.md +16 -1
- package/src/agent/workflow.md +1 -1
- package/src/cli/scaffold.js +63 -2
- package/src/mcp/server.js +34 -18
- package/src/server/index.js +26 -7
- package/src/server/server.test.js +47 -0
- package/src/ui/stat.js +1 -1
- package/src/ui/ui.test.js +6 -0
- package/docs/public/dist/accessibility.boot-5DVTARJU.js +0 -115
- package/docs/public/dist/actions.boot-P66HKQEM.js +0 -164
- package/docs/public/dist/auth.boot-IMAJAUPH.js +0 -140
- package/docs/public/dist/caching.boot-DVR6KDE7.js +0 -53
- package/docs/public/dist/components--accordion.boot-3HVKMNWC.js +0 -11
- package/docs/public/dist/components--alert.boot-GCEXOZAC.js +0 -6
- package/docs/public/dist/components--app-badge.boot-DVT3GCHJ.js +0 -6
- package/docs/public/dist/components--avatar.boot-PSW24EVA.js +0 -5
- package/docs/public/dist/components--badge.boot-TYDY2RMK.js +0 -7
- package/docs/public/dist/components--banner.boot-EI5PZSZK.js +0 -7
- package/docs/public/dist/components--breadcrumbs.boot-SMA2E2GO.js +0 -34
- package/docs/public/dist/components--button.boot-J54BQM2E.js +0 -23
- package/docs/public/dist/components--card.boot-PZGNDIB6.js +0 -138
- package/docs/public/dist/components--carousel.boot-TP6LPFZZ.js +0 -12
- package/docs/public/dist/components--charts.boot-2EOYQWKL.js +0 -108
- package/docs/public/dist/components--checkbox.boot-DS5BSL6T.js +0 -54
- package/docs/public/dist/components--cluster.boot-HHVIBBJG.js +0 -9
- package/docs/public/dist/components--code-window.boot-2GR2DV33.js +0 -20
- package/docs/public/dist/components--container.boot-7LOOGK2K.js +0 -5
- package/docs/public/dist/components--cta.boot-FSNZ5YRT.js +0 -11
- package/docs/public/dist/components--divider.boot-3NI2C3QG.js +0 -6
- package/docs/public/dist/components--empty.boot-YX2UR3PV.js +0 -7
- package/docs/public/dist/components--feature.boot-MUD7NSUO.js +0 -13
- package/docs/public/dist/components--fieldset.boot-J7BYHMKF.js +0 -19
- package/docs/public/dist/components--fileupload.boot-NIKVTTPD.js +0 -52
- package/docs/public/dist/components--footer.boot-EYUK5FRG.js +0 -14
- package/docs/public/dist/components--grid.boot-URDQVDDR.js +0 -59
- package/docs/public/dist/components--heading.boot-BPQKU43E.js +0 -44
- package/docs/public/dist/components--hero.boot-4RAPRGAB.js +0 -17
- package/docs/public/dist/components--icons.boot-ZITNU5JP.js +0 -68
- package/docs/public/dist/components--image.boot-XEEGHQZF.js +0 -19
- package/docs/public/dist/components--input.boot-SGASZG5K.js +0 -7
- package/docs/public/dist/components--list.boot-W3XC5MHD.js +0 -55
- package/docs/public/dist/components--media.boot-5VFIETZO.js +0 -13
- package/docs/public/dist/components--modal.boot-RZUYXBN2.js +0 -47
- package/docs/public/dist/components--nav.boot-ODBOHU7O.js +0 -33
- package/docs/public/dist/components--pricing.boot-4AQ4ZVBY.js +0 -21
- package/docs/public/dist/components--progress.boot-GHAGYZOK.js +0 -30
- package/docs/public/dist/components--prose.boot-QANJL6JI.js +0 -67
- package/docs/public/dist/components--pullquote.boot-Q2WMNAZU.js +0 -22
- package/docs/public/dist/components--radio.boot-TJRDQ2OL.js +0 -75
- package/docs/public/dist/components--rating.boot-QBAN6DEL.js +0 -38
- package/docs/public/dist/components--search.boot-PXH5O5AG.js +0 -17
- package/docs/public/dist/components--section.boot-AQGIYHWW.js +0 -12
- package/docs/public/dist/components--segmented.boot-BEVTKEJO.js +0 -33
- package/docs/public/dist/components--select.boot-47X5RHOC.js +0 -10
- package/docs/public/dist/components--slider.boot-PSRRX7XL.js +0 -47
- package/docs/public/dist/components--spinner.boot-MZ5MO2OH.js +0 -22
- package/docs/public/dist/components--stack.boot-DI4NJXBF.js +0 -9
- package/docs/public/dist/components--stat.boot-QMFUWBQT.js +0 -9
- package/docs/public/dist/components--stepper.boot-34PP2NEV.js +0 -22
- package/docs/public/dist/components--table.boot-FCQGSFIQ.js +0 -11
- package/docs/public/dist/components--testimonial.boot-DWQPDKYG.js +0 -11
- package/docs/public/dist/components--textarea.boot-QVXLBOJ5.js +0 -4
- package/docs/public/dist/components--timeline.boot-26LN52P2.js +0 -95
- package/docs/public/dist/components--toggle.boot-IQQEI76S.js +0 -29
- package/docs/public/dist/components--tooltip.boot-LGHCO6NN.js +0 -9
- package/docs/public/dist/components.boot-SE6PQ4P7.js +0 -103
- package/docs/public/dist/config.boot-DTRRWUE6.js +0 -126
- package/docs/public/dist/constraints.boot-DUHDZBMC.js +0 -71
- package/docs/public/dist/deploy.boot-SLAD3NI2.js +0 -163
- package/docs/public/dist/docs-8e3d4b5c.css +0 -1
- package/docs/public/dist/extending.boot-UA3CN243.js +0 -159
- package/docs/public/dist/faq.boot-6EQAWLQR.js +0 -43
- package/docs/public/dist/getting-started.boot-TDKIFL5U.js +0 -86
- package/docs/public/dist/guard.boot-AUHAWTG4.js +0 -80
- package/docs/public/dist/home.boot-BVQXRH32.js +0 -383
- package/docs/public/dist/how-it-works.boot-LTWAKWKW.js +0 -104
- package/docs/public/dist/hydration.boot-JRM6IPJL.js +0 -78
- package/docs/public/dist/images.boot-M6ZVKTZS.js +0 -80
- package/docs/public/dist/manifest.json +0 -94
- package/docs/public/dist/meta.boot-7NXGPHR4.js +0 -79
- package/docs/public/dist/mutations.boot-F6F43UDX.js +0 -79
- package/docs/public/dist/navigation.boot-AOXWS3ZF.js +0 -57
- package/docs/public/dist/performance.boot-C3UPCOBK.js +0 -98
- package/docs/public/dist/persist.boot-WT32PQOQ.js +0 -61
- package/docs/public/dist/project-structure.boot-FB3LRVJ4.js +0 -63
- package/docs/public/dist/prompt-examples.boot-YKR4VDK4.js +0 -31
- package/docs/public/dist/pulse-ui-81a85c03.css +0 -1
- package/docs/public/dist/raw-responses.boot-M4KA5YXL.js +0 -104
- package/docs/public/dist/routing.boot-FNX5FDGH.js +0 -70
- package/docs/public/dist/runtime-B73WLANC.js +0 -1
- package/docs/public/dist/runtime-KO4BHUQ3.js +0 -49
- package/docs/public/dist/runtime-L2HNXIHW.js +0 -59
- package/docs/public/dist/runtime-QFURDKA2.js +0 -5
- package/docs/public/dist/runtime-UVPXO4IR.js +0 -375
- package/docs/public/dist/runtime-VMJA3Z4N.js +0 -10
- package/docs/public/dist/runtime-ZJ4FXT5O.js +0 -11
- package/docs/public/dist/server-api.boot-K7X3LCFB.js +0 -219
- package/docs/public/dist/server-data.boot-Y7HQYC4R.js +0 -157
- package/docs/public/dist/slash-commands.boot-V2UV7OW2.js +0 -26
- package/docs/public/dist/spec.boot-2WU7ZHCV.js +0 -159
- package/docs/public/dist/state.boot-B24GUE3R.js +0 -73
- package/docs/public/dist/store.boot-TLIB4XHH.js +0 -150
- package/docs/public/dist/streaming.boot-W2DZSMW4.js +0 -80
- package/docs/public/dist/stripe.boot-QN3C2GEL.js +0 -164
- package/docs/public/dist/supabase.boot-BG4XXLZE.js +0 -303
- package/docs/public/dist/testing.boot-6U4WKMTE.js +0 -130
- package/docs/public/dist/validation.boot-PQHYGW5B.js +0 -100
|
@@ -1,25 +1,17 @@
|
|
|
1
1
|
name: Release
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
|
-
|
|
5
|
-
|
|
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:
|
|
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,14 +23,13 @@ jobs:
|
|
|
31
23
|
|
|
32
24
|
- run: npm test
|
|
33
25
|
|
|
34
|
-
- name: Bump version
|
|
35
|
-
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
|
|
26
|
+
- name: Bump patch version if already published, then publish
|
|
43
27
|
env:
|
|
44
28
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
29
|
+
run: |
|
|
30
|
+
VERSION=$(node -p "require('./package.json').version")
|
|
31
|
+
while npm view @invisibleloop/pulse@$VERSION version 2>/dev/null | grep -q .; do
|
|
32
|
+
VERSION=$(node -e "const [a,b,c]='$VERSION'.split('.').map(Number);console.log(a+'.'+b+'.'+(c+1))")
|
|
33
|
+
done
|
|
34
|
+
npm version $VERSION --no-git-tag-version
|
|
35
|
+
npm publish --provenance --access public
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Pulse
|
|
2
2
|
|
|
3
|
-
**A spec-first, AI-native web framework.** Early access — v0.1.
|
|
3
|
+
**A spec-first, AI-native web framework.** Early access — v0.1.30.
|
|
4
4
|
|
|
5
5
|
Write a plain JavaScript object that describes what a page does. Pulse handles routing, SSR, hydration, client-side navigation, compression, security headers, and caching automatically.
|
|
6
6
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.28
|
package/docs/public/docs.css
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/docs/public/pulse-ui.css
CHANGED
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:
|
|
190
|
-
staticDir:
|
|
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 += '<'
|
|
224
|
+
i++ // skip <
|
|
225
|
+
if (tag[i] === '/') { out += '/'; i++ }
|
|
226
|
+
|
|
227
|
+
// Tag name
|
|
228
|
+
let name = ''
|
|
229
|
+
while (i < tag.length && /[\w-]/.test(tag[i])) name += tag[i++]
|
|
230
|
+
if (name) out += span('tok-kw', name)
|
|
231
|
+
|
|
232
|
+
// Attributes and remainder up to >
|
|
233
|
+
while (i < tag.length) {
|
|
234
|
+
const ch = tag[i]
|
|
235
|
+
if (ch === '>') { out += '>'; i++; break }
|
|
236
|
+
if (ch === '/') { out += '/'; i++; continue }
|
|
237
|
+
|
|
238
|
+
if (/[\w-]/.test(ch)) {
|
|
239
|
+
// Attribute name
|
|
240
|
+
let attr = ''
|
|
241
|
+
while (i < tag.length && /[\w-]/.test(tag[i])) attr += tag[i++]
|
|
242
|
+
|
|
243
|
+
if (tag[i] === '=') {
|
|
244
|
+
out += span('tok-fn', attr) + esc('=')
|
|
245
|
+
i++ // skip =
|
|
246
|
+
// Attribute value
|
|
247
|
+
const q = tag[i]
|
|
248
|
+
if (q === '"' || q === "'") {
|
|
249
|
+
let val = q
|
|
250
|
+
i++
|
|
251
|
+
while (i < tag.length && tag[i] !== q) {
|
|
252
|
+
if (tag[i] === '\\') { val += tag[i] + (tag[i + 1] || ''); i += 2; continue }
|
|
253
|
+
val += tag[i++]
|
|
254
|
+
}
|
|
255
|
+
if (i < tag.length) val += tag[i++] // closing quote
|
|
256
|
+
out += span('tok-str', val)
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
out += esc(attr)
|
|
260
|
+
}
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
out += esc(ch)
|
|
265
|
+
i++
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return out
|
|
269
|
+
}
|
|
270
|
+
|
|
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
|
-
|
|
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
|
}
|
package/docs/src/lib/layout.js
CHANGED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
@@ -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">
|
|
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>
|
package/docs/src/pages/faq.js
CHANGED
|
@@ -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
|
|
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 ~
|
|
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(
|
package/docs/src/pages/home.js
CHANGED
|
@@ -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">
|
|
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
|
|
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>
|
|
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
|
`,
|
package/docs/src/pages/meta.js
CHANGED
|
@@ -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><script type="application/ld+json"></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><link rel="canonical"></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><head></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><link rel="stylesheet"></code> tags in the <code><head></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.
|
|
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
package/public/pulse-ui.css
CHANGED
|
@@ -810,8 +810,8 @@ hr.ui-divider {
|
|
|
810
810
|
}
|
|
811
811
|
|
|
812
812
|
.ui-testimonial-avatar--initials {
|
|
813
|
-
background: var(--ui-
|
|
814
|
-
color: var(--ui-
|
|
813
|
+
background: var(--ui-muted-bg, rgba(128,128,128,.2));
|
|
814
|
+
color: var(--ui-text, var(--text, #e2e2ea));
|
|
815
815
|
font-size: .8rem;
|
|
816
816
|
font-weight: 700;
|
|
817
817
|
display: flex;
|
|
@@ -151,7 +151,7 @@ Use `prose()` for HTML from external sources (CMS, markdown). Use `heading()`/`l
|
|
|
151
151
|
| `feature` | `icon` (HTML slot), `title`, `level` (1–6, default 3), `description`, `center` |
|
|
152
152
|
| `testimonial` | `quote`, `name`, `role`, `src`, `rating` (1–5) |
|
|
153
153
|
| `pricing` | `name`, `level` (1–6, default 3), `price`, `period`, `features` ([strings]), `action` (HTML slot), `highlighted` |
|
|
154
|
-
| `accordion` | `items` ([{
|
|
154
|
+
| `accordion` | `items` ([{question,answer}]) — native `<details>`, no JS |
|
|
155
155
|
| `appBadge` | `store` (apple/google), `href` |
|
|
156
156
|
| `cta` | `eyebrow`, `title`, `level` (1–6, default 2), `subtitle`, `actions` (HTML slot), `align` |
|
|
157
157
|
|
|
@@ -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.
|
package/src/agent/guide-spec.md
CHANGED
|
@@ -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: {
|
|
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.
|
|
@@ -67,6 +75,7 @@ export default {
|
|
|
67
75
|
## Key rules
|
|
68
76
|
|
|
69
77
|
- view() returns an HTML string using template literals
|
|
78
|
+
- **Component props are auto-escaped — pass literal strings, not HTML entities.** Use `'© 2026 Acme'` not `'© 2026 Acme'`. Use `'Pricing & Plans'` not `'Pricing & Plans'`. Passing HTML entities to component props double-escapes them (`&amp;`, `&copy;`) and shows raw entity text to the user.
|
|
70
79
|
- data-event="mutationName" on buttons/elements — passes the DOM event to the mutation
|
|
71
80
|
- data-event="change:mutationName" to fire on input change
|
|
72
81
|
- data-action="actionName" on <form> elements only — submits pass FormData to the action
|
|
@@ -73,7 +73,20 @@ All components use `--ui-font` (body) and `--ui-mono` (code). These resolve from
|
|
|
73
73
|
|
|
74
74
|
### Google Fonts
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
**Google Fonts requires CSP configuration** — the default CSP blocks external font and stylesheet sources. You must pass `csp` to `createServer`, otherwise the font will be blocked and the page will fail Lighthouse Best Practices:
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
// server.js
|
|
80
|
+
createServer(specs, {
|
|
81
|
+
port: 3000,
|
|
82
|
+
csp: {
|
|
83
|
+
'style-src': ['https://fonts.googleapis.com'],
|
|
84
|
+
'font-src': ['https://fonts.gstatic.com'],
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Then add the URL **before** `pulse-ui.css` in `meta.styles`. Use `family=Name:wght@weights` and always include `&display=swap`.
|
|
77
90
|
|
|
78
91
|
```js
|
|
79
92
|
meta: {
|
|
@@ -90,6 +103,8 @@ Then in app.css:
|
|
|
90
103
|
:root { --font: 'Inter', system-ui, sans-serif; }
|
|
91
104
|
```
|
|
92
105
|
|
|
106
|
+
If you cannot modify `createServer` (e.g. the project uses auto-discovery), use a self-hosted font in `public/fonts/` with `@font-face` in `app.css` — no CSP changes needed.
|
|
107
|
+
|
|
93
108
|
For multiple weights or italic variants, separate them with a semicolon in the URL:
|
|
94
109
|
```
|
|
95
110
|
?family=Inter:ital,wght@0,400;0,700;1,400&display=swap
|
package/src/agent/workflow.md
CHANGED
|
@@ -33,7 +33,7 @@ Wait for the user to confirm or adjust the plan before writing any code.
|
|
|
33
33
|
|
|
34
34
|
## Phase 3 — Build
|
|
35
35
|
|
|
36
|
-
Write the spec and any related files
|
|
36
|
+
Write the spec and any related files using the **Write tool** — not `pulse_create_page`. This shows the user a readable diff of the file content. After writing, call `pulse_create_page(name)` to validate the file you just wrote. After each file is written, output a one-line status: `✓ Page written — validating...`
|
|
37
37
|
|
|
38
38
|
---
|
|
39
39
|
|
package/src/cli/scaffold.js
CHANGED
|
@@ -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',
|
|
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:
|