@invisibleloop/pulse 0.2.2 → 0.2.4
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/README.md +1 -1
- package/docs/public/.pulse-ui-version +1 -1
- package/docs/public/docs.css +4 -2
- package/docs/public/img/placeholder-landscape.svg +18 -0
- package/docs/public/img/placeholder-square.svg +14 -0
- package/docs/public/img/placeholder-tall.svg +22 -0
- package/docs/public/pulse-ui.css +18 -1
- package/docs/server.js +3 -0
- package/docs/src/pages/components/hero.js +65 -9
- package/docs/src/pages/components/image.js +3 -3
- package/docs/src/pages/home.js +1 -1
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/public/pulse-ui.css +18 -1
- package/scripts/build.js +37 -0
- package/scripts/strip-server.test.js +40 -0
- package/src/runtime/navigate.js +18 -2
- package/src/server/index.js +14 -3
- package/src/ui/hero.js +41 -17
- package/src/ui/uiimage.js +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Pulse
|
|
2
2
|
|
|
3
|
-
**A spec-first, AI-native web framework.** Early access
|
|
3
|
+
**A spec-first, AI-native web framework.** Early access.
|
|
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
|
+
0.2.2
|
package/docs/public/docs.css
CHANGED
|
@@ -1157,8 +1157,10 @@ a:hover {
|
|
|
1157
1157
|
}
|
|
1158
1158
|
|
|
1159
1159
|
.demo-preview--col .demo-preview-inner {
|
|
1160
|
-
|
|
1161
|
-
|
|
1160
|
+
display: block;
|
|
1161
|
+
}
|
|
1162
|
+
.demo-preview--col .demo-preview-inner > * + * {
|
|
1163
|
+
margin-top: 0.75rem;
|
|
1162
1164
|
}
|
|
1163
1165
|
|
|
1164
1166
|
.demo-preview--scroll .demo-preview-inner {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 450" role="img" aria-label="Placeholder image">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
|
4
|
+
<stop offset="0%" stop-color="#0d1b2e"/>
|
|
5
|
+
<stop offset="100%" stop-color="#1a3a5c"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="ground" x1="0" y1="0" x2="0" y2="1">
|
|
8
|
+
<stop offset="0%" stop-color="#1a2e1a"/>
|
|
9
|
+
<stop offset="100%" stop-color="#0d1a0d"/>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
</defs>
|
|
12
|
+
<rect width="800" height="450" fill="url(#sky)"/>
|
|
13
|
+
<circle cx="640" cy="100" r="55" fill="#f5c542" opacity=".15"/>
|
|
14
|
+
<circle cx="640" cy="100" r="40" fill="#f5c542" opacity=".2"/>
|
|
15
|
+
<polygon points="0,300 120,180 240,260 320,160 440,240 560,140 680,210 800,170 800,450 0,450" fill="url(#ground)"/>
|
|
16
|
+
<polygon points="0,310 100,270 200,300 320,260 440,290 560,255 680,280 800,260 800,450 0,450" fill="#0f200f" opacity=".7"/>
|
|
17
|
+
<rect width="800" height="450" fill="#1a1a2e" opacity=".1"/>
|
|
18
|
+
</svg>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img" aria-label="Placeholder image">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
|
4
|
+
<stop offset="0%" stop-color="#1a1a2e"/>
|
|
5
|
+
<stop offset="100%" stop-color="#0d0d1a"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect width="400" height="400" fill="url(#bg)"/>
|
|
9
|
+
<circle cx="200" cy="155" r="72" fill="#2a2a3e"/>
|
|
10
|
+
<circle cx="200" cy="155" r="58" fill="#3a3a50"/>
|
|
11
|
+
<circle cx="200" cy="140" r="35" fill="#4a4a62"/>
|
|
12
|
+
<ellipse cx="200" cy="320" rx="110" ry="70" fill="#2a2a3e"/>
|
|
13
|
+
<ellipse cx="200" cy="310" rx="90" ry="58" fill="#3a3a50"/>
|
|
14
|
+
</svg>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" role="img" aria-label="Placeholder image">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="sea" x1="0" y1="0" x2="0" y2="1">
|
|
4
|
+
<stop offset="0%" stop-color="#0d2a4a"/>
|
|
5
|
+
<stop offset="100%" stop-color="#051520"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="sky2" x1="0" y1="0" x2="0" y2="1">
|
|
8
|
+
<stop offset="0%" stop-color="#0d1b2e"/>
|
|
9
|
+
<stop offset="100%" stop-color="#1a3a5c"/>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
</defs>
|
|
12
|
+
<rect width="800" height="600" fill="url(#sky2)"/>
|
|
13
|
+
<rect x="0" y="320" width="800" height="280" fill="url(#sea)"/>
|
|
14
|
+
<rect x="0" y="310" width="800" height="20" fill="#0a2a3a" opacity=".6"/>
|
|
15
|
+
<rect x="120" y="200" width="60" height="120" fill="#1e2e3e"/>
|
|
16
|
+
<rect x="200" y="170" width="80" height="150" fill="#1a2a3a"/>
|
|
17
|
+
<rect x="300" y="210" width="50" height="110" fill="#1e2e3e"/>
|
|
18
|
+
<rect x="380" y="185" width="90" height="135" fill="#1a2a3a"/>
|
|
19
|
+
<rect x="490" y="220" width="55" height="100" fill="#1e2e3e"/>
|
|
20
|
+
<rect x="570" y="195" width="70" height="125" fill="#1a2a3a"/>
|
|
21
|
+
<path d="M0,320 Q200,300 400,315 Q600,330 800,310 L800,340 Q600,355 400,340 Q200,325 0,345Z" fill="#0d2535" opacity=".5"/>
|
|
22
|
+
</svg>
|
package/docs/public/pulse-ui.css
CHANGED
|
@@ -774,6 +774,21 @@ hr.ui-divider {
|
|
|
774
774
|
}
|
|
775
775
|
.ui-hero--left .ui-hero-actions { justify-content: flex-start; }
|
|
776
776
|
|
|
777
|
+
/* Split (text + image) */
|
|
778
|
+
.ui-hero--split { text-align: left; }
|
|
779
|
+
.ui-hero--split .ui-hero-inner {
|
|
780
|
+
max-width: 1200px;
|
|
781
|
+
margin: 0 auto;
|
|
782
|
+
display: grid;
|
|
783
|
+
grid-template-columns: 1fr 1fr;
|
|
784
|
+
gap: 3rem;
|
|
785
|
+
align-items: center;
|
|
786
|
+
}
|
|
787
|
+
.ui-hero--split .ui-hero-actions { justify-content: flex-start; }
|
|
788
|
+
.ui-hero--split .ui-hero-media { display: flex; align-items: center; justify-content: center; }
|
|
789
|
+
.ui-hero--split .ui-hero-media img { width: 100%; height: auto; border-radius: var(--ui-radius, 8px); display: block; }
|
|
790
|
+
.ui-hero--media-left .ui-hero-media { order: -1; }
|
|
791
|
+
|
|
777
792
|
/* ─── Testimonial ────────────────────────────────────────────────────────── */
|
|
778
793
|
.ui-testimonial {
|
|
779
794
|
background: var(--ui-surface, var(--surface, #111116));
|
|
@@ -1313,6 +1328,8 @@ hr.ui-divider {
|
|
|
1313
1328
|
.ui-hero { padding: 3rem 1.25rem; }
|
|
1314
1329
|
.ui-hero-title { font-size: 2rem; }
|
|
1315
1330
|
.ui-hero-subtitle { font-size: 1rem; }
|
|
1331
|
+
.ui-hero--split .ui-hero-inner { grid-template-columns: 1fr; }
|
|
1332
|
+
.ui-hero--media-left .ui-hero-media { order: 0; }
|
|
1316
1333
|
|
|
1317
1334
|
.ui-nav-links { display: none; }
|
|
1318
1335
|
.ui-nav-burger { display: flex; }
|
|
@@ -2529,7 +2546,7 @@ fieldset[disabled] .ui-rating-stars .ui-rating-star:hover ~ .ui-rating-star { co
|
|
|
2529
2546
|
.ui-image-img--cover {
|
|
2530
2547
|
display: block;
|
|
2531
2548
|
width: 100%;
|
|
2532
|
-
height:
|
|
2549
|
+
height: 100%;
|
|
2533
2550
|
object-fit: cover;
|
|
2534
2551
|
}
|
|
2535
2552
|
.ui-image-img {
|
package/docs/server.js
CHANGED
|
@@ -191,5 +191,8 @@ createServer(
|
|
|
191
191
|
port: process.env.PORT ? Number(process.env.PORT) : 4000,
|
|
192
192
|
staticDir: new URL('./public', import.meta.url).pathname,
|
|
193
193
|
defaultCache: true,
|
|
194
|
+
csp: {
|
|
195
|
+
'img-src': ['https://picsum.photos', 'https://images.unsplash.com'],
|
|
196
|
+
},
|
|
194
197
|
}
|
|
195
198
|
)
|
|
@@ -13,12 +13,30 @@ export default {
|
|
|
13
13
|
styles: ['/pulse-ui.css', '/docs.css'],
|
|
14
14
|
},
|
|
15
15
|
state: {},
|
|
16
|
-
view: () =>
|
|
16
|
+
view: () => {
|
|
17
|
+
const placeholderImg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 400" style="width:100%;height:auto;border-radius:var(--ui-radius,8px);display:block" role="img" aria-label="Placeholder image">
|
|
18
|
+
<rect width="600" height="400" fill="#111116" rx="8"/>
|
|
19
|
+
<rect x="24" y="24" width="552" height="32" rx="6" fill="#1a1a22"/>
|
|
20
|
+
<circle cx="44" cy="40" r="6" fill="#ff5f57"/><circle cx="64" cy="40" r="6" fill="#febc2e"/><circle cx="84" cy="40" r="6" fill="#28c840"/>
|
|
21
|
+
<rect x="24" y="72" width="552" height="200" rx="6" fill="#1a1a22"/>
|
|
22
|
+
<rect x="40" y="92" width="200" height="12" rx="3" fill="#2a2a35"/>
|
|
23
|
+
<rect x="40" y="116" width="300" height="8" rx="3" fill="#222228"/>
|
|
24
|
+
<rect x="40" y="132" width="260" height="8" rx="3" fill="#222228"/>
|
|
25
|
+
<rect x="40" y="148" width="280" height="8" rx="3" fill="#222228"/>
|
|
26
|
+
<rect x="40" y="176" width="120" height="32" rx="6" fill="#9b8dff" opacity=".9"/>
|
|
27
|
+
<rect x="24" y="288" width="264" height="88" rx="6" fill="#1a1a22"/>
|
|
28
|
+
<rect x="312" y="288" width="264" height="88" rx="6" fill="#1a1a22"/>
|
|
29
|
+
<rect x="40" y="304" width="100" height="8" rx="3" fill="#2a2a35"/>
|
|
30
|
+
<rect x="40" y="320" width="140" height="6" rx="3" fill="#222228"/>
|
|
31
|
+
<rect x="328" y="304" width="100" height="8" rx="3" fill="#2a2a35"/>
|
|
32
|
+
<rect x="328" y="320" width="120" height="6" rx="3" fill="#222228"/>
|
|
33
|
+
</svg>`
|
|
34
|
+
return renderComponentPage({
|
|
17
35
|
currentHref: '/components/hero',
|
|
18
36
|
prev,
|
|
19
37
|
next,
|
|
20
38
|
name: 'hero',
|
|
21
|
-
description: 'Full-width hero section.
|
|
39
|
+
description: 'Full-width hero section. Pass <code>image</code> HTML to activate the split layout — text on one side, image on the other. Use <code>imageAlign</code> to swap sides. Set <code>align: \'left\'</code> for left-aligned text without an image. Use <code>size: \'sm\'</code> for inner-page headers.',
|
|
22
40
|
content: `
|
|
23
41
|
${demo(
|
|
24
42
|
hero({
|
|
@@ -36,6 +54,41 @@ export default {
|
|
|
36
54
|
})`
|
|
37
55
|
)}
|
|
38
56
|
|
|
57
|
+
${demo(
|
|
58
|
+
hero({
|
|
59
|
+
eyebrow: 'Split layout',
|
|
60
|
+
title: 'Text left, image right',
|
|
61
|
+
subtitle: 'Pass an image slot to activate the split layout. The text sits on the left, image on the right.',
|
|
62
|
+
actions: appBadge({ store: 'apple', href: '#' }) + ' ' + appBadge({ store: 'google', href: '#' }),
|
|
63
|
+
image: placeholderImg,
|
|
64
|
+
}),
|
|
65
|
+
`hero({
|
|
66
|
+
eyebrow: 'Split layout',
|
|
67
|
+
title: 'Text left, image right',
|
|
68
|
+
subtitle: 'Pass an image slot to activate the split layout.',
|
|
69
|
+
actions: appBadge({ store: 'apple', href: appStoreUrl }) +
|
|
70
|
+
appBadge({ store: 'google', href: playStoreUrl }),
|
|
71
|
+
image: \`<img src="\${screenshotUrl}" alt="Product screenshot">\`,
|
|
72
|
+
})`
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
${demo(
|
|
76
|
+
hero({
|
|
77
|
+
eyebrow: 'Split layout',
|
|
78
|
+
title: 'Image left, text right',
|
|
79
|
+
subtitle: 'Set imageAlign: \'left\' to put the image on the left and text on the right.',
|
|
80
|
+
image: placeholderImg,
|
|
81
|
+
imageAlign: 'left',
|
|
82
|
+
}),
|
|
83
|
+
`hero({
|
|
84
|
+
eyebrow: 'Split layout',
|
|
85
|
+
title: 'Image left, text right',
|
|
86
|
+
subtitle: 'Set imageAlign to swap sides.',
|
|
87
|
+
image: \`<img src="\${screenshotUrl}" alt="Product screenshot">\`,
|
|
88
|
+
imageAlign: 'left',
|
|
89
|
+
})`
|
|
90
|
+
)}
|
|
91
|
+
|
|
39
92
|
${demo(
|
|
40
93
|
hero({
|
|
41
94
|
title: 'Blog',
|
|
@@ -52,14 +105,17 @@ export default {
|
|
|
52
105
|
${table(
|
|
53
106
|
['Prop', 'Type', 'Default', 'Description'],
|
|
54
107
|
[
|
|
55
|
-
['<code>eyebrow</code>',
|
|
56
|
-
['<code>title</code>',
|
|
57
|
-
['<code>subtitle</code>',
|
|
58
|
-
['<code>actions</code>',
|
|
59
|
-
['<code>
|
|
60
|
-
['<code>
|
|
108
|
+
['<code>eyebrow</code>', 'string', '—', 'Small label above the title'],
|
|
109
|
+
['<code>title</code>', 'string', '—', ''],
|
|
110
|
+
['<code>subtitle</code>', 'string', '—', ''],
|
|
111
|
+
['<code>actions</code>', 'string (HTML)', '—', 'Raw HTML slot — buttons, badges, etc.'],
|
|
112
|
+
['<code>image</code>', 'string (HTML)', '—', 'Raw HTML slot — activates split layout when set'],
|
|
113
|
+
['<code>imageAlign</code>', 'string', "'right'", "'right' (text left) or 'left' (text right) — only applies when image is set"],
|
|
114
|
+
['<code>align</code>', 'string', "'center'", "'center' or 'left' — text alignment when no image"],
|
|
115
|
+
['<code>size</code>', 'string', "'md'", "'md' (5rem padding) or 'sm' (2.5rem top, no bottom) — use sm for inner-page headers"],
|
|
61
116
|
]
|
|
62
117
|
)}
|
|
63
118
|
`,
|
|
64
|
-
})
|
|
119
|
+
})
|
|
120
|
+
},
|
|
65
121
|
}
|
|
@@ -24,14 +24,14 @@ export default {
|
|
|
24
24
|
<h2 class="doc-h2" id="ratio">With aspect ratio</h2>
|
|
25
25
|
<p>Set <code>ratio</code> to constrain the image to a fixed aspect ratio. The image fills the crop area with <code>object-fit: cover</code>.</p>
|
|
26
26
|
${demo(
|
|
27
|
-
uiImage({ src: '
|
|
27
|
+
uiImage({ src: '/img/placeholder-landscape.svg', alt: 'Mountain landscape at dusk', ratio: '16/9' }),
|
|
28
28
|
`uiImage({ src: '/img/photo.jpg', alt: 'Mountain landscape at dusk', ratio: '16/9' })`,
|
|
29
29
|
{ col: true }
|
|
30
30
|
)}
|
|
31
31
|
|
|
32
32
|
<h2 class="doc-h2" id="rounded">Square and rounded</h2>
|
|
33
33
|
${demo(
|
|
34
|
-
`<div style="max-width:200px;margin:0 auto">${uiImage({ src: '
|
|
34
|
+
`<div style="max-width:200px;margin:0 auto">${uiImage({ src: '/img/placeholder-square.svg', alt: 'Profile photo', ratio: '1/1', rounded: true })}</div>`,
|
|
35
35
|
`uiImage({ src: '/img/avatar.jpg', alt: 'Profile photo', ratio: '1/1', rounded: true })`,
|
|
36
36
|
{ col: true }
|
|
37
37
|
)}
|
|
@@ -39,7 +39,7 @@ export default {
|
|
|
39
39
|
<h2 class="doc-h2" id="caption">With caption</h2>
|
|
40
40
|
${demo(
|
|
41
41
|
uiImage({
|
|
42
|
-
src: '
|
|
42
|
+
src: '/img/placeholder-tall.svg',
|
|
43
43
|
alt: 'Aerial view of a coastal town',
|
|
44
44
|
ratio: '4/3',
|
|
45
45
|
caption: 'Aerial view of Porto, Portugal. Photo by João Silva.',
|
package/docs/src/pages/home.js
CHANGED
|
@@ -69,7 +69,7 @@ export default {
|
|
|
69
69
|
<div class="hero-badge">v${version} — EARLY ACCESS</div>
|
|
70
70
|
<p class="hero-kicker">A Node.js framework for building server-rendered web apps</p>
|
|
71
71
|
<h1 class="hero-title">Describe the outcome. Pulse guarantees it.</h1>
|
|
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
|
|
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.<br><strong>Designed for AI agents.</strong></p>
|
|
73
73
|
<div class="hero-ctas">
|
|
74
74
|
<a href="/getting-started" class="btn-primary">Get Started</a>
|
|
75
75
|
<a href="/spec" class="btn-secondary">Read the Spec</a>
|
package/package.json
CHANGED
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.2.
|
|
1
|
+
0.2.4
|
package/public/pulse-ui.css
CHANGED
|
@@ -774,6 +774,21 @@ hr.ui-divider {
|
|
|
774
774
|
}
|
|
775
775
|
.ui-hero--left .ui-hero-actions { justify-content: flex-start; }
|
|
776
776
|
|
|
777
|
+
/* Split (text + image) */
|
|
778
|
+
.ui-hero--split { text-align: left; }
|
|
779
|
+
.ui-hero--split .ui-hero-inner {
|
|
780
|
+
max-width: 1200px;
|
|
781
|
+
margin: 0 auto;
|
|
782
|
+
display: grid;
|
|
783
|
+
grid-template-columns: 1fr 1fr;
|
|
784
|
+
gap: 3rem;
|
|
785
|
+
align-items: center;
|
|
786
|
+
}
|
|
787
|
+
.ui-hero--split .ui-hero-actions { justify-content: flex-start; }
|
|
788
|
+
.ui-hero--split .ui-hero-media { display: flex; align-items: center; justify-content: center; }
|
|
789
|
+
.ui-hero--split .ui-hero-media img { width: 100%; height: auto; border-radius: var(--ui-radius, 8px); display: block; }
|
|
790
|
+
.ui-hero--media-left .ui-hero-media { order: -1; }
|
|
791
|
+
|
|
777
792
|
/* ─── Testimonial ────────────────────────────────────────────────────────── */
|
|
778
793
|
.ui-testimonial {
|
|
779
794
|
background: var(--ui-surface, var(--surface, #111116));
|
|
@@ -1313,6 +1328,8 @@ hr.ui-divider {
|
|
|
1313
1328
|
.ui-hero { padding: 3rem 1.25rem; }
|
|
1314
1329
|
.ui-hero-title { font-size: 2rem; }
|
|
1315
1330
|
.ui-hero-subtitle { font-size: 1rem; }
|
|
1331
|
+
.ui-hero--split .ui-hero-inner { grid-template-columns: 1fr; }
|
|
1332
|
+
.ui-hero--media-left .ui-hero-media { order: 0; }
|
|
1316
1333
|
|
|
1317
1334
|
.ui-nav-links { display: none; }
|
|
1318
1335
|
.ui-nav-burger { display: flex; }
|
|
@@ -2529,7 +2546,7 @@ fieldset[disabled] .ui-rating-stars .ui-rating-star:hover ~ .ui-rating-star { co
|
|
|
2529
2546
|
.ui-image-img--cover {
|
|
2530
2547
|
display: block;
|
|
2531
2548
|
width: 100%;
|
|
2532
|
-
height:
|
|
2549
|
+
height: 100%;
|
|
2533
2550
|
object-fit: cover;
|
|
2534
2551
|
}
|
|
2535
2552
|
.ui-image-img {
|
package/scripts/build.js
CHANGED
|
@@ -51,11 +51,48 @@ function stripServerOnlyKeys(source) {
|
|
|
51
51
|
* from a JS source string. Uses a character-level scanner to correctly handle
|
|
52
52
|
* nested structures, string literals, template literals, and function expressions.
|
|
53
53
|
*/
|
|
54
|
+
/**
|
|
55
|
+
* Returns true if `pos` in `source` falls inside a string or template literal.
|
|
56
|
+
* Used to avoid stripping keys that appear inside code-example strings in docs pages.
|
|
57
|
+
*/
|
|
58
|
+
function isInsideString(source, pos) {
|
|
59
|
+
let i = 0
|
|
60
|
+
const stack = [] // stack of open delimiters: '"' | "'" | '`' | '{' ('{' = template expression)
|
|
61
|
+
while (i < pos) {
|
|
62
|
+
const c = source[i]
|
|
63
|
+
const top = stack[stack.length - 1]
|
|
64
|
+
if (top === '"' || top === "'") {
|
|
65
|
+
if (c === '\\') { i += 2; continue }
|
|
66
|
+
if (c === top) { stack.pop() }
|
|
67
|
+
i++; continue
|
|
68
|
+
}
|
|
69
|
+
if (top === '`') {
|
|
70
|
+
if (c === '\\') { i += 2; continue }
|
|
71
|
+
if (c === '`') { stack.pop(); i++; continue }
|
|
72
|
+
if (c === '$' && source[i + 1] === '{') { stack.push('{'); i += 2; continue }
|
|
73
|
+
i++; continue
|
|
74
|
+
}
|
|
75
|
+
if (top === '{') {
|
|
76
|
+
// inside a template expression — track nested braces
|
|
77
|
+
if (c === '{') { stack.push('{'); i++; continue }
|
|
78
|
+
if (c === '}') { stack.pop(); i++; continue }
|
|
79
|
+
if (c === '"' || c === "'" || c === '`') { stack.push(c); i++; continue }
|
|
80
|
+
i++; continue
|
|
81
|
+
}
|
|
82
|
+
// top-level
|
|
83
|
+
if (c === '"' || c === "'" || c === '`') { stack.push(c) }
|
|
84
|
+
i++
|
|
85
|
+
}
|
|
86
|
+
return stack.length > 0
|
|
87
|
+
}
|
|
88
|
+
|
|
54
89
|
function removeObjectKey(source, key) {
|
|
55
90
|
const keyRe = new RegExp(`^([ \\t]*)(${key})([ \\t]*:)`, 'gm')
|
|
56
91
|
let match
|
|
57
92
|
|
|
58
93
|
while ((match = keyRe.exec(source)) !== null) {
|
|
94
|
+
if (isInsideString(source, match.index)) { continue }
|
|
95
|
+
|
|
59
96
|
const removeStart = match.index // start of indentation
|
|
60
97
|
const afterColon = match.index + match[0].length // character after ':'
|
|
61
98
|
|
|
@@ -14,10 +14,40 @@ function stripServerOnlyKeys(source) {
|
|
|
14
14
|
return source
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
function isInsideString(source, pos) {
|
|
18
|
+
let i = 0
|
|
19
|
+
const stack = []
|
|
20
|
+
while (i < pos) {
|
|
21
|
+
const c = source[i]
|
|
22
|
+
const top = stack[stack.length - 1]
|
|
23
|
+
if (top === '"' || top === "'") {
|
|
24
|
+
if (c === '\\') { i += 2; continue }
|
|
25
|
+
if (c === top) { stack.pop() }
|
|
26
|
+
i++; continue
|
|
27
|
+
}
|
|
28
|
+
if (top === '`') {
|
|
29
|
+
if (c === '\\') { i += 2; continue }
|
|
30
|
+
if (c === '`') { stack.pop(); i++; continue }
|
|
31
|
+
if (c === '$' && source[i + 1] === '{') { stack.push('{'); i += 2; continue }
|
|
32
|
+
i++; continue
|
|
33
|
+
}
|
|
34
|
+
if (top === '{') {
|
|
35
|
+
if (c === '{') { stack.push('{'); i++; continue }
|
|
36
|
+
if (c === '}') { stack.pop(); i++; continue }
|
|
37
|
+
if (c === '"' || c === "'" || c === '`') { stack.push(c); i++; continue }
|
|
38
|
+
i++; continue
|
|
39
|
+
}
|
|
40
|
+
if (c === '"' || c === "'" || c === '`') { stack.push(c) }
|
|
41
|
+
i++
|
|
42
|
+
}
|
|
43
|
+
return stack.length > 0
|
|
44
|
+
}
|
|
45
|
+
|
|
17
46
|
function removeObjectKey(source, key) {
|
|
18
47
|
const keyRe = new RegExp(`^([ \\t]*)(${key})([ \\t]*:)`, 'gm')
|
|
19
48
|
let match
|
|
20
49
|
while ((match = keyRe.exec(source)) !== null) {
|
|
50
|
+
if (isInsideString(source, match.index)) { continue }
|
|
21
51
|
const removeStart = match.index
|
|
22
52
|
const afterColon = match.index + match[0].length
|
|
23
53
|
let pos = afterColon
|
|
@@ -181,6 +211,16 @@ test('server with deeply nested template literal in view is not affected',
|
|
|
181
211
|
'export default {\n view: (s, srv) => `<ul>${srv.items.map(i => `<li>${i}</li>`).join(\'\')}</ul>`\n}'
|
|
182
212
|
)
|
|
183
213
|
|
|
214
|
+
test('render: inside a template literal string (docs code example) is not stripped',
|
|
215
|
+
'export default {\n route: \'/auth\',\n view: () => `${highlight(`export default {\n render: (ctx) => ctx.user\n}`)}`\n}',
|
|
216
|
+
'export default {\n route: \'/auth\',\n view: () => `${highlight(`export default {\n render: (ctx) => ctx.user\n}`)}`\n}'
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
test('server: inside a double-quoted string is not stripped',
|
|
220
|
+
'export default {\n route: \'/docs\',\n view: () => "<pre>server: { data: async () => {} }</pre>"\n}',
|
|
221
|
+
'export default {\n route: \'/docs\',\n view: () => "<pre>server: { data: async () => {} }</pre>"\n}'
|
|
222
|
+
)
|
|
223
|
+
|
|
184
224
|
// ---------------------------------------------------------------------------
|
|
185
225
|
|
|
186
226
|
console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`)
|
package/src/runtime/navigate.js
CHANGED
|
@@ -22,10 +22,16 @@ export function initNavigation(root, mountFn) {
|
|
|
22
22
|
// Track the current mount instance so we can destroy it (and its store
|
|
23
23
|
// subscription) before mounting the next page.
|
|
24
24
|
let currentMount = null
|
|
25
|
+
let currentNavController = null
|
|
25
26
|
|
|
26
27
|
async function navigate(path, push) {
|
|
28
|
+
// Cancel any in-flight navigation before starting a new one
|
|
29
|
+
currentNavController?.abort()
|
|
30
|
+
const controller = new AbortController()
|
|
31
|
+
currentNavController = controller
|
|
32
|
+
|
|
27
33
|
try {
|
|
28
|
-
const res = await fetch(path, { headers: { 'X-Pulse-Navigate': 'true' } })
|
|
34
|
+
const res = await fetch(path, { headers: { 'X-Pulse-Navigate': 'true' }, signal: controller.signal })
|
|
29
35
|
|
|
30
36
|
if (!res.ok) { location.href = path; return }
|
|
31
37
|
|
|
@@ -34,6 +40,10 @@ export function initNavigation(root, mountFn) {
|
|
|
34
40
|
if (ct.includes('application/x-ndjson')) {
|
|
35
41
|
// Streaming nav response — apply chunks progressively as they arrive
|
|
36
42
|
const reader = res.body.getReader()
|
|
43
|
+
// Explicitly cancel the reader when this navigation is superseded — aborting
|
|
44
|
+
// the fetch signal does not reliably cancel an in-progress body stream reader
|
|
45
|
+
// in all browsers, so we do it explicitly.
|
|
46
|
+
controller.signal.addEventListener('abort', () => reader.cancel(), { once: true })
|
|
37
47
|
const decoder = new TextDecoder()
|
|
38
48
|
let buf = ''
|
|
39
49
|
let hydratePath = null
|
|
@@ -87,6 +97,9 @@ export function initNavigation(root, mountFn) {
|
|
|
87
97
|
}
|
|
88
98
|
if (buf) await processLine(buf)
|
|
89
99
|
|
|
100
|
+
// Bail out if a newer navigation superseded us while we were streaming
|
|
101
|
+
if (controller.signal.aborted) return
|
|
102
|
+
|
|
90
103
|
runScripts(root)
|
|
91
104
|
document.dispatchEvent(new CustomEvent('pulse:navigate'))
|
|
92
105
|
|
|
@@ -102,6 +115,8 @@ export function initNavigation(root, mountFn) {
|
|
|
102
115
|
// Legacy JSON response (server running with stream: false)
|
|
103
116
|
const { html, title, styles, scripts, hydrate, serverState, storeState } = await res.json()
|
|
104
117
|
|
|
118
|
+
if (controller.signal.aborted) return
|
|
119
|
+
|
|
105
120
|
if (storeState && window.__updatePulseStore__) {
|
|
106
121
|
window.__updatePulseStore__(storeState)
|
|
107
122
|
}
|
|
@@ -126,7 +141,8 @@ export function initNavigation(root, mountFn) {
|
|
|
126
141
|
scrollAndFocus(root)
|
|
127
142
|
}
|
|
128
143
|
|
|
129
|
-
} catch {
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (err?.name === 'AbortError') return
|
|
130
146
|
location.href = path
|
|
131
147
|
}
|
|
132
148
|
}
|
package/src/server/index.js
CHANGED
|
@@ -601,8 +601,12 @@ export function createServer(specs, options = {}) {
|
|
|
601
601
|
|
|
602
602
|
// Client-side navigation request — return JSON fragment (or NDJSON stream), not a full document
|
|
603
603
|
if (req.headers['x-pulse-navigate'] === 'true') {
|
|
604
|
-
|
|
605
|
-
|
|
604
|
+
// Only use NDJSON streaming when there are actual deferred segments to stream.
|
|
605
|
+
// For simple pages (no spec.stream.deferred), the JSON path is faster and does
|
|
606
|
+
// not hold the HTTP/1.1 connection open for the duration of the stream.
|
|
607
|
+
const hasDeferred = stream && spec.stream?.deferred?.length > 0
|
|
608
|
+
if (hasDeferred) {
|
|
609
|
+
await handleNavStreamResponse(spec, ctx, req, res)
|
|
606
610
|
} else {
|
|
607
611
|
await handleNavResponse(spec, ctx, res, dev)
|
|
608
612
|
}
|
|
@@ -730,7 +734,7 @@ async function handleNavResponse(spec, ctx, res, dev = false) {
|
|
|
730
734
|
* as their server data resolves. The browser applies chunks progressively,
|
|
731
735
|
* showing shell content without waiting for slower deferred fetchers.
|
|
732
736
|
*/
|
|
733
|
-
async function handleNavStreamResponse(spec, ctx, res) {
|
|
737
|
+
async function handleNavStreamResponse(spec, ctx, req, res) {
|
|
734
738
|
const meta = resolveMeta(spec.meta, ctx)
|
|
735
739
|
|
|
736
740
|
res.writeHead(200, {
|
|
@@ -743,6 +747,12 @@ async function handleNavStreamResponse(spec, ctx, res) {
|
|
|
743
747
|
const navStream = renderToNavStream(spec, ctx, meta)
|
|
744
748
|
const reader = navStream.getReader()
|
|
745
749
|
|
|
750
|
+
// If the client disconnects (e.g. user navigated away before the stream ended),
|
|
751
|
+
// cancel the reader immediately so the HTTP connection is freed rather than held
|
|
752
|
+
// open until the navStream finishes naturally.
|
|
753
|
+
const onClose = () => reader.cancel()
|
|
754
|
+
req.on('close', onClose)
|
|
755
|
+
|
|
746
756
|
try {
|
|
747
757
|
while (true) {
|
|
748
758
|
const { done, value } = await reader.read()
|
|
@@ -752,6 +762,7 @@ async function handleNavStreamResponse(spec, ctx, res) {
|
|
|
752
762
|
} catch {
|
|
753
763
|
// Headers already sent — cannot change status, just end cleanly
|
|
754
764
|
} finally {
|
|
765
|
+
req.off('close', onClose)
|
|
755
766
|
res.end()
|
|
756
767
|
}
|
|
757
768
|
}
|
package/src/ui/hero.js
CHANGED
|
@@ -2,36 +2,60 @@
|
|
|
2
2
|
* Pulse UI — Hero
|
|
3
3
|
*
|
|
4
4
|
* Full-width hero section with eyebrow, headline, subheadline, and action slot.
|
|
5
|
+
* Pass `image` HTML to activate the split layout — text on one side, image on the other.
|
|
5
6
|
*
|
|
6
7
|
* @param {object} opts
|
|
7
|
-
* @param {string} opts.eyebrow
|
|
8
|
-
* @param {string} opts.title
|
|
9
|
-
* @param {string} opts.subtitle
|
|
10
|
-
* @param {string} opts.actions
|
|
11
|
-
* @param {
|
|
12
|
-
* @param {'
|
|
8
|
+
* @param {string} opts.eyebrow - Small label above the title (e.g. "Now available")
|
|
9
|
+
* @param {string} opts.title - Main headline
|
|
10
|
+
* @param {string} opts.subtitle - Supporting text beneath the headline
|
|
11
|
+
* @param {string} opts.actions - Raw HTML slot — typically button() or appBadge() calls
|
|
12
|
+
* @param {string} opts.image - Raw HTML slot for the image (activates split layout)
|
|
13
|
+
* @param {'right'|'left'} opts.imageAlign - Which side the image sits on (default: 'right')
|
|
14
|
+
* @param {'center'|'left'} opts.align - Text alignment when no image (default: 'center')
|
|
15
|
+
* @param {'md'|'sm'} opts.size - Vertical padding: 'md' (default, 5rem) or 'sm' (2.5rem)
|
|
13
16
|
* @param {string} opts.class
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
import { escHtml as e } from '../html.js'
|
|
17
20
|
|
|
18
21
|
export function hero({
|
|
19
|
-
eyebrow
|
|
20
|
-
title
|
|
21
|
-
subtitle
|
|
22
|
-
actions
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
eyebrow = '',
|
|
23
|
+
title = '',
|
|
24
|
+
subtitle = '',
|
|
25
|
+
actions = '',
|
|
26
|
+
image = '',
|
|
27
|
+
imageAlign = 'right',
|
|
28
|
+
align = 'center',
|
|
29
|
+
size = 'md',
|
|
30
|
+
class: cls = '',
|
|
26
31
|
} = {}) {
|
|
27
|
-
const
|
|
32
|
+
const split = Boolean(image)
|
|
33
|
+
const classes = [
|
|
34
|
+
'ui-hero',
|
|
35
|
+
split ? 'ui-hero--split' : align === 'left' && 'ui-hero--left',
|
|
36
|
+
split && imageAlign === 'left' && 'ui-hero--media-left',
|
|
37
|
+
size === 'sm' && 'ui-hero--sm',
|
|
38
|
+
cls,
|
|
39
|
+
].filter(Boolean).join(' ')
|
|
28
40
|
|
|
29
|
-
|
|
30
|
-
<div class="ui-hero-inner">
|
|
41
|
+
const content = `
|
|
31
42
|
${eyebrow ? `<p class="ui-hero-eyebrow">${e(eyebrow)}</p>` : ''}
|
|
32
43
|
${title ? `<h1 class="ui-hero-title">${e(title)}</h1>` : ''}
|
|
33
44
|
${subtitle ? `<p class="ui-hero-subtitle">${e(subtitle)}</p>` : ''}
|
|
34
|
-
${actions ? `<div class="ui-hero-actions">${actions}</div>` : ''}
|
|
45
|
+
${actions ? `<div class="ui-hero-actions">${actions}</div>` : ''}`
|
|
46
|
+
|
|
47
|
+
if (split) {
|
|
48
|
+
return `<section class="${e(classes)}">
|
|
49
|
+
<div class="ui-hero-inner">
|
|
50
|
+
<div class="ui-hero-content">${content}
|
|
51
|
+
</div>
|
|
52
|
+
<div class="ui-hero-media">${image}</div>
|
|
53
|
+
</div>
|
|
54
|
+
</section>`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return `<section class="${e(classes)}">
|
|
58
|
+
<div class="ui-hero-inner">${content}
|
|
35
59
|
</div>
|
|
36
60
|
</section>`
|
|
37
61
|
}
|
package/src/ui/uiimage.js
CHANGED
|
@@ -49,8 +49,8 @@ export function uiImage({
|
|
|
49
49
|
|
|
50
50
|
if (ratio) {
|
|
51
51
|
return `<figure class="${e(figClasses)}"${maxWidthStyle}>
|
|
52
|
-
<div class="ui-image-crop">
|
|
53
|
-
<img src="${e(src)}" alt="${e(alt)}" class="ui-image-img--cover"
|
|
52
|
+
<div class="ui-image-crop" style="aspect-ratio:${e(ratio)}">
|
|
53
|
+
<img src="${e(src)}" alt="${e(alt)}" class="ui-image-img--cover"${widthAttr}${heightAttr} loading="lazy" decoding="async">
|
|
54
54
|
</div>
|
|
55
55
|
${captionHtml}
|
|
56
56
|
</figure>`
|