@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.
- package/.claude/settings.local.json +12 -1
- package/.github/workflows/publish.yml +11 -19
- 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 +6 -2
- package/src/agent/guide-routing.md +20 -0
- package/src/agent/guide-spec.md +9 -1
- package/src/cli/scaffold.js +63 -2
- package/src/server/index.js +21 -6
- package/src/server/server.test.js +47 -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
|
@@ -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
|
-
|
|
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,12 +23,12 @@ jobs:
|
|
|
31
23
|
|
|
32
24
|
- run: npm test
|
|
33
25
|
|
|
34
|
-
- name:
|
|
26
|
+
- name: Publish if version is new
|
|
35
27
|
run: |
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@invisibleloop/pulse",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
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.
|
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:
|
package/src/server/index.js
CHANGED
|
@@ -541,14 +541,16 @@ export function createServer(specs, options = {}) {
|
|
|
541
541
|
}
|
|
542
542
|
}
|
|
543
543
|
|
|
544
|
-
//
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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)}">`)
|