@invisibleloop/pulse 0.1.21
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/commands/build-page.md +59 -0
- package/.claude/commands/new-doc-page.md +45 -0
- package/.claude/commands/verify.md +52 -0
- package/.claude/pulse-checklist.md +111 -0
- package/.claude/settings.local.json +102 -0
- package/.github/workflows/ci.yml +22 -0
- package/.github/workflows/publish.yml +41 -0
- package/.pulse/load-reports/home/1773432711417.json +22 -0
- package/CLAUDE.md +383 -0
- package/README.md +95 -0
- package/docs/.claude/pulse-checklist.md +111 -0
- package/docs/public/.pulse-ui-version +1 -0
- package/docs/public/dist/accessibility.boot-5DVTARJU.js +115 -0
- package/docs/public/dist/actions.boot-P66HKQEM.js +164 -0
- package/docs/public/dist/auth.boot-IMAJAUPH.js +140 -0
- package/docs/public/dist/caching.boot-DVR6KDE7.js +53 -0
- package/docs/public/dist/components--accordion.boot-3HVKMNWC.js +11 -0
- package/docs/public/dist/components--alert.boot-GCEXOZAC.js +6 -0
- package/docs/public/dist/components--app-badge.boot-DVT3GCHJ.js +6 -0
- package/docs/public/dist/components--avatar.boot-PSW24EVA.js +5 -0
- package/docs/public/dist/components--badge.boot-TYDY2RMK.js +7 -0
- package/docs/public/dist/components--banner.boot-EI5PZSZK.js +7 -0
- package/docs/public/dist/components--breadcrumbs.boot-SMA2E2GO.js +34 -0
- package/docs/public/dist/components--button.boot-J54BQM2E.js +23 -0
- package/docs/public/dist/components--card.boot-PZGNDIB6.js +138 -0
- package/docs/public/dist/components--carousel.boot-TP6LPFZZ.js +12 -0
- package/docs/public/dist/components--charts.boot-2EOYQWKL.js +108 -0
- package/docs/public/dist/components--checkbox.boot-DS5BSL6T.js +54 -0
- package/docs/public/dist/components--cluster.boot-HHVIBBJG.js +9 -0
- package/docs/public/dist/components--code-window.boot-2GR2DV33.js +20 -0
- package/docs/public/dist/components--container.boot-7LOOGK2K.js +5 -0
- package/docs/public/dist/components--cta.boot-FSNZ5YRT.js +11 -0
- package/docs/public/dist/components--divider.boot-3NI2C3QG.js +6 -0
- package/docs/public/dist/components--empty.boot-YX2UR3PV.js +7 -0
- package/docs/public/dist/components--feature.boot-MUD7NSUO.js +13 -0
- package/docs/public/dist/components--fieldset.boot-J7BYHMKF.js +19 -0
- package/docs/public/dist/components--fileupload.boot-NIKVTTPD.js +52 -0
- package/docs/public/dist/components--footer.boot-EYUK5FRG.js +14 -0
- package/docs/public/dist/components--grid.boot-URDQVDDR.js +59 -0
- package/docs/public/dist/components--heading.boot-BPQKU43E.js +44 -0
- package/docs/public/dist/components--hero.boot-4RAPRGAB.js +17 -0
- package/docs/public/dist/components--icons.boot-ZITNU5JP.js +68 -0
- package/docs/public/dist/components--image.boot-XEEGHQZF.js +19 -0
- package/docs/public/dist/components--input.boot-SGASZG5K.js +7 -0
- package/docs/public/dist/components--list.boot-W3XC5MHD.js +55 -0
- package/docs/public/dist/components--media.boot-5VFIETZO.js +13 -0
- package/docs/public/dist/components--modal.boot-RZUYXBN2.js +47 -0
- package/docs/public/dist/components--nav.boot-ODBOHU7O.js +33 -0
- package/docs/public/dist/components--pricing.boot-4AQ4ZVBY.js +21 -0
- package/docs/public/dist/components--progress.boot-GHAGYZOK.js +30 -0
- package/docs/public/dist/components--prose.boot-QANJL6JI.js +67 -0
- package/docs/public/dist/components--pullquote.boot-Q2WMNAZU.js +22 -0
- package/docs/public/dist/components--radio.boot-TJRDQ2OL.js +75 -0
- package/docs/public/dist/components--rating.boot-QBAN6DEL.js +38 -0
- package/docs/public/dist/components--search.boot-PXH5O5AG.js +17 -0
- package/docs/public/dist/components--section.boot-AQGIYHWW.js +12 -0
- package/docs/public/dist/components--segmented.boot-BEVTKEJO.js +33 -0
- package/docs/public/dist/components--select.boot-47X5RHOC.js +10 -0
- package/docs/public/dist/components--slider.boot-PSRRX7XL.js +47 -0
- package/docs/public/dist/components--spinner.boot-MZ5MO2OH.js +22 -0
- package/docs/public/dist/components--stack.boot-DI4NJXBF.js +9 -0
- package/docs/public/dist/components--stat.boot-QMFUWBQT.js +9 -0
- package/docs/public/dist/components--stepper.boot-34PP2NEV.js +22 -0
- package/docs/public/dist/components--table.boot-FCQGSFIQ.js +11 -0
- package/docs/public/dist/components--testimonial.boot-DWQPDKYG.js +11 -0
- package/docs/public/dist/components--textarea.boot-QVXLBOJ5.js +4 -0
- package/docs/public/dist/components--timeline.boot-26LN52P2.js +95 -0
- package/docs/public/dist/components--toggle.boot-IQQEI76S.js +29 -0
- package/docs/public/dist/components--tooltip.boot-LGHCO6NN.js +9 -0
- package/docs/public/dist/components.boot-SE6PQ4P7.js +103 -0
- package/docs/public/dist/config.boot-DTRRWUE6.js +126 -0
- package/docs/public/dist/constraints.boot-DUHDZBMC.js +71 -0
- package/docs/public/dist/deploy.boot-SLAD3NI2.js +163 -0
- package/docs/public/dist/docs-8e3d4b5c.css +1 -0
- package/docs/public/dist/extending.boot-UA3CN243.js +159 -0
- package/docs/public/dist/faq.boot-6EQAWLQR.js +43 -0
- package/docs/public/dist/getting-started.boot-TDKIFL5U.js +86 -0
- package/docs/public/dist/guard.boot-AUHAWTG4.js +80 -0
- package/docs/public/dist/home.boot-BVQXRH32.js +383 -0
- package/docs/public/dist/how-it-works.boot-LTWAKWKW.js +104 -0
- package/docs/public/dist/hydration.boot-JRM6IPJL.js +78 -0
- package/docs/public/dist/images.boot-M6ZVKTZS.js +80 -0
- package/docs/public/dist/manifest.json +94 -0
- package/docs/public/dist/meta.boot-7NXGPHR4.js +79 -0
- package/docs/public/dist/mutations.boot-F6F43UDX.js +79 -0
- package/docs/public/dist/navigation.boot-AOXWS3ZF.js +57 -0
- package/docs/public/dist/performance.boot-C3UPCOBK.js +98 -0
- package/docs/public/dist/persist.boot-WT32PQOQ.js +61 -0
- package/docs/public/dist/project-structure.boot-FB3LRVJ4.js +63 -0
- package/docs/public/dist/prompt-examples.boot-YKR4VDK4.js +31 -0
- package/docs/public/dist/pulse-ui-81a85c03.css +1 -0
- package/docs/public/dist/raw-responses.boot-M4KA5YXL.js +104 -0
- package/docs/public/dist/routing.boot-FNX5FDGH.js +70 -0
- package/docs/public/dist/runtime-B73WLANC.js +1 -0
- package/docs/public/dist/runtime-KO4BHUQ3.js +49 -0
- package/docs/public/dist/runtime-L2HNXIHW.js +59 -0
- package/docs/public/dist/runtime-QFURDKA2.js +5 -0
- package/docs/public/dist/runtime-UVPXO4IR.js +375 -0
- package/docs/public/dist/runtime-VMJA3Z4N.js +10 -0
- package/docs/public/dist/runtime-ZJ4FXT5O.js +11 -0
- package/docs/public/dist/server-api.boot-K7X3LCFB.js +219 -0
- package/docs/public/dist/server-data.boot-Y7HQYC4R.js +157 -0
- package/docs/public/dist/slash-commands.boot-V2UV7OW2.js +26 -0
- package/docs/public/dist/spec.boot-2WU7ZHCV.js +159 -0
- package/docs/public/dist/state.boot-B24GUE3R.js +73 -0
- package/docs/public/dist/store.boot-TLIB4XHH.js +150 -0
- package/docs/public/dist/streaming.boot-W2DZSMW4.js +80 -0
- package/docs/public/dist/stripe.boot-QN3C2GEL.js +164 -0
- package/docs/public/dist/supabase.boot-BG4XXLZE.js +303 -0
- package/docs/public/dist/testing.boot-6U4WKMTE.js +130 -0
- package/docs/public/dist/validation.boot-PQHYGW5B.js +100 -0
- package/docs/public/docs.css +2020 -0
- package/docs/public/menu.js +83 -0
- package/docs/public/pulse-ui.css +2739 -0
- package/docs/public/pulse-ui.js +236 -0
- package/docs/server.js +192 -0
- package/docs/src/lib/component-page.js +47 -0
- package/docs/src/lib/highlight.js +255 -0
- package/docs/src/lib/layout.js +131 -0
- package/docs/src/lib/metrics-store.js +6 -0
- package/docs/src/lib/nav.js +159 -0
- package/docs/src/lib/stats.js +81 -0
- package/docs/src/pages/accessibility.js +157 -0
- package/docs/src/pages/actions.js +191 -0
- package/docs/src/pages/auth.js +177 -0
- package/docs/src/pages/caching.js +95 -0
- package/docs/src/pages/components/accordion.js +48 -0
- package/docs/src/pages/components/alert.js +35 -0
- package/docs/src/pages/components/app-badge.js +41 -0
- package/docs/src/pages/components/avatar.js +35 -0
- package/docs/src/pages/components/badge.js +36 -0
- package/docs/src/pages/components/banner.js +45 -0
- package/docs/src/pages/components/breadcrumbs.js +94 -0
- package/docs/src/pages/components/button.js +84 -0
- package/docs/src/pages/components/card.js +225 -0
- package/docs/src/pages/components/carousel.js +72 -0
- package/docs/src/pages/components/charts.js +278 -0
- package/docs/src/pages/components/checkbox.js +129 -0
- package/docs/src/pages/components/cluster.js +47 -0
- package/docs/src/pages/components/code-window.js +57 -0
- package/docs/src/pages/components/container.js +40 -0
- package/docs/src/pages/components/cta.js +53 -0
- package/docs/src/pages/components/divider.js +37 -0
- package/docs/src/pages/components/empty.js +36 -0
- package/docs/src/pages/components/feature.js +60 -0
- package/docs/src/pages/components/fieldset.js +65 -0
- package/docs/src/pages/components/fileupload.js +127 -0
- package/docs/src/pages/components/footer.js +58 -0
- package/docs/src/pages/components/grid.js +165 -0
- package/docs/src/pages/components/heading.js +107 -0
- package/docs/src/pages/components/hero.js +65 -0
- package/docs/src/pages/components/icons.js +285 -0
- package/docs/src/pages/components/image.js +71 -0
- package/docs/src/pages/components/input.js +51 -0
- package/docs/src/pages/components/list.js +112 -0
- package/docs/src/pages/components/media.js +51 -0
- package/docs/src/pages/components/modal.js +111 -0
- package/docs/src/pages/components/nav.js +86 -0
- package/docs/src/pages/components/pricing.js +68 -0
- package/docs/src/pages/components/progress.js +102 -0
- package/docs/src/pages/components/prose.js +111 -0
- package/docs/src/pages/components/pullquote.js +71 -0
- package/docs/src/pages/components/radio.js +194 -0
- package/docs/src/pages/components/rating.js +106 -0
- package/docs/src/pages/components/search.js +61 -0
- package/docs/src/pages/components/section.js +59 -0
- package/docs/src/pages/components/segmented.js +121 -0
- package/docs/src/pages/components/select.js +45 -0
- package/docs/src/pages/components/slider.js +114 -0
- package/docs/src/pages/components/spinner.js +73 -0
- package/docs/src/pages/components/stack.js +48 -0
- package/docs/src/pages/components/stat.js +55 -0
- package/docs/src/pages/components/stepper.js +66 -0
- package/docs/src/pages/components/table.js +45 -0
- package/docs/src/pages/components/testimonial.js +49 -0
- package/docs/src/pages/components/textarea.js +31 -0
- package/docs/src/pages/components/timeline.js +227 -0
- package/docs/src/pages/components/toggle.js +84 -0
- package/docs/src/pages/components/tooltip.js +48 -0
- package/docs/src/pages/components.js +204 -0
- package/docs/src/pages/config.js +193 -0
- package/docs/src/pages/constraints.js +99 -0
- package/docs/src/pages/deploy.js +233 -0
- package/docs/src/pages/extending.js +198 -0
- package/docs/src/pages/faq.js +96 -0
- package/docs/src/pages/getting-started.js +106 -0
- package/docs/src/pages/guard.js +121 -0
- package/docs/src/pages/home.js +401 -0
- package/docs/src/pages/how-it-works.js +183 -0
- package/docs/src/pages/hydration.js +98 -0
- package/docs/src/pages/images.js +121 -0
- package/docs/src/pages/meta.js +120 -0
- package/docs/src/pages/mutations.js +106 -0
- package/docs/src/pages/navigation.js +85 -0
- package/docs/src/pages/performance.js +157 -0
- package/docs/src/pages/persist.js +88 -0
- package/docs/src/pages/project-structure.js +90 -0
- package/docs/src/pages/prompt-examples.js +186 -0
- package/docs/src/pages/raw-responses.js +124 -0
- package/docs/src/pages/routing.js +99 -0
- package/docs/src/pages/server-api.js +281 -0
- package/docs/src/pages/server-data.js +185 -0
- package/docs/src/pages/slash-commands.js +55 -0
- package/docs/src/pages/spec.js +207 -0
- package/docs/src/pages/state.js +101 -0
- package/docs/src/pages/store.js +181 -0
- package/docs/src/pages/streaming.js +108 -0
- package/docs/src/pages/stripe.js +193 -0
- package/docs/src/pages/supabase.js +323 -0
- package/docs/src/pages/testing.js +198 -0
- package/docs/src/pages/validation.js +138 -0
- package/examples/contact.js +166 -0
- package/examples/counter.js +94 -0
- package/examples/dev.server.js +91 -0
- package/examples/examples.test.js +394 -0
- package/examples/pricing.js +244 -0
- package/examples/products.js +191 -0
- package/examples/quiz.js +208 -0
- package/examples/shared.js +78 -0
- package/examples/todos.js +162 -0
- package/package.json +75 -0
- package/public/.pulse-ui-version +1 -0
- package/public/chippy-bird.css +246 -0
- package/public/examples/contact.css +119 -0
- package/public/examples/counter.css +79 -0
- package/public/examples/pricing.css +132 -0
- package/public/examples/products.css +100 -0
- package/public/examples/quiz.css +200 -0
- package/public/examples/todos.css +137 -0
- package/public/favicon.ico +0 -0
- package/public/log-dashboard.css +383 -0
- package/public/pulse-ui.css +2740 -0
- package/public/pulse-ui.js +236 -0
- package/public/pulse.css +149 -0
- package/scripts/build.js +411 -0
- package/src/agent/checklist.md +111 -0
- package/src/agent/coverage-check.js +66 -0
- package/src/agent/guide-components.md +274 -0
- package/src/agent/guide-examples.md +54 -0
- package/src/agent/guide-routing.md +36 -0
- package/src/agent/guide-server.md +258 -0
- package/src/agent/guide-spec.md +103 -0
- package/src/agent/guide-styles.md +191 -0
- package/src/agent/guide.md +979 -0
- package/src/agent/identity.md +106 -0
- package/src/agent/workflow.md +108 -0
- package/src/cli/cli.test.js +82 -0
- package/src/cli/dev.js +195 -0
- package/src/cli/discover.js +113 -0
- package/src/cli/index.js +361 -0
- package/src/cli/load-report.js +91 -0
- package/src/cli/load-runner.js +121 -0
- package/src/cli/report-server.js +723 -0
- package/src/cli/report.js +116 -0
- package/src/cli/scaffold.archive.js +1371 -0
- package/src/cli/scaffold.js +349 -0
- package/src/cli/start.js +74 -0
- package/src/html.js +19 -0
- package/src/mcp/server.js +884 -0
- package/src/mcp/validate-worker.js +110 -0
- package/src/runtime/image.js +74 -0
- package/src/runtime/image.test.js +111 -0
- package/src/runtime/index.js +621 -0
- package/src/runtime/navigate.js +146 -0
- package/src/runtime/runtime.test.js +773 -0
- package/src/runtime/ssr.js +464 -0
- package/src/runtime/ssr.test.js +421 -0
- package/src/runtime/store.js +92 -0
- package/src/runtime/toast.js +163 -0
- package/src/server/index.js +1386 -0
- package/src/server/server.test.js +1248 -0
- package/src/spec/schema.js +428 -0
- package/src/spec/schema.test.js +291 -0
- package/src/store/index.js +102 -0
- package/src/store/store.test.js +210 -0
- package/src/testing/html.js +283 -0
- package/src/testing/index.js +249 -0
- package/src/testing/testing.test.js +450 -0
- package/src/ui/accordion.js +28 -0
- package/src/ui/alert.js +43 -0
- package/src/ui/app-badge.js +48 -0
- package/src/ui/avatar.js +47 -0
- package/src/ui/badge.js +24 -0
- package/src/ui/banner.js +26 -0
- package/src/ui/breadcrumbs.js +38 -0
- package/src/ui/button.js +66 -0
- package/src/ui/card.js +34 -0
- package/src/ui/carousel.js +59 -0
- package/src/ui/charts.js +321 -0
- package/src/ui/checkbox.js +65 -0
- package/src/ui/cluster.js +44 -0
- package/src/ui/code-window.js +39 -0
- package/src/ui/container.js +24 -0
- package/src/ui/cta.js +37 -0
- package/src/ui/divider.js +29 -0
- package/src/ui/empty.js +33 -0
- package/src/ui/feature.js +33 -0
- package/src/ui/fieldset.js +37 -0
- package/src/ui/fileupload.js +89 -0
- package/src/ui/footer.js +38 -0
- package/src/ui/grid.js +36 -0
- package/src/ui/heading.js +45 -0
- package/src/ui/hero.js +37 -0
- package/src/ui/icons.js +161 -0
- package/src/ui/index.js +89 -0
- package/src/ui/input.js +74 -0
- package/src/ui/list.js +36 -0
- package/src/ui/media.js +44 -0
- package/src/ui/modal.js +80 -0
- package/src/ui/nav.js +61 -0
- package/src/ui/pricing.js +56 -0
- package/src/ui/progress.js +62 -0
- package/src/ui/prose.js +29 -0
- package/src/ui/pullquote.js +34 -0
- package/src/ui/radio.js +102 -0
- package/src/ui/rating.js +93 -0
- package/src/ui/search.js +77 -0
- package/src/ui/section.js +69 -0
- package/src/ui/segmented.js +50 -0
- package/src/ui/select.js +77 -0
- package/src/ui/slider.js +84 -0
- package/src/ui/spinner.js +34 -0
- package/src/ui/stack.js +36 -0
- package/src/ui/stat.js +52 -0
- package/src/ui/stepper.js +46 -0
- package/src/ui/switch.js +57 -0
- package/src/ui/table.js +45 -0
- package/src/ui/testimonial.js +48 -0
- package/src/ui/textarea.js +72 -0
- package/src/ui/timeline.js +72 -0
- package/src/ui/tooltip.js +28 -0
- package/src/ui/ui.test.js +1241 -0
- package/src/ui/uiimage.js +65 -0
- package/tsconfig.json +13 -0
- package/types/html.d.ts +17 -0
- package/types/image.d.ts +70 -0
- package/types/index.d.ts +7 -0
- package/types/navigate.d.ts +38 -0
- package/types/runtime.d.ts +63 -0
- package/types/schema.d.ts +243 -0
- package/types/server.d.ts +145 -0
- package/types/ssr.d.ts +110 -0
- package/types/testing.d.ts +154 -0
- package/types/ui.d.ts +704 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse 2 — Spec Schema
|
|
3
|
+
*
|
|
4
|
+
* This is the authoritative definition of a valid Pulse spec.
|
|
5
|
+
* Everything the runtime, SSR renderer, streaming layer, and AI layer
|
|
6
|
+
* are built against this contract.
|
|
7
|
+
*
|
|
8
|
+
* A spec is a plain JS object. It is the source of truth for a route.
|
|
9
|
+
* Human-readable code (React, Svelte, etc.) is generated FROM this — never the reverse.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Types (JSDoc — no build step, no TypeScript compiler needed)
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} StreamConfig
|
|
18
|
+
* Shell segments render immediately on first flush.
|
|
19
|
+
* Deferred segments render after their server data resolves.
|
|
20
|
+
*
|
|
21
|
+
* @property {string[]} shell - Segment keys to render in the first flush
|
|
22
|
+
* @property {string[]} [deferred] - Segment keys to render after async data
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} ValidationRule
|
|
27
|
+
* Declared once. Enforced at both ends — browser before submit, server before action.
|
|
28
|
+
*
|
|
29
|
+
* @property {boolean} [required]
|
|
30
|
+
* @property {number} [minLength]
|
|
31
|
+
* @property {number} [maxLength]
|
|
32
|
+
* @property {number} [min]
|
|
33
|
+
* @property {number} [max]
|
|
34
|
+
* @property {'email'|'url'|'numeric'} [format]
|
|
35
|
+
* @property {RegExp} [pattern]
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {Object} ActionConfig
|
|
40
|
+
* Actions cross the server/client boundary. They are always async.
|
|
41
|
+
* The runtime handles the network request — the spec just declares the intent.
|
|
42
|
+
*
|
|
43
|
+
* @property {boolean} [validate] - Run validation before executing. Default false.
|
|
44
|
+
* @property {function} run - async (state, serverState?) => void
|
|
45
|
+
* @property {function} onSuccess - (state) => Partial<state>
|
|
46
|
+
* @property {function} onError - (state, error) => Partial<state>
|
|
47
|
+
* @property {function} [onStart] - (state) => Partial<state> — optimistic update
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {Object} PulseSpec
|
|
52
|
+
* The complete definition of a route.
|
|
53
|
+
*
|
|
54
|
+
* @property {string} route - URL pattern e.g. '/contact', '/products/:id'
|
|
55
|
+
* @property {StreamConfig} [stream] - Streaming priority config. Default: render all in shell.
|
|
56
|
+
*
|
|
57
|
+
* @property {Object.<string, function>} [server]
|
|
58
|
+
* Async functions executed on the server. Results serialised into the initial HTML.
|
|
59
|
+
* Each key becomes available as the second argument to view functions.
|
|
60
|
+
* e.g. { products: async (req) => db.products.findAll() }
|
|
61
|
+
*
|
|
62
|
+
* @property {Object} state
|
|
63
|
+
* Initial client state. Plain object — no special types, no proxies.
|
|
64
|
+
* This is the only mutable state in the system. All mutations go through
|
|
65
|
+
* the mutations map — never direct assignment.
|
|
66
|
+
*
|
|
67
|
+
* @property {Object.<string, ValidationRule>} [validation]
|
|
68
|
+
* Dot-notation paths into state mapped to validation rules.
|
|
69
|
+
* e.g. { 'fields.email': { required: true, format: 'email' } }
|
|
70
|
+
*
|
|
71
|
+
* @property {Object.<string, function>|function} view
|
|
72
|
+
* Pure functions: (clientState, serverState?) => HTML string.
|
|
73
|
+
* Can be a single function for simple components, or a map of named
|
|
74
|
+
* segments for routes that use streaming.
|
|
75
|
+
* MUST be pure — no side effects, no async, deterministic output.
|
|
76
|
+
*
|
|
77
|
+
* @property {Object.<string, function>} [mutations]
|
|
78
|
+
* Pure functions: (state, payload?) => Partial<state>
|
|
79
|
+
* Each key matches a data-event attribute on a DOM element.
|
|
80
|
+
* e.g. { increment: (state) => ({ count: state.count + 1 }) }
|
|
81
|
+
* Return only the keys that change — runtime merges with current state.
|
|
82
|
+
*
|
|
83
|
+
* @property {Object.<string, ActionConfig>} [actions]
|
|
84
|
+
* Async operations that cross the server/client boundary.
|
|
85
|
+
* Triggered by data-action attributes on form elements.
|
|
86
|
+
*
|
|
87
|
+
* @property {Object} [meta]
|
|
88
|
+
* Page metadata — title, description, og tags etc.
|
|
89
|
+
* Used by the SSR renderer to populate <head>.
|
|
90
|
+
* Any value can be a function (ctx) => value for per-request resolution,
|
|
91
|
+
* useful for multi-brand sites where title, styles, or og tags vary by domain.
|
|
92
|
+
* e.g. { title: (ctx) => ctx.brand.name, styles: (ctx) => ['/themes/' + ctx.brand.slug + '.css'] }
|
|
93
|
+
* theme: 'light' adds data-theme="light" to <body>, activating the built-in light token set.
|
|
94
|
+
*
|
|
95
|
+
* @property {Object} [constraints]
|
|
96
|
+
* Runtime constraints on state values.
|
|
97
|
+
* Applied after every mutation — state can never violate these.
|
|
98
|
+
* e.g. { 'count': { min: 0, max: 10 } }
|
|
99
|
+
*
|
|
100
|
+
* @property {string} [hydrate]
|
|
101
|
+
* Browser-importable path to this spec file (must have a default export).
|
|
102
|
+
* When set, the SSR renderer injects a bootstrap <script> that calls mount()
|
|
103
|
+
* after the server-rendered HTML arrives, resuming interactivity.
|
|
104
|
+
* e.g. '/examples/counter.js'
|
|
105
|
+
*
|
|
106
|
+
* @property {function} [guard]
|
|
107
|
+
* Optional async function called before server data fetchers on every request.
|
|
108
|
+
* Return { redirect: '/login' } to deny access and redirect the user.
|
|
109
|
+
* Return nothing (or undefined) to allow the request to proceed.
|
|
110
|
+
* e.g. async (ctx) => { if (!ctx.cookies.session) return { redirect: '/login' } }
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Validation
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Validate a spec object against the schema.
|
|
119
|
+
* Returns { valid: true } or { valid: false, errors: string[] }
|
|
120
|
+
*
|
|
121
|
+
* @param {unknown} spec
|
|
122
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
123
|
+
*/
|
|
124
|
+
export function validateSpec(spec) {
|
|
125
|
+
const errors = []
|
|
126
|
+
const warnings = []
|
|
127
|
+
|
|
128
|
+
if (!spec || typeof spec !== 'object') {
|
|
129
|
+
return { valid: false, errors: ['Spec must be a plain object'] }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// route
|
|
133
|
+
if (spec.route === undefined) {
|
|
134
|
+
warnings.push('spec.route is not set — the server cannot register this page without a route')
|
|
135
|
+
} else if (typeof spec.route !== 'string') {
|
|
136
|
+
errors.push('spec.route must be a string (e.g. "/contact")')
|
|
137
|
+
} else if (!spec.route.startsWith('/')) {
|
|
138
|
+
errors.push('spec.route must start with "/" (e.g. "/contact")')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// contentType — marks a raw content spec (RSS, sitemap, JSON API, etc.)
|
|
142
|
+
if (spec.contentType !== undefined) {
|
|
143
|
+
if (typeof spec.contentType !== 'string' || !spec.contentType.trim()) {
|
|
144
|
+
errors.push('spec.contentType must be a non-empty string (e.g. "application/rss+xml; charset=utf-8")')
|
|
145
|
+
}
|
|
146
|
+
if (typeof spec.render !== 'function') {
|
|
147
|
+
errors.push('spec.render is required when spec.contentType is set — (ctx, server) => string')
|
|
148
|
+
}
|
|
149
|
+
// Raw specs don't need state or view — skip those checks
|
|
150
|
+
return { valid: errors.length === 0, errors }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// state
|
|
154
|
+
if (spec.state === undefined) {
|
|
155
|
+
errors.push('spec.state is required — use {} if there is no client state')
|
|
156
|
+
} else if (typeof spec.state !== 'object' || Array.isArray(spec.state)) {
|
|
157
|
+
errors.push('spec.state must be a plain object')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// view
|
|
161
|
+
if (!spec.view) {
|
|
162
|
+
errors.push('spec.view is required')
|
|
163
|
+
} else if (typeof spec.view !== 'function' && typeof spec.view !== 'object') {
|
|
164
|
+
errors.push('spec.view must be a function or a map of named segment functions')
|
|
165
|
+
} else if (typeof spec.view === 'object') {
|
|
166
|
+
for (const [key, fn] of Object.entries(spec.view)) {
|
|
167
|
+
if (typeof fn !== 'function') {
|
|
168
|
+
errors.push(`spec.view.${key} must be a function`)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// stream
|
|
174
|
+
if (spec.stream) {
|
|
175
|
+
if (!Array.isArray(spec.stream.shell)) {
|
|
176
|
+
errors.push('spec.stream.shell must be an array of segment key strings')
|
|
177
|
+
}
|
|
178
|
+
if (spec.stream.deferred && !Array.isArray(spec.stream.deferred)) {
|
|
179
|
+
errors.push('spec.stream.deferred must be an array of segment key strings')
|
|
180
|
+
}
|
|
181
|
+
// ensure streamed segments exist in view
|
|
182
|
+
if (typeof spec.view === 'object') {
|
|
183
|
+
const allSegments = [
|
|
184
|
+
...(spec.stream.shell || []),
|
|
185
|
+
...(spec.stream.deferred || [])
|
|
186
|
+
]
|
|
187
|
+
for (const seg of allSegments) {
|
|
188
|
+
if (!spec.view[seg]) {
|
|
189
|
+
errors.push(`spec.stream references "${seg}" but spec.view.${seg} is not defined`)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// server
|
|
196
|
+
if (spec.server) {
|
|
197
|
+
if (typeof spec.server !== 'object') {
|
|
198
|
+
errors.push('spec.server must be a plain object of async functions')
|
|
199
|
+
} else {
|
|
200
|
+
for (const [key, fn] of Object.entries(spec.server)) {
|
|
201
|
+
if (typeof fn !== 'function') {
|
|
202
|
+
errors.push(`spec.server.${key} must be a function`)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// mutations
|
|
209
|
+
if (spec.mutations) {
|
|
210
|
+
if (typeof spec.mutations !== 'object') {
|
|
211
|
+
errors.push('spec.mutations must be a plain object of functions')
|
|
212
|
+
} else {
|
|
213
|
+
for (const [key, fn] of Object.entries(spec.mutations)) {
|
|
214
|
+
if (typeof fn !== 'function') {
|
|
215
|
+
errors.push(`spec.mutations.${key} must be a function`)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// actions
|
|
222
|
+
if (spec.actions) {
|
|
223
|
+
if (typeof spec.actions !== 'object') {
|
|
224
|
+
errors.push('spec.actions must be a plain object')
|
|
225
|
+
} else {
|
|
226
|
+
for (const [key, action] of Object.entries(spec.actions)) {
|
|
227
|
+
if (typeof action.run !== 'function') {
|
|
228
|
+
errors.push(`spec.actions.${key}.run must be an async function`)
|
|
229
|
+
}
|
|
230
|
+
if (typeof action.onSuccess !== 'function') {
|
|
231
|
+
errors.push(`spec.actions.${key}.onSuccess must be a function`)
|
|
232
|
+
}
|
|
233
|
+
if (typeof action.onError !== 'function') {
|
|
234
|
+
errors.push(`spec.actions.${key}.onError must be a function`)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// validation
|
|
241
|
+
if (spec.validation) {
|
|
242
|
+
if (typeof spec.validation !== 'object') {
|
|
243
|
+
errors.push('spec.validation must be a plain object')
|
|
244
|
+
} else {
|
|
245
|
+
const validRuleKeys = ['required', 'minLength', 'maxLength', 'min', 'max', 'format', 'pattern']
|
|
246
|
+
for (const [path, rules] of Object.entries(spec.validation)) {
|
|
247
|
+
if (typeof rules !== 'object') {
|
|
248
|
+
errors.push(`spec.validation["${path}"] must be a plain object of rules`)
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
for (const key of Object.keys(rules)) {
|
|
252
|
+
if (!validRuleKeys.includes(key)) {
|
|
253
|
+
errors.push(`spec.validation["${path}"].${key} is not a recognised rule. Valid rules: ${validRuleKeys.join(', ')}`)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// constraints
|
|
261
|
+
if (spec.constraints) {
|
|
262
|
+
if (typeof spec.constraints !== 'object') {
|
|
263
|
+
errors.push('spec.constraints must be a plain object')
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// persist
|
|
268
|
+
if (spec.persist !== undefined) {
|
|
269
|
+
if (!Array.isArray(spec.persist) || !spec.persist.every(k => typeof k === 'string')) {
|
|
270
|
+
errors.push('spec.persist must be an array of state key strings, e.g. [\'count\']')
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// store — keys from the global store this page subscribes to
|
|
275
|
+
if (spec.store !== undefined) {
|
|
276
|
+
if (!Array.isArray(spec.store) || !spec.store.every(k => typeof k === 'string')) {
|
|
277
|
+
errors.push('spec.store must be an array of store key strings, e.g. [\'user\', \'settings\']')
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// onViewError — fallback renderer called when view() throws
|
|
282
|
+
if (spec.onViewError !== undefined) {
|
|
283
|
+
if (typeof spec.onViewError !== 'function') {
|
|
284
|
+
errors.push('spec.onViewError must be a function — (err, state, serverState) => htmlString')
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// methods — HTTP methods this page spec accepts (default GET + HEAD)
|
|
289
|
+
if (spec.methods !== undefined) {
|
|
290
|
+
const VALID_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
|
|
291
|
+
if (!Array.isArray(spec.methods) || !spec.methods.every(m => VALID_METHODS.includes(m.toUpperCase()))) {
|
|
292
|
+
errors.push(`spec.methods must be an array of HTTP method strings, e.g. ['GET', 'POST']. Valid values: ${VALID_METHODS.join(', ')}`)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// meta
|
|
297
|
+
if (spec.meta) {
|
|
298
|
+
if (typeof spec.meta !== 'object') {
|
|
299
|
+
errors.push('spec.meta must be a plain object')
|
|
300
|
+
} else if (spec.meta.schema !== undefined && typeof spec.meta.schema !== 'object') {
|
|
301
|
+
errors.push('spec.meta.schema must be a plain object (JSON-LD)')
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// serverTtl — seconds to cache server data in-process
|
|
306
|
+
if (spec.serverTtl !== undefined) {
|
|
307
|
+
if (typeof spec.serverTtl !== 'number' || spec.serverTtl <= 0) {
|
|
308
|
+
errors.push('spec.serverTtl must be a positive number (seconds)')
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// serverTimeout — ms before any server fetcher times out
|
|
313
|
+
if (spec.serverTimeout !== undefined) {
|
|
314
|
+
if (typeof spec.serverTimeout !== 'number' || spec.serverTimeout <= 0) {
|
|
315
|
+
errors.push('spec.serverTimeout must be a positive number (milliseconds)')
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// guard — per-route authorization check
|
|
320
|
+
if (spec.guard !== undefined) {
|
|
321
|
+
if (typeof spec.guard !== 'function') {
|
|
322
|
+
errors.push('spec.guard must be a function — async (ctx) => { redirect?: string }')
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// cache — HTTP cache-control for HTML responses
|
|
327
|
+
if (spec.cache !== undefined) {
|
|
328
|
+
if (typeof spec.cache !== 'object' || Array.isArray(spec.cache)) {
|
|
329
|
+
errors.push('spec.cache must be a plain object')
|
|
330
|
+
} else {
|
|
331
|
+
if (spec.cache.maxAge !== undefined && typeof spec.cache.maxAge !== 'number') {
|
|
332
|
+
errors.push('spec.cache.maxAge must be a number (seconds)')
|
|
333
|
+
}
|
|
334
|
+
if (spec.cache.staleWhileRevalidate !== undefined && typeof spec.cache.staleWhileRevalidate !== 'number') {
|
|
335
|
+
errors.push('spec.cache.staleWhileRevalidate must be a number (seconds)')
|
|
336
|
+
}
|
|
337
|
+
if (spec.cache.public !== undefined && typeof spec.cache.public !== 'boolean') {
|
|
338
|
+
errors.push('spec.cache.public must be a boolean')
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// hydrate — required whenever there is any client interactivity
|
|
344
|
+
if (!spec.hydrate && !spec.contentType) {
|
|
345
|
+
if (spec.mutations || spec.actions || spec.persist) {
|
|
346
|
+
warnings.push('spec.hydrate is missing — pages with mutations, actions, or persist need hydrate set to their browser-importable path, or client interactivity will silently do nothing')
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// meta quality
|
|
351
|
+
if (!spec.meta) {
|
|
352
|
+
warnings.push('spec.meta is missing — add at minimum title and description for SEO')
|
|
353
|
+
} else {
|
|
354
|
+
if (!spec.meta.title || (typeof spec.meta.title === 'string' && !spec.meta.title.trim())) {
|
|
355
|
+
warnings.push('spec.meta.title is missing')
|
|
356
|
+
}
|
|
357
|
+
const desc = spec.meta.description
|
|
358
|
+
if (!desc) {
|
|
359
|
+
warnings.push('spec.meta.description is missing — add a meaningful description for SEO')
|
|
360
|
+
} else if (typeof desc === 'string' && desc.trim() === 'Built with Pulse') {
|
|
361
|
+
warnings.push('spec.meta.description is still the default "Built with Pulse" — replace it with a real description')
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
valid: errors.length === 0,
|
|
367
|
+
errors,
|
|
368
|
+
warnings,
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
// Helpers
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Assert a spec is valid. Throws with all errors if not.
|
|
378
|
+
* Used by the runtime and SSR renderer on startup.
|
|
379
|
+
*
|
|
380
|
+
* @param {unknown} spec
|
|
381
|
+
* @throws {Error}
|
|
382
|
+
*/
|
|
383
|
+
export function assertValidSpec(spec) {
|
|
384
|
+
const { valid, errors, warnings } = validateSpec(spec)
|
|
385
|
+
if (!valid) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`Invalid Pulse spec${spec?.route ? ` for route "${spec.route}"` : ''}:\n` +
|
|
388
|
+
errors.map(e => ` — ${e}`).join('\n')
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
if (warnings.length > 0) {
|
|
392
|
+
console.warn(
|
|
393
|
+
`Pulse spec warnings${spec?.route ? ` for route "${spec.route}"` : ''}:\n` +
|
|
394
|
+
warnings.map(w => ` ⚠ ${w}`).join('\n')
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Get all segment keys defined in a spec's view.
|
|
401
|
+
* For a function view, returns ['default'].
|
|
402
|
+
* For a segmented view, returns the object keys.
|
|
403
|
+
*
|
|
404
|
+
* @param {PulseSpec} spec
|
|
405
|
+
* @returns {string[]}
|
|
406
|
+
*/
|
|
407
|
+
export function getViewSegments(spec) {
|
|
408
|
+
if (typeof spec.view === 'function') return ['default']
|
|
409
|
+
return Object.keys(spec.view)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Get the stream order for a spec.
|
|
414
|
+
* Returns { shell: string[], deferred: string[] }
|
|
415
|
+
* If no stream config, all segments are in shell.
|
|
416
|
+
*
|
|
417
|
+
* @param {PulseSpec} spec
|
|
418
|
+
* @returns {{ shell: string[], deferred: string[] }}
|
|
419
|
+
*/
|
|
420
|
+
export function getStreamOrder(spec) {
|
|
421
|
+
if (!spec.stream) {
|
|
422
|
+
return { shell: getViewSegments(spec), deferred: [] }
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
shell: spec.stream.shell || [],
|
|
426
|
+
deferred: spec.stream.deferred || []
|
|
427
|
+
}
|
|
428
|
+
}
|