@invisibleloop/pulse 0.2.15 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +33 -22
- package/docs/public/pulse-ui.css +76 -0
- package/docs/public/pulse-ui.js +24 -9
- package/docs/server.js +106 -187
- package/docs/src/pages/components/nav.js +56 -6
- package/docs/src/pages/hydration.js +19 -10
- package/docs/src/pages/server-api.js +40 -34
- package/docs/src/pages/spec.js +3 -5
- package/examples/contact.js +0 -1
- package/examples/counter.js +0 -1
- package/examples/dev.server.js +10 -8
- package/examples/pricing.js +0 -1
- package/examples/products.js +0 -1
- package/examples/quiz.js +0 -1
- package/examples/todos.js +0 -1
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/public/pulse-ui.css +76 -0
- package/public/pulse-ui.js +24 -9
- package/src/agent/checklist.md +1 -5
- package/src/server/index.js +63 -3
- package/src/server/server.test.js +6 -6
- package/src/spec/schema.js +0 -7
- package/src/ui/nav.js +57 -10
|
@@ -8,7 +8,7 @@ export default {
|
|
|
8
8
|
route: '/hydration',
|
|
9
9
|
meta: {
|
|
10
10
|
title: 'Hydration — Pulse Docs',
|
|
11
|
-
description: 'How Pulse hydrates server-rendered HTML on the client —
|
|
11
|
+
description: 'How Pulse hydrates server-rendered HTML on the client — automatic hydration, mount(), and production bundles.',
|
|
12
12
|
styles: ['/docs.css'],
|
|
13
13
|
},
|
|
14
14
|
state: {},
|
|
@@ -18,13 +18,22 @@ export default {
|
|
|
18
18
|
next,
|
|
19
19
|
content: `
|
|
20
20
|
${h1('Hydration')}
|
|
21
|
-
${lead('Hydration in Pulse is
|
|
21
|
+
${lead('Hydration in Pulse is automatic. Specs with <code>mutations</code>, <code>actions</code>, or <code>persist</code> are hydrated automatically — Pulse binds events to the server-rendered HTML without re-rendering it. Purely server-rendered specs get zero JavaScript sent to the browser.')}
|
|
22
22
|
|
|
23
|
-
${section('enabling', '
|
|
24
|
-
<p>
|
|
25
|
-
${codeBlock(highlight(
|
|
23
|
+
${section('enabling', 'How hydration works')}
|
|
24
|
+
<p>Pass spec files as <code>URL</code> objects to <code>createServer</code>. Pulse detects whether each spec needs client-side interactivity and, if so, derives the browser-importable path automatically — no <code>hydrate</code> field needed in the spec:</p>
|
|
25
|
+
${codeBlock(highlight(`// server.js
|
|
26
|
+
await createServer(
|
|
27
|
+
[
|
|
28
|
+
new URL('./src/pages/counter.js', import.meta.url), // has mutations → auto-hydrated
|
|
29
|
+
new URL('./src/pages/about.js', import.meta.url), // no mutations → zero JS
|
|
30
|
+
],
|
|
31
|
+
{ port: 3000, root: new URL('.', import.meta.url) }
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
// src/pages/counter.js — no hydrate field needed
|
|
35
|
+
export default {
|
|
26
36
|
route: '/counter',
|
|
27
|
-
hydrate: '/src/pages/counter.js', // browser path to this file
|
|
28
37
|
state: { count: 0 },
|
|
29
38
|
mutations: {
|
|
30
39
|
increment: (state) => ({ count: state.count + 1 }),
|
|
@@ -40,7 +49,7 @@ export default {
|
|
|
40
49
|
}`, 'js'))}
|
|
41
50
|
|
|
42
51
|
${section('dev-bootstrap', 'Development bootstrap')}
|
|
43
|
-
<p>In development
|
|
52
|
+
<p>In development Pulse emits an inline bootstrap script that imports the spec and runtime source files directly — no build step required:</p>
|
|
44
53
|
${codeBlock(highlight(`<script type="module">
|
|
45
54
|
import spec from '/src/pages/counter.js'
|
|
46
55
|
import { mount } from '/src/runtime/index.js'
|
|
@@ -57,7 +66,7 @@ public/dist/
|
|
|
57
66
|
runtime-abc123.js # shared runtime (~3.8 kB brotli)
|
|
58
67
|
counter.boot-def456.js # per-page spec bundle (~0.4–0.9 kB brotli)
|
|
59
68
|
manifest.json # { '/src/pages/counter.js': '/dist/counter.boot-def456.js' }`, 'bash'))}
|
|
60
|
-
<p>When Pulse detects a manifest (via <code>staticDir</code> auto-detection or explicit <code>manifest</code> option), it resolves the
|
|
69
|
+
<p>When Pulse detects a manifest (via <code>staticDir</code> auto-detection or explicit <code>manifest</code> option), it resolves the auto-derived hydrate path to the bundle path and emits a single <code><script src></code> tag instead of the inline bootstrap:</p>
|
|
61
70
|
${codeBlock(highlight(`<script type="module" src="/dist/counter.boot-def456.js"></script>`, 'html'))}
|
|
62
71
|
|
|
63
72
|
${section('ssr-true', 'The { ssr: true } option')}
|
|
@@ -78,14 +87,14 @@ mount(
|
|
|
78
87
|
<p>After mount, all <code>data-event</code> and <code>data-action</code> attributes in the DOM are wired to the spec's mutations and actions. State updates trigger a full view re-render via <code>innerHTML</code> replacement.</p>
|
|
79
88
|
|
|
80
89
|
${section('no-hydrate', 'Pages without hydration')}
|
|
81
|
-
<p>
|
|
90
|
+
<p>Specs with no <code>mutations</code>, <code>actions</code>, or <code>persist</code> automatically get zero JavaScript — no runtime overhead, no hydration cost. This is the correct default for:</p>
|
|
82
91
|
<ul>
|
|
83
92
|
<li>Documentation pages</li>
|
|
84
93
|
<li>Marketing/landing pages</li>
|
|
85
94
|
<li>Blog posts and articles</li>
|
|
86
95
|
<li>Any page with no client-side interactivity</li>
|
|
87
96
|
</ul>
|
|
88
|
-
${callout('tip', '
|
|
97
|
+
${callout('tip', 'Many pages that appear to need JavaScript can be handled server-side with <a href="/routing">routing</a> and <a href="/server-data">server data</a>. Keep specs free of mutations and actions wherever possible.')}
|
|
89
98
|
|
|
90
99
|
${section('server-state', 'Passing server state to the client')}
|
|
91
100
|
<p>Server data fetched via <code>server.data()</code> is serialised into the page HTML as <code>window.__PULSE_SERVER__</code>. The client runtime reads this on mount, making server data available to the view during client re-renders without an additional network request.</p>
|
|
@@ -18,16 +18,16 @@ export default {
|
|
|
18
18
|
next,
|
|
19
19
|
content: `
|
|
20
20
|
${h1('Server API')}
|
|
21
|
-
${lead('<code>createServer(
|
|
21
|
+
${lead('<code>createServer(entries, options)</code> starts an HTTP server with all guarantees active. Specs are validated before the server accepts connections. SSR, streaming, brotli compression, immutable asset caching, security headers, CSP nonces, and HSTS are all handled automatically.')}
|
|
22
22
|
|
|
23
|
-
${section('signature', 'createServer(
|
|
23
|
+
${section('signature', 'createServer(entries, options)')}
|
|
24
24
|
${codeBlock(highlight(`import { createServer } from '@invisibleloop/pulse'
|
|
25
25
|
|
|
26
|
-
createServer(
|
|
26
|
+
await createServer(entries, options)`, 'js'))}
|
|
27
27
|
${table(
|
|
28
28
|
['Parameter', 'Type', 'Description'],
|
|
29
29
|
[
|
|
30
|
-
['<code>
|
|
30
|
+
['<code>entries</code>', '<code>Array<URL | Spec></code>', 'Array of <code>URL</code> objects or plain spec objects. URL entries are imported and hydration is auto-derived. Validated at startup — a bad spec throws before the server accepts connections.'],
|
|
31
31
|
['<code>options</code>', '<code>object</code>', 'Server configuration options (see below).'],
|
|
32
32
|
]
|
|
33
33
|
)}
|
|
@@ -41,6 +41,7 @@ createServer(specs, options)`, 'js'))}
|
|
|
41
41
|
['<code>staticDir</code>', '<code>string</code>', '<code>undefined</code>', 'Path to a directory of static files to serve. Relative to the process working directory.'],
|
|
42
42
|
['<code>manifest</code>', '<code>string | object</code>', '<code>null</code>', 'Explicit manifest path or object. Overrides auto-detection from <code>staticDir/dist/manifest.json</code>.'],
|
|
43
43
|
['<code>trailingSlash</code>', '<code>"remove" | "add" | "allow"</code>', '<code>"remove"</code>', '<code>"remove"</code> — 301 redirect <code>/about/</code> → <code>/about</code>. <code>"add"</code> — 301 redirect <code>/about</code> → <code>/about/</code>. <code>"allow"</code> — serve both, no redirect.'],
|
|
44
|
+
['<code>root</code>', '<code>URL | string</code>', '<code>process.cwd()</code>', 'Project root used to derive browser-importable paths from URL entries. Pass <code>new URL(\'.\', import.meta.url)</code> for a CWD-independent value.'],
|
|
44
45
|
['<code>store</code>', '<code>object</code>', '<code>null</code>', 'Global store definition (default export from <code>pulse.store.js</code>). See <a href="/store">Global Store</a>.'],
|
|
45
46
|
['<code>maxBody</code>', '<code>number</code>', '<code>1048576</code>', 'Maximum request body size in bytes (default 1 MB). Requests exceeding this limit receive a 413 response.'],
|
|
46
47
|
['<code>defaultCache</code>', '<code>boolean | number | object</code>', '<code>null</code>', 'Default HTML cache TTL for all pages in production. <code>true</code> = 1 h + 24 h SWR. A number sets <code>max-age</code> in seconds. An object accepts <code>{ public, maxAge, staleWhileRevalidate }</code>. <code>spec.cache</code> overrides per-page.'],
|
|
@@ -55,37 +56,42 @@ createServer(specs, options)`, 'js'))}
|
|
|
55
56
|
|
|
56
57
|
${section('example', 'Full example')}
|
|
57
58
|
${codeBlock(highlight(`import { createServer } from '@invisibleloop/pulse'
|
|
58
|
-
import home from './src/pages/home.js'
|
|
59
|
-
import contact from './src/pages/contact.js'
|
|
60
59
|
|
|
61
|
-
createServer(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
60
|
+
await createServer(
|
|
61
|
+
[
|
|
62
|
+
new URL('./src/pages/home.js', import.meta.url),
|
|
63
|
+
new URL('./src/pages/contact.js', import.meta.url),
|
|
64
|
+
],
|
|
65
|
+
{
|
|
66
|
+
port: 3000,
|
|
67
|
+
stream: true,
|
|
68
|
+
staticDir: 'public',
|
|
69
|
+
root: new URL('.', import.meta.url),
|
|
70
|
+
onRequest: (req, res) => {
|
|
71
|
+
// Add custom headers
|
|
72
|
+
res.setHeader('X-My-Header', 'my-value')
|
|
73
|
+
// Return false to block a request
|
|
74
|
+
if (req.url.startsWith('/admin') && !isAuthenticated(req)) {
|
|
75
|
+
res.writeHead(401)
|
|
76
|
+
res.end('Unauthorized')
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
// Return undefined (or nothing) to let Pulse handle it
|
|
80
|
+
},
|
|
81
|
+
onError: (err, req, res) => {
|
|
82
|
+
console.error(err)
|
|
83
|
+
if (!res.headersSent) {
|
|
84
|
+
res.writeHead(500, { 'Content-Type': 'text/html' })
|
|
85
|
+
res.end('<h1>Internal Server Error</h1>')
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
)`, 'js'))}
|
|
84
90
|
|
|
85
91
|
${section('multi-brand', 'Multi-brand sites')}
|
|
86
92
|
<p>One Pulse server can serve multiple brands, using the request domain as the key. Pass <code>resolveBrand</code> to <code>createServer</code> — it receives the <code>host</code> header and returns a brand config object of any shape you choose. The result is cached per host for 60 seconds and attached to <code>ctx.brand</code>.</p>
|
|
87
93
|
${codeBlock(highlight(`// server.js
|
|
88
|
-
createServer(specs, {
|
|
94
|
+
await createServer(specs, {
|
|
89
95
|
resolveBrand: async (host) => {
|
|
90
96
|
const slug = host.split('.')[0] // 'acme' from 'acme.myco.com'
|
|
91
97
|
return db.brands.findBySlug(slug) // { slug, name, accent, logo, ... }
|
|
@@ -126,7 +132,7 @@ createServer(specs, {
|
|
|
126
132
|
${section('static-files', 'Static file serving')}
|
|
127
133
|
<p>When <code>staticDir</code> is set, Pulse serves all files in that directory at their relative path. For example, a file at <code>public/app.css</code> is served at <code>/app.css</code>.</p>
|
|
128
134
|
<p>If <code>staticDir/dist/manifest.json</code> exists, Pulse automatically loads it to resolve production hydration bundle paths. No additional configuration is needed.</p>
|
|
129
|
-
${codeBlock(highlight(`createServer(specs, {
|
|
135
|
+
${codeBlock(highlight(`await createServer(specs, {
|
|
130
136
|
staticDir: 'public', // serves public/* at /*
|
|
131
137
|
// manifest auto-detected from public/dist/manifest.json
|
|
132
138
|
})`, 'js'))}
|
|
@@ -217,7 +223,7 @@ Cross-Origin-Resource-Policy: same-origin`, 'bash'))}
|
|
|
217
223
|
form-action 'self'`, 'bash'))}
|
|
218
224
|
<p>All inline scripts injected by the framework carry a matching <code>nonce</code> attribute. The nonce is also available as <code>ctx.nonce</code> so view functions can attach it to their own inline scripts.</p>
|
|
219
225
|
<p>To load resources from external origins — Google Fonts, a CDN, an external API — pass a <code>csp</code> object to <code>createServer</code>. Sources are merged into the framework defaults; existing directives are not replaced:</p>
|
|
220
|
-
${codeBlock(highlight(`createServer(specs, {
|
|
226
|
+
${codeBlock(highlight(`await createServer(specs, {
|
|
221
227
|
csp: {
|
|
222
228
|
'style-src': ['https://fonts.googleapis.com'],
|
|
223
229
|
'font-src': ['https://fonts.gstatic.com'],
|
|
@@ -252,7 +258,7 @@ Cross-Origin-Resource-Policy: same-origin`, 'bash'))}
|
|
|
252
258
|
${codeBlock(highlight(`GET /healthz → 200 OK
|
|
253
259
|
{ "status": "ok", "uptime": 42.3 }`, 'json'))}
|
|
254
260
|
<p>Configure the path or disable it entirely:</p>
|
|
255
|
-
${codeBlock(highlight(`createServer(specs, {
|
|
261
|
+
${codeBlock(highlight(`await createServer(specs, {
|
|
256
262
|
healthCheck: '/ping', // custom path
|
|
257
263
|
// healthCheck: false, // disable
|
|
258
264
|
})`, 'js'))}
|
|
@@ -267,7 +273,7 @@ Cross-Origin-Resource-Policy: same-origin`, 'bash'))}
|
|
|
267
273
|
<li>After <code>shutdownTimeout</code> ms (default 30 000 ms), the process force-exits to prevent a stuck request from blocking a deploy indefinitely.</li>
|
|
268
274
|
</ol>
|
|
269
275
|
<p>The <code>shutdown()</code> function is also returned from <code>createServer</code> so you can trigger it programmatically:</p>
|
|
270
|
-
${codeBlock(highlight(`const { server, shutdown } = createServer(specs, {
|
|
276
|
+
${codeBlock(highlight(`const { server, shutdown } = await createServer(specs, {
|
|
271
277
|
port: 3000,
|
|
272
278
|
shutdownTimeout: 10000, // 10 s — override the 30 s default
|
|
273
279
|
})
|
package/docs/src/pages/spec.js
CHANGED
|
@@ -28,7 +28,7 @@ export default {
|
|
|
28
28
|
['<code>state</code>', '<code>object</code>', 'Yes', 'Initial client-side state. Deep-cloned on mount.'],
|
|
29
29
|
['<code>view</code>', '<code>function</code>', 'Yes', 'Returns an HTML string. Receives <code>(state, serverState)</code>.'],
|
|
30
30
|
['<code>meta</code>', '<code>object</code>', 'No', 'Page metadata: title, description, styles, OG tags, schema.'],
|
|
31
|
-
['<code>hydrate</code>', '<code>string</code>', '
|
|
31
|
+
['<code>hydrate</code>', '<code>string</code>', 'Auto', 'Auto-derived from the URL entry in <code>createServer</code>. Do not set manually.'],
|
|
32
32
|
['<code>mutations</code>', '<code>object</code>', 'No', 'Synchronous state updaters keyed by name.'],
|
|
33
33
|
['<code>actions</code>', '<code>object</code>', 'No', 'Async operations with full lifecycle hooks.'],
|
|
34
34
|
['<code>validation</code>', '<code>object</code>', 'No', 'Declarative validation rules keyed by dot-path state keys.'],
|
|
@@ -84,10 +84,8 @@ route: '/blog/:year/:slug'`, 'js'))}
|
|
|
84
84
|
<p>See <a href="/meta">Metadata & SEO</a> for the full reference.</p>
|
|
85
85
|
|
|
86
86
|
${section('hydrate', 'hydrate')}
|
|
87
|
-
<p>
|
|
88
|
-
${
|
|
89
|
-
// Production: resolved automatically via manifest.json`, 'js'))}
|
|
90
|
-
${callout('note', 'Omit <code>hydrate</code> for purely server-rendered pages with no client interactivity. Pulse sends zero JavaScript to the browser — no runtime overhead, no hydration cost.')}
|
|
87
|
+
<p>Auto-derived by the framework — do not set this in your spec. When a spec is registered via a <code>URL</code> entry in <code>createServer</code>, Pulse detects whether it needs hydration (has <code>mutations</code>, <code>actions</code>, or <code>persist</code>) and sets the browser path automatically. Purely server-rendered specs get zero JavaScript.</p>
|
|
88
|
+
${callout('note', 'See <a href="/hydration">Hydration</a> for how this works and how to register specs via URL entries.')}
|
|
91
89
|
|
|
92
90
|
${section('mutations', 'mutations')}
|
|
93
91
|
<p>Synchronous state updaters. Each mutation is a function <code>(state, event) => partialState</code>. The returned partial object is merged into state. See <a href="/mutations">Mutations</a>.</p>
|
package/examples/contact.js
CHANGED
package/examples/counter.js
CHANGED
package/examples/dev.server.js
CHANGED
|
@@ -18,12 +18,6 @@ import fs from 'fs'
|
|
|
18
18
|
import path from 'path'
|
|
19
19
|
import { createServer } from '../src/server/index.js'
|
|
20
20
|
import { themeScript } from './shared.js'
|
|
21
|
-
import counter from './counter.js'
|
|
22
|
-
import todos from './todos.js'
|
|
23
|
-
import contact from './contact.js'
|
|
24
|
-
import quiz from './quiz.js'
|
|
25
|
-
import products from './products.js'
|
|
26
|
-
import pricing from './pricing.js'
|
|
27
21
|
|
|
28
22
|
const ROOT = path.resolve(import.meta.dirname, '..')
|
|
29
23
|
|
|
@@ -79,12 +73,20 @@ function staticHandler(req, res) {
|
|
|
79
73
|
return false
|
|
80
74
|
}
|
|
81
75
|
|
|
82
|
-
createServer(
|
|
83
|
-
[
|
|
76
|
+
await createServer(
|
|
77
|
+
[
|
|
78
|
+
new URL('./counter.js', import.meta.url),
|
|
79
|
+
new URL('./todos.js', import.meta.url),
|
|
80
|
+
new URL('./contact.js', import.meta.url),
|
|
81
|
+
new URL('./quiz.js', import.meta.url),
|
|
82
|
+
new URL('./products.js', import.meta.url),
|
|
83
|
+
new URL('./pricing.js', import.meta.url),
|
|
84
|
+
],
|
|
84
85
|
{
|
|
85
86
|
port: 3001,
|
|
86
87
|
stream: true,
|
|
87
88
|
staticDir: path.join(ROOT, 'public'),
|
|
89
|
+
root: new URL('..', import.meta.url), // pulse2/ — specs served at /examples/*
|
|
88
90
|
onRequest: staticHandler,
|
|
89
91
|
extraBody: themeScript,
|
|
90
92
|
}
|
package/examples/pricing.js
CHANGED
package/examples/products.js
CHANGED
package/examples/quiz.js
CHANGED
package/examples/todos.js
CHANGED
package/package.json
CHANGED
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.4.0
|
package/public/pulse-ui.css
CHANGED
|
@@ -1280,6 +1280,82 @@ hr.ui-divider {
|
|
|
1280
1280
|
}
|
|
1281
1281
|
.ui-nav-mobile .ui-nav-link:last-child { border-bottom: none; }
|
|
1282
1282
|
.ui-nav-mobile-action { padding-top: .75rem; }
|
|
1283
|
+
.ui-nav-mobile-section {
|
|
1284
|
+
font-size: .7rem;
|
|
1285
|
+
font-weight: 600;
|
|
1286
|
+
text-transform: uppercase;
|
|
1287
|
+
letter-spacing: .08em;
|
|
1288
|
+
color: var(--ui-muted, var(--muted, #6b6b80));
|
|
1289
|
+
margin: .75rem 0 .25rem;
|
|
1290
|
+
padding: 0;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/* ─── Mega nav ───────────────────────────────────────────────────────────── */
|
|
1294
|
+
.ui-nav-mega-wrap { position: relative; }
|
|
1295
|
+
|
|
1296
|
+
.ui-nav-mega-trigger {
|
|
1297
|
+
background: none;
|
|
1298
|
+
border: none;
|
|
1299
|
+
cursor: pointer;
|
|
1300
|
+
display: inline-flex;
|
|
1301
|
+
align-items: center;
|
|
1302
|
+
gap: .3rem;
|
|
1303
|
+
padding: 0;
|
|
1304
|
+
font: inherit;
|
|
1305
|
+
font-size: .875rem;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
.ui-nav-mega-chevron { transition: transform .2s ease; flex-shrink: 0; }
|
|
1309
|
+
.ui-nav-mega-wrap--open .ui-nav-mega-chevron { transform: rotate(180deg); }
|
|
1310
|
+
|
|
1311
|
+
.ui-nav-mega-panel {
|
|
1312
|
+
position: absolute;
|
|
1313
|
+
top: calc(100% + .875rem);
|
|
1314
|
+
left: 50%;
|
|
1315
|
+
transform: translateX(-50%);
|
|
1316
|
+
background: var(--ui-bg, var(--bg, #0d0d10));
|
|
1317
|
+
border: 1px solid var(--ui-border, var(--border, #232329));
|
|
1318
|
+
border-radius: var(--ui-radius, 8px);
|
|
1319
|
+
box-shadow: 0 8px 40px rgba(0,0,0,.35);
|
|
1320
|
+
padding: 1.5rem;
|
|
1321
|
+
z-index: 200;
|
|
1322
|
+
min-width: 360px;
|
|
1323
|
+
}
|
|
1324
|
+
.ui-nav-mega-panel[hidden] { display: none !important; }
|
|
1325
|
+
|
|
1326
|
+
.ui-nav-mega-inner { display: flex; gap: 2rem; }
|
|
1327
|
+
|
|
1328
|
+
.ui-nav-mega-col { flex: 1; min-width: 140px; }
|
|
1329
|
+
|
|
1330
|
+
.ui-nav-mega-heading {
|
|
1331
|
+
font-size: .7rem;
|
|
1332
|
+
font-weight: 600;
|
|
1333
|
+
text-transform: uppercase;
|
|
1334
|
+
letter-spacing: .08em;
|
|
1335
|
+
color: var(--ui-muted, var(--muted, #6b6b80));
|
|
1336
|
+
margin: 0 0 .5rem;
|
|
1337
|
+
padding: 0 .5rem;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
.ui-nav-mega-item {
|
|
1341
|
+
display: flex;
|
|
1342
|
+
flex-direction: column;
|
|
1343
|
+
gap: .15rem;
|
|
1344
|
+
padding: .5rem;
|
|
1345
|
+
border-radius: calc(var(--ui-radius, 8px) * .65);
|
|
1346
|
+
transition: background .15s;
|
|
1347
|
+
}
|
|
1348
|
+
.ui-nav-mega-item:hover { background: var(--ui-surface, var(--surface, #111116)); }
|
|
1349
|
+
.ui-nav-mega-item-label {
|
|
1350
|
+
font-size: .875rem;
|
|
1351
|
+
font-weight: 500;
|
|
1352
|
+
color: var(--ui-text, var(--text, #e2e2ea));
|
|
1353
|
+
text-decoration: none;
|
|
1354
|
+
}
|
|
1355
|
+
.ui-nav-mega-item-label:hover { color: var(--ui-accent, var(--accent, #9b8dff)); }
|
|
1356
|
+
.ui-nav-mega-item-desc { font-size: .75rem; color: var(--ui-muted, var(--muted, #6b6b80)); }
|
|
1357
|
+
|
|
1358
|
+
[data-theme="light"] .ui-nav-mega-panel { box-shadow: 0 8px 40px rgba(0,0,0,.12); }
|
|
1283
1359
|
|
|
1284
1360
|
/* ─── App Badge ──────────────────────────────────────────────────────────── */
|
|
1285
1361
|
.ui-app-badge {
|
package/public/pulse-ui.js
CHANGED
|
@@ -131,19 +131,34 @@ document.addEventListener('keydown', (e) => {
|
|
|
131
131
|
function initNav(el) {
|
|
132
132
|
const burger = el.querySelector('.ui-nav-burger')
|
|
133
133
|
const mobile = el.querySelector('.ui-nav-mobile')
|
|
134
|
-
if (!burger || !mobile) return
|
|
135
134
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
135
|
+
// Mobile burger
|
|
136
|
+
if (burger && mobile) {
|
|
137
|
+
const open = () => { el.classList.add('ui-nav--open'); burger.setAttribute('aria-expanded', 'true') }
|
|
138
|
+
const close = () => { el.classList.remove('ui-nav--open'); burger.setAttribute('aria-expanded', 'false') }
|
|
139
|
+
const toggle = () => el.classList.contains('ui-nav--open') ? close() : open()
|
|
139
140
|
|
|
140
|
-
|
|
141
|
+
burger.addEventListener('click', toggle)
|
|
142
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
|
|
143
|
+
document.addEventListener('click', (e) => { if (!el.contains(e.target)) close() })
|
|
144
|
+
mobile.querySelectorAll('.ui-nav-link').forEach(a => a.addEventListener('click', close))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Mega nav dropdowns
|
|
148
|
+
el.querySelectorAll('.ui-nav-mega-wrap').forEach(wrap => {
|
|
149
|
+
const trigger = wrap.querySelector('.ui-nav-mega-trigger')
|
|
150
|
+
const panel = wrap.querySelector('.ui-nav-mega-panel')
|
|
151
|
+
if (!trigger || !panel) return
|
|
141
152
|
|
|
142
|
-
|
|
143
|
-
|
|
153
|
+
const openMega = () => { panel.hidden = false; wrap.classList.add('ui-nav-mega-wrap--open'); trigger.setAttribute('aria-expanded', 'true') }
|
|
154
|
+
const closeMega = () => { panel.hidden = true; wrap.classList.remove('ui-nav-mega-wrap--open'); trigger.setAttribute('aria-expanded', 'false') }
|
|
155
|
+
const toggleMega = () => panel.hidden ? openMega() : closeMega()
|
|
144
156
|
|
|
145
|
-
|
|
146
|
-
|
|
157
|
+
trigger.addEventListener('click', (e) => { e.stopPropagation(); toggleMega() })
|
|
158
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMega() })
|
|
159
|
+
document.addEventListener('click', (e) => { if (!wrap.contains(e.target)) closeMega() })
|
|
160
|
+
panel.querySelectorAll('.ui-nav-mega-item').forEach(a => a.addEventListener('click', closeMega))
|
|
161
|
+
})
|
|
147
162
|
}
|
|
148
163
|
|
|
149
164
|
function runNavs() { document.querySelectorAll('.ui-nav').forEach(initNav) }
|
package/src/agent/checklist.md
CHANGED
|
@@ -4,11 +4,7 @@ Before finishing any spec, verify every point below. Fix anything that fails.
|
|
|
4
4
|
|
|
5
5
|
### Critical
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
```js
|
|
9
|
-
hydrate: '/src/pages/my-page.js', // browser-importable path to this file
|
|
10
|
-
```
|
|
11
|
-
Omit `hydrate` only for purely server-rendered pages with zero client interactivity.
|
|
7
|
+
- **Do not set `hydrate` in specs.** It is auto-derived by the framework from the URL entry passed to `createServer`. Specs with `mutations`, `actions`, or `persist` are hydrated automatically. Purely server-rendered specs get zero JavaScript — no configuration needed.
|
|
12
8
|
|
|
13
9
|
### Components first
|
|
14
10
|
|
package/src/server/index.js
CHANGED
|
@@ -386,14 +386,70 @@ const MIME_TYPES = {
|
|
|
386
386
|
* Return false to short-circuit routing
|
|
387
387
|
*/
|
|
388
388
|
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// Spec entry resolution — URL objects are imported and get hydrate auto-set
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Returns true if the spec needs a client-side hydration script.
|
|
395
|
+
* Specs with no mutations, actions, or persist are purely server-rendered.
|
|
396
|
+
*/
|
|
397
|
+
function needsHydration(spec) {
|
|
398
|
+
return !!(spec.mutations && Object.keys(spec.mutations).length) ||
|
|
399
|
+
!!(spec.actions && Object.keys(spec.actions).length) ||
|
|
400
|
+
!!spec.persist
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Accept mixed arrays of spec objects and URL objects.
|
|
405
|
+
* URL entries are dynamically imported; hydrate is auto-derived from the file
|
|
406
|
+
* URL by stripping the project root, so developers never need to set it.
|
|
407
|
+
*
|
|
408
|
+
* root — a file: URL string or URL object for the project root directory.
|
|
409
|
+
* Defaults to process.cwd(). Pass `new URL('.', import.meta.url)` from
|
|
410
|
+
* your server.js for a CWD-independent alternative.
|
|
411
|
+
*/
|
|
412
|
+
async function resolveSpecEntries(entries, root) {
|
|
413
|
+
// Normalise root to a href string ending with /
|
|
414
|
+
let rootHref
|
|
415
|
+
if (root instanceof URL) {
|
|
416
|
+
rootHref = root.href.endsWith('/') ? root.href : root.href + '/'
|
|
417
|
+
} else if (typeof root === 'string' && root.startsWith('file://')) {
|
|
418
|
+
rootHref = root.endsWith('/') ? root : root + '/'
|
|
419
|
+
} else {
|
|
420
|
+
// Default: process.cwd() converted to a file URL
|
|
421
|
+
rootHref = 'file://' + process.cwd().replace(/\\/g, '/') + '/'
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const resolved = []
|
|
425
|
+
for (const entry of entries) {
|
|
426
|
+
if (!(entry instanceof URL)) {
|
|
427
|
+
resolved.push(entry)
|
|
428
|
+
continue
|
|
429
|
+
}
|
|
430
|
+
const mod = await import(entry.href)
|
|
431
|
+
const spec = mod.default ?? mod
|
|
432
|
+
if (!spec.hydrate && needsHydration(spec)) {
|
|
433
|
+
// Strip project root to get the browser-importable path
|
|
434
|
+
const browserPath = entry.href.startsWith(rootHref)
|
|
435
|
+
? '/' + entry.href.slice(rootHref.length)
|
|
436
|
+
: entry.pathname // fallback: use pathname as-is
|
|
437
|
+
resolved.push({ ...spec, hydrate: browserPath })
|
|
438
|
+
} else {
|
|
439
|
+
resolved.push(spec)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return resolved
|
|
443
|
+
}
|
|
444
|
+
|
|
389
445
|
/**
|
|
390
446
|
* Create and start a Pulse HTTP server.
|
|
391
447
|
*
|
|
392
|
-
* @param {import('../spec/schema.js').PulseSpec
|
|
448
|
+
* @param {Array<import('../spec/schema.js').PulseSpec|URL>} entries
|
|
393
449
|
* @param {ServerOptions} [options]
|
|
394
|
-
* @returns {http.Server}
|
|
450
|
+
* @returns {Promise<{server: http.Server, shutdown: Function, updateSpecs: Function}>}
|
|
395
451
|
*/
|
|
396
|
-
export function createServer(
|
|
452
|
+
export async function createServer(entries, options = {}) {
|
|
397
453
|
const {
|
|
398
454
|
port = 3000,
|
|
399
455
|
stream = true,
|
|
@@ -403,6 +459,7 @@ export function createServer(specs, options = {}) {
|
|
|
403
459
|
extraBody = '', // extra HTML injected before </body> on every page
|
|
404
460
|
dev = false, // dev mode — show detailed error pages
|
|
405
461
|
store = null, // global store definition (pulse.store.js default export)
|
|
462
|
+
root = null, // URL/string — project root for deriving browser paths from file: URLs
|
|
406
463
|
resolveBrand = null, // async (host) => brandConfig — keyed by domain
|
|
407
464
|
defaultCache = null, // default page cache: true | seconds | { public, maxAge, swr }
|
|
408
465
|
fetcherTimeout = null, // ms before any server fetcher times out (null = no limit)
|
|
@@ -414,6 +471,9 @@ export function createServer(specs, options = {}) {
|
|
|
414
471
|
onRequest
|
|
415
472
|
} = options
|
|
416
473
|
|
|
474
|
+
// Resolve URL entries → spec objects, auto-setting hydrate where needed
|
|
475
|
+
const specs = await resolveSpecEntries(entries, root)
|
|
476
|
+
|
|
417
477
|
const healthPath = healthCheck === true ? '/healthz' : (healthCheck || null)
|
|
418
478
|
|
|
419
479
|
// Validate store at startup — fail fast before the server accepts connections
|
|
@@ -134,7 +134,7 @@ let nextPort = 13337
|
|
|
134
134
|
|
|
135
135
|
async function withServer(specs, options, fn) {
|
|
136
136
|
const port = nextPort++
|
|
137
|
-
const { server } = createServer(specs, { ...options, port })
|
|
137
|
+
const { server } = await createServer(specs, { ...options, port })
|
|
138
138
|
await new Promise(resolve => server.once('listening', resolve))
|
|
139
139
|
try {
|
|
140
140
|
await fn(port)
|
|
@@ -345,7 +345,7 @@ console.log('\nStartup validation\n')
|
|
|
345
345
|
await test('throws on invalid spec at startup', async () => {
|
|
346
346
|
let threw = false
|
|
347
347
|
try {
|
|
348
|
-
createServer([{ route: '/bad' }], { port: 19999 })
|
|
348
|
+
await createServer([{ route: '/bad' }], { port: 19999 })
|
|
349
349
|
} catch (e) {
|
|
350
350
|
threw = true
|
|
351
351
|
assert(e.message.includes('/bad'), `Expected route in error: ${e.message}`)
|
|
@@ -1250,7 +1250,7 @@ console.log('\nGraceful shutdown\n')
|
|
|
1250
1250
|
|
|
1251
1251
|
await test('shutdown() is returned from createServer', async () => {
|
|
1252
1252
|
const port = nextPort++
|
|
1253
|
-
const result = createServer([helloSpec], { port, stream: false })
|
|
1253
|
+
const result = await createServer([helloSpec], { port, stream: false })
|
|
1254
1254
|
assert(typeof result.shutdown === 'function', 'Expected shutdown to be a function')
|
|
1255
1255
|
result.server.closeAllConnections?.()
|
|
1256
1256
|
await new Promise(resolve => result.server.close(resolve))
|
|
@@ -1258,7 +1258,7 @@ await test('shutdown() is returned from createServer', async () => {
|
|
|
1258
1258
|
|
|
1259
1259
|
await test('shutdown() stops the server from accepting new connections', async () => {
|
|
1260
1260
|
const port = nextPort++
|
|
1261
|
-
const { server, shutdown } = createServer([helloSpec], { port, stream: false })
|
|
1261
|
+
const { server, shutdown } = await createServer([helloSpec], { port, stream: false })
|
|
1262
1262
|
await new Promise(resolve => server.once('listening', resolve))
|
|
1263
1263
|
|
|
1264
1264
|
// Confirm server is up
|
|
@@ -1276,7 +1276,7 @@ await test('shutdown() stops the server from accepting new connections', async (
|
|
|
1276
1276
|
|
|
1277
1277
|
await test('shutdown() is idempotent — calling twice does not throw', async () => {
|
|
1278
1278
|
const port = nextPort++
|
|
1279
|
-
const { server, shutdown } = createServer([helloSpec], { port, stream: false })
|
|
1279
|
+
const { server, shutdown } = await createServer([helloSpec], { port, stream: false })
|
|
1280
1280
|
await new Promise(resolve => server.once('listening', resolve))
|
|
1281
1281
|
shutdown()
|
|
1282
1282
|
shutdown() // second call is a no-op
|
|
@@ -1295,7 +1295,7 @@ await test('in-flight request completes after shutdown() is called', async () =>
|
|
|
1295
1295
|
view: (_s, server) => `<p>${server.data}</p>`
|
|
1296
1296
|
}
|
|
1297
1297
|
|
|
1298
|
-
const { server, shutdown } = createServer([slowSpec], { port, stream: false })
|
|
1298
|
+
const { server, shutdown } = await createServer([slowSpec], { port, stream: false })
|
|
1299
1299
|
await new Promise(resolve => server.once('listening', resolve))
|
|
1300
1300
|
|
|
1301
1301
|
// Start a request but don't resolve its server fetch yet
|