@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
package/scripts/build.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse — Build script
|
|
3
|
+
*
|
|
4
|
+
* Auto-discovers pages from src/pages/, generates a self-executing bootstrap
|
|
5
|
+
* module per page, bundles everything with a shared runtime chunk.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* node scripts/build.js [--root /path/to/project]
|
|
9
|
+
*
|
|
10
|
+
* Output:
|
|
11
|
+
* public/dist/<name>-<hash>.js — minified self-executing bundle per page
|
|
12
|
+
* public/dist/runtime-<hash>.js — shared runtime chunk
|
|
13
|
+
* public/dist/manifest.json — maps source hydrate paths → bundle paths
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as esbuild from 'esbuild'
|
|
17
|
+
import fs from 'fs'
|
|
18
|
+
import path from 'path'
|
|
19
|
+
import { createHash } from 'crypto'
|
|
20
|
+
import { discoverPages } from '../src/cli/discover.js'
|
|
21
|
+
import { renderToString } from '../src/runtime/ssr.js'
|
|
22
|
+
|
|
23
|
+
// Project root — can be overridden via --root flag for CLI usage
|
|
24
|
+
const rootArg = process.argv.indexOf('--root')
|
|
25
|
+
const ROOT = rootArg !== -1
|
|
26
|
+
? path.resolve(process.argv[rootArg + 1])
|
|
27
|
+
: path.resolve(import.meta.dirname, '..')
|
|
28
|
+
|
|
29
|
+
const OUT_DIR = path.join(ROOT, 'public', 'dist')
|
|
30
|
+
const TMP_DIR = path.join(ROOT, '.pulse-build')
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Discover pages
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const pages = discoverPages(ROOT)
|
|
37
|
+
|
|
38
|
+
if (pages.length === 0) {
|
|
39
|
+
console.error('No pages found in src/pages/. Nothing to build.')
|
|
40
|
+
process.exit(1)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Clean output directories
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
for (const dir of [OUT_DIR, TMP_DIR]) {
|
|
48
|
+
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true })
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log('⚡ Building Pulse client bundles...\n')
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Generate bootstrap entry points
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
const RUNTIME_PATH = new URL('../src/runtime/index.js', import.meta.url).pathname
|
|
59
|
+
const NAVIGATE_PATH = new URL('../src/runtime/navigate.js', import.meta.url).pathname
|
|
60
|
+
|
|
61
|
+
const PAGES_DIR = path.join(ROOT, 'src', 'pages')
|
|
62
|
+
|
|
63
|
+
const bootstrapFiles = pages.map(({ filePath }) => {
|
|
64
|
+
// Use the path relative to src/pages/ so nested pages get unique names.
|
|
65
|
+
// e.g. src/pages/api/products.js → 'api--products' (not 'products')
|
|
66
|
+
const relToPages = path.relative(PAGES_DIR, filePath)
|
|
67
|
+
const name = relToPages.replace(/\.js$/, '').replace(/[\\/]/g, '--')
|
|
68
|
+
const bootstrapPath = path.join(TMP_DIR, `${name}.boot.js`)
|
|
69
|
+
const relSpec = path.relative(TMP_DIR, filePath)
|
|
70
|
+
const relRuntime = path.relative(TMP_DIR, RUNTIME_PATH)
|
|
71
|
+
const relNavigate = path.relative(TMP_DIR, NAVIGATE_PATH)
|
|
72
|
+
|
|
73
|
+
fs.writeFileSync(bootstrapPath, `\
|
|
74
|
+
import spec from '${relSpec}'
|
|
75
|
+
import { mount } from '${relRuntime}'
|
|
76
|
+
import { initNavigation } from '${relNavigate}'
|
|
77
|
+
|
|
78
|
+
const root = document.getElementById('pulse-root')
|
|
79
|
+
if (root && !root.dataset.pulseMounted) {
|
|
80
|
+
root.dataset.pulseMounted = '1'
|
|
81
|
+
mount(spec, root, window.__PULSE_SERVER__ || {}, { ssr: true })
|
|
82
|
+
initNavigation(root, mount)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default spec
|
|
86
|
+
`)
|
|
87
|
+
|
|
88
|
+
return { filePath, bootstrapPath, name }
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Bundle
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
const result = await esbuild.build({
|
|
96
|
+
entryPoints: bootstrapFiles.map(b => b.bootstrapPath),
|
|
97
|
+
bundle: true,
|
|
98
|
+
format: 'esm',
|
|
99
|
+
platform: 'browser',
|
|
100
|
+
outdir: OUT_DIR,
|
|
101
|
+
entryNames: '[name]-[hash]',
|
|
102
|
+
chunkNames: 'runtime-[hash]',
|
|
103
|
+
splitting: true,
|
|
104
|
+
minify: true,
|
|
105
|
+
metafile: true,
|
|
106
|
+
sourcemap: false,
|
|
107
|
+
treeShaking: true,
|
|
108
|
+
define: {
|
|
109
|
+
'process.env.NODE_ENV': '"production"'
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Build manifest — maps source hydrate paths → bundle paths
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
const manifest = {}
|
|
118
|
+
|
|
119
|
+
for (const [outFile, meta] of Object.entries(result.metafile.outputs)) {
|
|
120
|
+
const bundlePath = '/' + path.relative(path.join(ROOT, 'public'), path.join(ROOT, outFile))
|
|
121
|
+
|
|
122
|
+
// Shared runtime chunk — esbuild generates this via splitting, no entryPoint
|
|
123
|
+
if (!meta.entryPoint && path.basename(outFile).startsWith('runtime-')) {
|
|
124
|
+
manifest['_runtime'] = bundlePath
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!meta.entryPoint) continue
|
|
129
|
+
|
|
130
|
+
const bootstrapName = path.basename(meta.entryPoint, '.boot.js')
|
|
131
|
+
const entry = bootstrapFiles.find(b => b.name === bootstrapName)
|
|
132
|
+
if (!entry) continue
|
|
133
|
+
|
|
134
|
+
// Hydrate key is the path the browser uses to import the spec
|
|
135
|
+
const sourceKey = '/' + path.relative(ROOT, entry.filePath)
|
|
136
|
+
manifest[sourceKey] = bundlePath
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
fs.writeFileSync(path.join(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2))
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Cleanup temp dir
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
fs.rmSync(TMP_DIR, { recursive: true })
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// CSS Purge — strip unused styles, write content-hashed CSS bundles
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
await purgeCssStep(pages, manifest, ROOT, OUT_DIR)
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Report
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
console.log('Bundles:\n')
|
|
158
|
+
for (const [src, bundle] of Object.entries(manifest)) {
|
|
159
|
+
if (bundle.startsWith('/dist/')) {
|
|
160
|
+
const filePath = path.join(ROOT, 'public', bundle)
|
|
161
|
+
if (fs.existsSync(filePath)) {
|
|
162
|
+
const size = fs.statSync(filePath).size
|
|
163
|
+
console.log(` ${src.padEnd(36)} → ${bundle} (${(size / 1024).toFixed(1)} kB)`)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
console.log('\n✓ manifest written to public/dist/manifest.json\n')
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// CSS Purge implementation
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
async function purgeCssStep(pages, manifest, root, outDir) {
|
|
174
|
+
console.log('⚡ Purging CSS...\n')
|
|
175
|
+
|
|
176
|
+
const htmlContents = []
|
|
177
|
+
const jsContents = []
|
|
178
|
+
const cssFiles = new Set()
|
|
179
|
+
|
|
180
|
+
for (const { filePath } of pages) {
|
|
181
|
+
try {
|
|
182
|
+
const mod = await import(filePath)
|
|
183
|
+
const spec = mod.default
|
|
184
|
+
if (!spec || typeof spec !== 'object') continue
|
|
185
|
+
|
|
186
|
+
// Collect CSS files referenced by this page
|
|
187
|
+
if (Array.isArray(spec.meta?.styles)) {
|
|
188
|
+
for (const href of spec.meta.styles) cssFiles.add(href)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// SSR render to extract class names from the actual HTML output
|
|
192
|
+
try {
|
|
193
|
+
const { html } = await renderToString(spec, {})
|
|
194
|
+
htmlContents.push(html)
|
|
195
|
+
} catch {
|
|
196
|
+
// Server data might fail in build context — fall back to direct view call
|
|
197
|
+
try {
|
|
198
|
+
const html = spec.view(spec.state || {}, {})
|
|
199
|
+
if (typeof html === 'string') htmlContents.push(html)
|
|
200
|
+
} catch {}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Also scan source JS for conditionally-applied class names
|
|
204
|
+
jsContents.push(fs.readFileSync(filePath, 'utf8'))
|
|
205
|
+
} catch {}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Scan project components directory for conditional class names
|
|
209
|
+
const componentsDir = path.join(root, 'src', 'components')
|
|
210
|
+
if (fs.existsSync(componentsDir)) {
|
|
211
|
+
const componentFiles = fs.readdirSync(componentsDir).filter(f => f.endsWith('.js'))
|
|
212
|
+
for (const f of componentFiles) {
|
|
213
|
+
try { jsContents.push(fs.readFileSync(path.join(componentsDir, f), 'utf8')) } catch {}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (cssFiles.size === 0) {
|
|
218
|
+
console.log(' No CSS files referenced — skipping.\n')
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const usedClasses = extractUsedClasses(htmlContents, jsContents)
|
|
223
|
+
|
|
224
|
+
for (const cssHref of cssFiles) {
|
|
225
|
+
// CSS path is relative to public/ (e.g. '/pulse-ui.css' → public/pulse-ui.css)
|
|
226
|
+
const cssPath = path.join(root, 'public', cssHref.replace(/^\//, ''))
|
|
227
|
+
if (!fs.existsSync(cssPath)) continue
|
|
228
|
+
|
|
229
|
+
const original = fs.readFileSync(cssPath, 'utf8')
|
|
230
|
+
const purged = minifyCss(purgeCss(original, usedClasses))
|
|
231
|
+
|
|
232
|
+
const hash = createHash('sha256').update(purged).digest('hex').slice(0, 8)
|
|
233
|
+
const name = path.basename(cssHref, '.css')
|
|
234
|
+
const outName = `${name}-${hash}.css`
|
|
235
|
+
const outPath = path.join(outDir, outName)
|
|
236
|
+
|
|
237
|
+
fs.writeFileSync(outPath, purged)
|
|
238
|
+
|
|
239
|
+
const origKb = (Buffer.byteLength(original) / 1024).toFixed(1)
|
|
240
|
+
const purgKb = (Buffer.byteLength(purged) / 1024).toFixed(1)
|
|
241
|
+
const pct = Math.round((1 - Buffer.byteLength(purged) / Buffer.byteLength(original)) * 100)
|
|
242
|
+
|
|
243
|
+
console.log(` ${cssHref.padEnd(28)} → /dist/${outName} (${purgKb} kB, ${pct}% removed from ${origKb} kB)`)
|
|
244
|
+
|
|
245
|
+
manifest[cssHref] = `/dist/${outName}`
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Re-write manifest with CSS entries added
|
|
249
|
+
fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
|
|
250
|
+
console.log('\n✓ CSS entries added to manifest\n')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// CSS class extractor
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
function extractUsedClasses(htmlContents, jsContents) {
|
|
258
|
+
const used = new Set()
|
|
259
|
+
|
|
260
|
+
// Extract from class="..." in both rendered HTML and JS template literals
|
|
261
|
+
for (const content of [...htmlContents, ...jsContents]) {
|
|
262
|
+
const re = /class(?:Name)?=["']([^"'\n]+)["']/g
|
|
263
|
+
let m
|
|
264
|
+
while ((m = re.exec(content)) !== null) {
|
|
265
|
+
for (const cls of m[1].trim().split(/\s+/)) {
|
|
266
|
+
if (cls) used.add(cls)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Scan JS source for quoted strings that look like CSS class names (contain hyphens)
|
|
272
|
+
// Catches conditionally-applied classes like 'ui-btn--disabled' in ternaries
|
|
273
|
+
for (const js of jsContents) {
|
|
274
|
+
const re = /['"`]((?:[a-zA-Z][a-zA-Z0-9]*-[a-zA-Z0-9-]*)(?:\s+[a-zA-Z][a-zA-Z0-9-]*)*)['"`]/g
|
|
275
|
+
let m
|
|
276
|
+
while ((m = re.exec(js)) !== null) {
|
|
277
|
+
for (const cls of m[1].trim().split(/\s+/)) {
|
|
278
|
+
if (cls && cls.includes('-')) used.add(cls)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return used
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// CSS purger — strips unused rules, preserves @media / :root / element rules
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Parse CSS text into top-level blocks using a bracket-depth counter.
|
|
292
|
+
* Returns [{ type, selector?, prelude?, inner?, raw }]
|
|
293
|
+
*/
|
|
294
|
+
function parseCssBlocks(css) {
|
|
295
|
+
const blocks = []
|
|
296
|
+
let i = 0
|
|
297
|
+
const len = css.length
|
|
298
|
+
|
|
299
|
+
while (i < len) {
|
|
300
|
+
// Skip whitespace
|
|
301
|
+
while (i < len && /\s/.test(css[i])) i++
|
|
302
|
+
if (i >= len) break
|
|
303
|
+
|
|
304
|
+
// Skip block comments
|
|
305
|
+
if (css[i] === '/' && css[i + 1] === '*') {
|
|
306
|
+
const end = css.indexOf('*/', i + 2)
|
|
307
|
+
i = end === -1 ? len : end + 2
|
|
308
|
+
continue
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const start = i
|
|
312
|
+
let depth = 0
|
|
313
|
+
|
|
314
|
+
while (i < len) {
|
|
315
|
+
// Skip inline comments
|
|
316
|
+
if (css[i] === '/' && css[i + 1] === '*') {
|
|
317
|
+
const end = css.indexOf('*/', i + 2)
|
|
318
|
+
i = end === -1 ? len : end + 2
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
if (css[i] === '{') { depth++; i++; continue }
|
|
322
|
+
if (css[i] === '}') { depth--; i++; if (depth === 0) break; continue }
|
|
323
|
+
if (css[i] === ';' && depth === 0) { i++; break }
|
|
324
|
+
i++
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const raw = css.slice(start, i).trim()
|
|
328
|
+
if (!raw) continue
|
|
329
|
+
|
|
330
|
+
if (raw.startsWith('@')) {
|
|
331
|
+
const braceIdx = raw.indexOf('{')
|
|
332
|
+
if (braceIdx === -1) {
|
|
333
|
+
blocks.push({ type: 'at-simple', raw })
|
|
334
|
+
} else {
|
|
335
|
+
const prelude = raw.slice(0, braceIdx).trim()
|
|
336
|
+
const inner = raw.slice(braceIdx + 1, raw.lastIndexOf('}')).trim()
|
|
337
|
+
blocks.push({ type: 'at-block', prelude, inner, raw })
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
const braceIdx = raw.indexOf('{')
|
|
341
|
+
if (braceIdx !== -1) {
|
|
342
|
+
blocks.push({ type: 'rule', selector: raw.slice(0, braceIdx).trim(), raw })
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return blocks
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Return true if any selector in the rule should be kept.
|
|
352
|
+
* Keeps: element selectors, *, :root, and any rule where a used class appears.
|
|
353
|
+
*/
|
|
354
|
+
function selectorUsed(selector, usedClasses) {
|
|
355
|
+
const selectors = selector.split(',').map(s => s.trim())
|
|
356
|
+
|
|
357
|
+
for (const sel of selectors) {
|
|
358
|
+
// Strip pseudo-classes/elements and attribute selectors to find the base
|
|
359
|
+
const base = sel
|
|
360
|
+
.replace(/::?[a-z-]+(\([^)]*\))?/gi, '')
|
|
361
|
+
.replace(/\[[^\]]*\]/g, '')
|
|
362
|
+
.trim()
|
|
363
|
+
|
|
364
|
+
// Keep element selectors, *, :root — no class tokens
|
|
365
|
+
if (!base.includes('.')) return true
|
|
366
|
+
|
|
367
|
+
// Keep if any class in the selector is in the used set
|
|
368
|
+
const classes = [...sel.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)/g)].map(m => m[1])
|
|
369
|
+
if (classes.some(cls => usedClasses.has(cls))) return true
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return false
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Purge unused CSS rules from a CSS string.
|
|
377
|
+
* Recursively processes @media and @supports blocks.
|
|
378
|
+
*/
|
|
379
|
+
function minifyCss(css) {
|
|
380
|
+
return css
|
|
381
|
+
.replace(/\/\*[\s\S]*?\*\//g, '') // strip comments
|
|
382
|
+
.replace(/\s+/g, ' ') // collapse whitespace
|
|
383
|
+
.replace(/\s*([{}:;,>~+])\s*/g, '$1') // remove spaces around punctuation
|
|
384
|
+
.replace(/;}/g, '}') // remove trailing semicolons
|
|
385
|
+
.trim()
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function purgeCss(cssText, usedClasses) {
|
|
389
|
+
const blocks = parseCssBlocks(cssText)
|
|
390
|
+
const kept = []
|
|
391
|
+
|
|
392
|
+
for (const block of blocks) {
|
|
393
|
+
if (block.type === 'at-simple') {
|
|
394
|
+
kept.push(block.raw)
|
|
395
|
+
} else if (block.type === 'at-block') {
|
|
396
|
+
const p = block.prelude.toLowerCase()
|
|
397
|
+
if (/^@(?:keyframes|font-face|charset)/.test(p)) {
|
|
398
|
+
kept.push(block.raw)
|
|
399
|
+
} else if (/^@(?:media|supports|layer)/.test(p)) {
|
|
400
|
+
const innerPurged = purgeCss(block.inner, usedClasses)
|
|
401
|
+
if (innerPurged.trim()) kept.push(`${block.prelude} {\n${innerPurged}\n}`)
|
|
402
|
+
} else {
|
|
403
|
+
kept.push(block.raw) // unknown at-blocks — keep to be safe
|
|
404
|
+
}
|
|
405
|
+
} else if (block.type === 'rule') {
|
|
406
|
+
if (selectorUsed(block.selector, usedClasses)) kept.push(block.raw)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return kept.join('\n\n')
|
|
411
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
## Spec review checklist
|
|
2
|
+
|
|
3
|
+
Before finishing any spec, verify every point below. Fix anything that fails.
|
|
4
|
+
|
|
5
|
+
### Critical
|
|
6
|
+
|
|
7
|
+
- **`hydrate` is set on every interactive page.** Without it, `data-event` / `data-action` bindings do nothing, `persist` never runs, and client-side navigation cannot re-mount the page. Every spec with `mutations`, `actions`, or `persist` must include:
|
|
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.
|
|
12
|
+
|
|
13
|
+
### Components first
|
|
14
|
+
|
|
15
|
+
- **Before writing any HTML by hand, check `src/ui/index.js`.** There are 50+ components. Use `button`, `card`, `alert`, `input`, `spinner`, `badge`, `modal`, `nav`, `pagination`, `table`, etc. before writing equivalent HTML from scratch.
|
|
16
|
+
|
|
17
|
+
### Reuse (DRY)
|
|
18
|
+
|
|
19
|
+
- **Extract a view helper when the same HTML pattern appears 3 or more times in a single spec.** A plain JS function returning an HTML string is sufficient — no framework needed:
|
|
20
|
+
```js
|
|
21
|
+
const card = ({ title, body }) => `<div class="card"><h3>${title}</h3><p>${body}</p></div>`
|
|
22
|
+
```
|
|
23
|
+
- **Create a shared component in `src/ui/` when the same pattern is needed across 2 or more different specs.** Follow the existing pattern: a named export that returns an HTML string.
|
|
24
|
+
- **Do not abstract a pattern that appears only once.** Duplication is cheaper than the wrong abstraction. Wait until the third use before extracting.
|
|
25
|
+
|
|
26
|
+
### Correctness
|
|
27
|
+
|
|
28
|
+
- Mutations return plain partial-state objects and have no side effects (no fetch, no DOM access).
|
|
29
|
+
- `persist` contains only serialisable state that should survive a page reload — not ephemeral UI state like a loading flag or temporary selection.
|
|
30
|
+
- `e.target` assumptions in mutations are safe if the element has child nodes — use `e.target.closest('[data-index]')` rather than assuming `e.target` is the element with the attribute.
|
|
31
|
+
- State shape is consistent — avoid a single field that is sometimes `null`, sometimes a string, sometimes a boolean. Use a dedicated `status` field instead.
|
|
32
|
+
- **Never use `state.modalOpen` or conditional modal rendering.** This destroys the `<dialog>` on every render, breaking focus, animation, and native ESC handling. Instead, always render the `<dialog>` in the DOM and use `data-dialog-open="id"` to open it — the runtime handles this without any spec state:
|
|
33
|
+
```html
|
|
34
|
+
<!-- always in the view, never conditional -->
|
|
35
|
+
${modal({ id: 'confirm', title: 'Confirm', content: '...' })}
|
|
36
|
+
<!-- anywhere on the page — opens the dialog, no mutation needed -->
|
|
37
|
+
${modalTrigger({ target: 'confirm', label: 'Open' })}
|
|
38
|
+
<!-- or inline: -->
|
|
39
|
+
<button data-dialog-open="confirm">Open</button>
|
|
40
|
+
```
|
|
41
|
+
Close is handled natively by `<form method="dialog">` (inside the modal), ESC key, backdrop click, or `data-dialog-close` on any element.
|
|
42
|
+
|
|
43
|
+
### Defensive data handling
|
|
44
|
+
|
|
45
|
+
- **Always check `res.ok` before parsing fetch responses.** Never call `res.json()` on a response that may have failed. Never use `await res.text()` as an error message — on a 404 or 500, this returns raw HTML which surfaces directly in toasts and error alerts. The correct pattern:
|
|
46
|
+
```js
|
|
47
|
+
const res = await fetch('/api/...')
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
let message = `Request failed: ${res.status}`
|
|
50
|
+
try { const j = await res.json(); message = j.message || j.error || message } catch {}
|
|
51
|
+
throw new Error(message)
|
|
52
|
+
}
|
|
53
|
+
return await res.json()
|
|
54
|
+
```
|
|
55
|
+
- **Never assume the shape of data from external APIs or server fetchers.** Use optional chaining (`?.`) and nullish coalescing (`??`) at every access point. If a server fetcher can return `null`, the view must handle it — `server.user?.name ?? 'Guest'` not `server.user.name`.
|
|
56
|
+
- **Validate FormData fields before use.** `formData.get('email')` returns `null` if the field is missing. Check for null/empty before passing to an API or database.
|
|
57
|
+
- **Do not trust URL params.** `ctx.params.id` is a raw string from the URL. Validate it before use — check it exists, is the right type, and refers to a real resource. Return a 404 or redirect if it doesn't.
|
|
58
|
+
|
|
59
|
+
### Security
|
|
60
|
+
|
|
61
|
+
- Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
|
|
62
|
+
|
|
63
|
+
### Tests
|
|
64
|
+
|
|
65
|
+
- Pure logic functions extracted from a spec (e.g. `checkResult`, `validate`, `formatPrice`) must have unit tests in a corresponding `.test.js` file.
|
|
66
|
+
- **When fixing a bug, write a failing test first.** The test must reproduce the bug before the fix is applied, then pass after. This pins the behaviour so the bug cannot silently return. A fix without a regression test is incomplete.
|
|
67
|
+
- **Use `renderSync` / `render` from `@invisibleloop/pulse/testing` to test view HTML output.** Do not test views with raw `html.includes()` — use the query helpers instead:
|
|
68
|
+
```js
|
|
69
|
+
import { renderSync, render } from '@invisibleloop/pulse/testing'
|
|
70
|
+
|
|
71
|
+
// Sync — call view directly with mock state/server
|
|
72
|
+
const result = renderSync(mySpec, { state: { count: 5 }, server: { items: [] } })
|
|
73
|
+
assert(result.has('button'))
|
|
74
|
+
assert.equal(result.get('#count').text, '5')
|
|
75
|
+
assert.equal(result.count('li'), 0)
|
|
76
|
+
|
|
77
|
+
// Async — run real server fetchers (integration), or pass server to skip them
|
|
78
|
+
const result = await render(mySpec, { server: { product: mockProduct } })
|
|
79
|
+
assert.equal(result.get('h1').text, mockProduct.name)
|
|
80
|
+
assert.equal(result.attr('img', 'src'), mockProduct.image)
|
|
81
|
+
```
|
|
82
|
+
Supported selectors: `tag`, `.class`, `#id`, `[attr]`, `[attr="value"]`, and combinations (`button.primary[disabled]`).
|
|
83
|
+
|
|
84
|
+
### View error handling
|
|
85
|
+
|
|
86
|
+
- **Define `onViewError` on any page where the view could throw due to bad or missing data.** Without it, a runtime view error returns a 500 on the server and shows a generic inline message on the client. With it, the server returns 200 with your fallback HTML instead of a 500, and the client renders your fallback:
|
|
87
|
+
```js
|
|
88
|
+
onViewError: (err, state, serverState) => `
|
|
89
|
+
<div class="u-p-4 u-text-center">
|
|
90
|
+
<p>Something went wrong. <a href="">Reload</a></p>
|
|
91
|
+
</div>
|
|
92
|
+
`
|
|
93
|
+
```
|
|
94
|
+
Use this on pages that render data from external APIs or user-supplied content — any path where the view can encounter `null`, `undefined`, or unexpected shapes that would cause a crash. It is not required on simple pages with predictable data.
|
|
95
|
+
|
|
96
|
+
### Store updates from actions
|
|
97
|
+
|
|
98
|
+
- **Use `_storeUpdate` to push changes to the global store from an action.** Return it from `onSuccess` alongside the local state update — it is stripped from page state and forwarded to the store. All mounted pages that subscribe to the affected keys re-render immediately:
|
|
99
|
+
```js
|
|
100
|
+
onSuccess: (state, theme) => ({
|
|
101
|
+
saved: true,
|
|
102
|
+
_storeUpdate: { settings: { theme } }, // ← merged into store state
|
|
103
|
+
}),
|
|
104
|
+
```
|
|
105
|
+
`_storeUpdate` only merges into the store — it does not appear in the page's own state. The rest of the return is merged into local state as normal. Use this instead of a full-page reload when a user action changes shared data (theme, cart count, user profile, etc.).
|
|
106
|
+
|
|
107
|
+
### Accessibility
|
|
108
|
+
|
|
109
|
+
- Interactive elements without visible text have an `aria-label`.
|
|
110
|
+
- Disabled state is reflected with the `disabled` attribute, not just CSS.
|
|
111
|
+
- The page has a `<main id="main-content">` landmark.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* coverage-check.js
|
|
4
|
+
*
|
|
5
|
+
* Called by the Stop hook in .claude/settings.json to enforce test coverage.
|
|
6
|
+
* Runs `npm run test:coverage` and blocks the agent if any src/pages/ file
|
|
7
|
+
* has uncovered lines — except async action run() and server fetcher functions
|
|
8
|
+
* which cannot be tested without mocking real APIs.
|
|
9
|
+
*
|
|
10
|
+
* Outputs a Claude Code hook JSON decision to stdout.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execSync } from 'child_process'
|
|
14
|
+
import fs from 'fs'
|
|
15
|
+
|
|
16
|
+
// No test files → nothing to check
|
|
17
|
+
const hasTests = fs.existsSync('src/pages') && (function scan(d) {
|
|
18
|
+
for (const f of fs.readdirSync(d)) {
|
|
19
|
+
const p = `${d}/${f}`
|
|
20
|
+
if (fs.statSync(p).isDirectory()) { if (scan(p)) return true }
|
|
21
|
+
else if (f.endsWith('.test.js')) return true
|
|
22
|
+
}
|
|
23
|
+
return false
|
|
24
|
+
})('src/pages')
|
|
25
|
+
|
|
26
|
+
if (!hasTests) process.exit(0)
|
|
27
|
+
|
|
28
|
+
let out = ''
|
|
29
|
+
try {
|
|
30
|
+
out = execSync('npm run test:coverage 2>&1', { encoding: 'utf8', timeout: 60000 })
|
|
31
|
+
} catch (e) {
|
|
32
|
+
out = e.stdout || e.message
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Parse the coverage report — look for lines with uncovered line numbers
|
|
36
|
+
const lines = out.split('\n')
|
|
37
|
+
let inReport = false
|
|
38
|
+
const gaps = []
|
|
39
|
+
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
if (line.includes('start of coverage report')) { inReport = true; continue }
|
|
42
|
+
if (!inReport) continue
|
|
43
|
+
|
|
44
|
+
// Match: # filename.js | line% | branch% | func% | 30-36 50
|
|
45
|
+
const m = line.match(/^[#ℹ]\s+([\w./[\]-]+\.js)\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*([^|\n]+)$/)
|
|
46
|
+
if (m) {
|
|
47
|
+
const uncov = m[2].trim()
|
|
48
|
+
if (uncov && uncov !== 'uncovered lines') {
|
|
49
|
+
gaps.push(` ${m[1].trim()} — uncovered lines: ${uncov}`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (gaps.length) {
|
|
55
|
+
process.stdout.write(JSON.stringify({
|
|
56
|
+
decision: 'block',
|
|
57
|
+
reason: [
|
|
58
|
+
'COVERAGE GAPS detected. Add tests for these uncovered lines before finishing:',
|
|
59
|
+
...gaps,
|
|
60
|
+
'',
|
|
61
|
+
'Run `npm run test:coverage` to see the full report.',
|
|
62
|
+
'Exempt from this rule: async action run() functions and server fetchers that call real APIs.',
|
|
63
|
+
'Everything else — view branches, mutations, onViewError, pure helper functions — must be covered.',
|
|
64
|
+
].join('\n'),
|
|
65
|
+
}))
|
|
66
|
+
}
|