@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,1371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse — Project scaffolding
|
|
3
|
+
*
|
|
4
|
+
* Creates a minimal Pulse project in the target directory.
|
|
5
|
+
* Includes a working home page with a counter to prove the app runs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs'
|
|
9
|
+
import path from 'path'
|
|
10
|
+
import { execSync } from 'child_process'
|
|
11
|
+
|
|
12
|
+
const PULSE_PKG = '@invisibleloop/pulse'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Scaffold a new Pulse project.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} targetDir - Absolute path to the project directory
|
|
18
|
+
* @param {Object} options
|
|
19
|
+
* @param {string} [options.name] - Project name (defaults to directory name)
|
|
20
|
+
* @param {number} [options.port] - Dev server port (defaults to 3000)
|
|
21
|
+
*/
|
|
22
|
+
export async function scaffold(targetDir, options = {}) {
|
|
23
|
+
const name = options.name || path.basename(targetDir)
|
|
24
|
+
const port = options.port || 3000
|
|
25
|
+
|
|
26
|
+
fs.mkdirSync(path.join(targetDir, 'src', 'pages'), { recursive: true })
|
|
27
|
+
fs.mkdirSync(path.join(targetDir, 'src', 'components'), { recursive: true })
|
|
28
|
+
fs.mkdirSync(path.join(targetDir, 'public'), { recursive: true })
|
|
29
|
+
fs.mkdirSync(path.join(targetDir, '.claude', 'commands'), { recursive: true })
|
|
30
|
+
|
|
31
|
+
// package.json
|
|
32
|
+
write(targetDir, 'package.json', JSON.stringify({
|
|
33
|
+
name,
|
|
34
|
+
version: '0.1.0',
|
|
35
|
+
type: 'module',
|
|
36
|
+
scripts: {
|
|
37
|
+
dev: 'pulse dev',
|
|
38
|
+
build: 'pulse build',
|
|
39
|
+
start: 'pulse start',
|
|
40
|
+
},
|
|
41
|
+
engines: {
|
|
42
|
+
node: '>=22',
|
|
43
|
+
},
|
|
44
|
+
dependencies: {
|
|
45
|
+
[PULSE_PKG]: 'latest',
|
|
46
|
+
}
|
|
47
|
+
}, null, 2))
|
|
48
|
+
|
|
49
|
+
// pulse.config.js
|
|
50
|
+
write(targetDir, 'pulse.config.js',
|
|
51
|
+
`export default {
|
|
52
|
+
${port !== 3000 ? ` port: ${port},\n` : ''} // Load test config — all fields optional, shown with defaults.
|
|
53
|
+
// load: {
|
|
54
|
+
// duration: 10, // seconds per test run
|
|
55
|
+
// connections: 10, // concurrent request chains
|
|
56
|
+
// thresholds: {
|
|
57
|
+
// rps: undefined, // minimum requests/sec (optional)
|
|
58
|
+
// p99: undefined, // maximum p99 latency ms (optional)
|
|
59
|
+
// errors: 0, // maximum error count
|
|
60
|
+
// },
|
|
61
|
+
// },
|
|
62
|
+
|
|
63
|
+
// Lighthouse & CWV thresholds — all fields optional, shown with defaults.
|
|
64
|
+
// lighthouse: {
|
|
65
|
+
// performance: 100,
|
|
66
|
+
// accessibility: 100,
|
|
67
|
+
// bestPractices: 100,
|
|
68
|
+
// seo: 100,
|
|
69
|
+
// lcp: 2500, // ms
|
|
70
|
+
// cls: 0.1,
|
|
71
|
+
// tbt: 200, // ms
|
|
72
|
+
// fcp: 1800, // ms
|
|
73
|
+
// si: 3400, // ms
|
|
74
|
+
// inp: 200, // ms
|
|
75
|
+
// },
|
|
76
|
+
|
|
77
|
+
// Per-route overrides — merged on top of global lighthouse/load config.
|
|
78
|
+
// routes: {
|
|
79
|
+
// '/dashboard': {
|
|
80
|
+
// lighthouse: { performance: 85, lcp: 4000 },
|
|
81
|
+
// load: { connections: 5, thresholds: { rps: 20 } },
|
|
82
|
+
// },
|
|
83
|
+
// },
|
|
84
|
+
|
|
85
|
+
// Named environments — for running tests and audits against different targets.
|
|
86
|
+
// Environment names are bespoke — choose whatever suits your project.
|
|
87
|
+
// environments: {
|
|
88
|
+
// local: { url: 'http://localhost:3000', default: true },
|
|
89
|
+
// staging: {
|
|
90
|
+
// url: 'https://staging.myapp.com',
|
|
91
|
+
// headers: { Authorization: \`Bearer \${process.env.STAGING_TOKEN}\` },
|
|
92
|
+
// load: { duration: 30, connections: 50 },
|
|
93
|
+
// lighthouse: { performance: 90 },
|
|
94
|
+
// },
|
|
95
|
+
// production: { url: 'https://myapp.com' },
|
|
96
|
+
// },
|
|
97
|
+
}
|
|
98
|
+
`
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
// Home page — working counter proves the app runs
|
|
102
|
+
write(targetDir, 'src/pages/home.js', homePage(name))
|
|
103
|
+
|
|
104
|
+
// Minimal stylesheet
|
|
105
|
+
write(targetDir, 'public/app.css', baseCSS())
|
|
106
|
+
|
|
107
|
+
// Consumer-facing CLAUDE.md
|
|
108
|
+
write(targetDir, 'CLAUDE.md', claudeMd(name))
|
|
109
|
+
|
|
110
|
+
// Slash commands
|
|
111
|
+
write(targetDir, '.claude/commands/pulse-dev.md', devCmd())
|
|
112
|
+
write(targetDir, '.claude/commands/pulse-stop.md', stopCmd())
|
|
113
|
+
write(targetDir, '.claude/commands/pulse-build.md', buildCmd())
|
|
114
|
+
write(targetDir, '.claude/commands/pulse-start.md', startCmd())
|
|
115
|
+
write(targetDir, '.claude/commands/pulse-report.md', reportCmd())
|
|
116
|
+
write(targetDir, '.claude/commands/pulse-load.md', loadCmd())
|
|
117
|
+
write(targetDir, '.claude/commands/pulse-contribute.md', contributeCmd())
|
|
118
|
+
|
|
119
|
+
// .gitignore
|
|
120
|
+
write(targetDir, '.gitignore', [
|
|
121
|
+
'node_modules',
|
|
122
|
+
'public/dist',
|
|
123
|
+
'.pulse-build',
|
|
124
|
+
'.DS_Store',
|
|
125
|
+
].join('\n') + '\n')
|
|
126
|
+
|
|
127
|
+
console.log(' ✓ Project files created')
|
|
128
|
+
|
|
129
|
+
// Install dependencies
|
|
130
|
+
console.log(' ✓ Installing dependencies...\n')
|
|
131
|
+
try {
|
|
132
|
+
// Use the globally linked package if available (local dev), otherwise npm install
|
|
133
|
+
execSync(`npm link ${PULSE_PKG}`, { cwd: targetDir, stdio: 'inherit' })
|
|
134
|
+
} catch {
|
|
135
|
+
execSync('npm install', { cwd: targetDir, stdio: 'inherit' })
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// File templates
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
function homePage(appName) {
|
|
144
|
+
return `\
|
|
145
|
+
export default {
|
|
146
|
+
meta: {
|
|
147
|
+
title: '${appName}',
|
|
148
|
+
description: 'Built with Pulse',
|
|
149
|
+
styles: ['/app.css'],
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
state: {
|
|
153
|
+
count: 0,
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
constraints: {
|
|
157
|
+
count: { min: 0, max: 10 },
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
view: (state) => \`
|
|
161
|
+
<main id="main-content" class="page">
|
|
162
|
+
<h1>${appName}</h1>
|
|
163
|
+
<p>Your Pulse app is running.</p>
|
|
164
|
+
|
|
165
|
+
<div class="counter">
|
|
166
|
+
<button data-event="decrement" \${state.count === 0 ? 'disabled' : ''}>−</button>
|
|
167
|
+
<span class="count">\${state.count}</span>
|
|
168
|
+
<button data-event="increment" \${state.count === 10 ? 'disabled' : ''}>+</button>
|
|
169
|
+
</div>
|
|
170
|
+
</main>
|
|
171
|
+
\`,
|
|
172
|
+
|
|
173
|
+
mutations: {
|
|
174
|
+
increment: (state) => ({ count: state.count + 1 }),
|
|
175
|
+
decrement: (state) => ({ count: state.count - 1 }),
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function baseCSS() {
|
|
182
|
+
return `\
|
|
183
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
184
|
+
|
|
185
|
+
:root {
|
|
186
|
+
--bg: #111;
|
|
187
|
+
--surface: #1a1a1a;
|
|
188
|
+
--text: #f0f0f0;
|
|
189
|
+
--muted: #888;
|
|
190
|
+
--accent: #9b8dff;
|
|
191
|
+
--accent-btn: #5c4de3;
|
|
192
|
+
--radius: 8px;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
body {
|
|
196
|
+
font-family: system-ui, sans-serif;
|
|
197
|
+
background: var(--bg);
|
|
198
|
+
color: var(--text);
|
|
199
|
+
line-height: 1.6;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.page {
|
|
203
|
+
max-width: 640px;
|
|
204
|
+
margin: 0 auto;
|
|
205
|
+
padding: 3rem 1.5rem;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
h1 { font-size: 2rem; margin-bottom: 1.5rem; }
|
|
209
|
+
p { color: var(--muted); margin-bottom: 2rem; }
|
|
210
|
+
a { color: var(--accent); }
|
|
211
|
+
|
|
212
|
+
.counter {
|
|
213
|
+
display: flex;
|
|
214
|
+
align-items: center;
|
|
215
|
+
gap: 1rem;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.count {
|
|
219
|
+
font-size: 2rem;
|
|
220
|
+
font-weight: 700;
|
|
221
|
+
min-width: 3rem;
|
|
222
|
+
text-align: center;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
button {
|
|
226
|
+
background: var(--accent-btn);
|
|
227
|
+
color: #fff;
|
|
228
|
+
border: none;
|
|
229
|
+
border-radius: var(--radius);
|
|
230
|
+
padding: 0.5rem 1.25rem;
|
|
231
|
+
font-size: 1.25rem;
|
|
232
|
+
cursor: pointer;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
button:disabled {
|
|
236
|
+
opacity: 0.3;
|
|
237
|
+
cursor: not-allowed;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
button:focus-visible {
|
|
241
|
+
outline: 3px solid var(--accent);
|
|
242
|
+
outline-offset: 2px;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
a:focus-visible {
|
|
246
|
+
outline: 3px solid var(--accent);
|
|
247
|
+
outline-offset: 2px;
|
|
248
|
+
border-radius: 2px;
|
|
249
|
+
}
|
|
250
|
+
`
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function claudeMd(appName) {
|
|
254
|
+
return `\
|
|
255
|
+
# ${appName} — Pulse App
|
|
256
|
+
|
|
257
|
+
Built with [Pulse](https://github.com/invisibleloop/pulse) — a spec-first, AI-native framework.
|
|
258
|
+
|
|
259
|
+
## Philosophy
|
|
260
|
+
|
|
261
|
+
**The spec is the source of truth.** You write a plain JS object that describes what a page does — its data, state, mutations, and view. The framework handles routing, SSR, hydration, compression, security headers, and client-side navigation automatically. You never touch any of that.
|
|
262
|
+
|
|
263
|
+
**Performance is non-negotiable.** Every page must meet these targets:
|
|
264
|
+
|
|
265
|
+
| Metric | Target |
|
|
266
|
+
|--------|--------|
|
|
267
|
+
| LCP | < 100ms (localhost) |
|
|
268
|
+
| CLS | 0.00 |
|
|
269
|
+
| Lighthouse Performance | 100 |
|
|
270
|
+
| Lighthouse Accessibility | 100 |
|
|
271
|
+
|
|
272
|
+
These are achieved automatically by the framework (streaming SSR, immutable asset caching, zero layout shift). Do not make changes that compromise them.
|
|
273
|
+
|
|
274
|
+
**No external JavaScript dependencies.** Pulse has no client-side dependencies. Do not install or import React, Vue, Alpine, jQuery, or any other JS library. If you need UI behaviour, express it as mutations and actions in the spec.
|
|
275
|
+
|
|
276
|
+
## Commands
|
|
277
|
+
|
|
278
|
+
\`\`\`bash
|
|
279
|
+
pulse dev # dev server (port from pulse.config.js, default 3000)
|
|
280
|
+
pulse stop # stop the dev server
|
|
281
|
+
pulse build # production build → public/dist/
|
|
282
|
+
pulse start # production server
|
|
283
|
+
\`\`\`
|
|
284
|
+
|
|
285
|
+
## Project structure
|
|
286
|
+
|
|
287
|
+
\`\`\`
|
|
288
|
+
src/
|
|
289
|
+
pages/ ← one file per page, auto-discovered
|
|
290
|
+
components/ ← reusable view fragments (JS functions returning HTML strings)
|
|
291
|
+
public/
|
|
292
|
+
app.css ← global stylesheet
|
|
293
|
+
\`\`\`
|
|
294
|
+
|
|
295
|
+
## Pages
|
|
296
|
+
|
|
297
|
+
Files in \`src/pages/\` are automatically registered as routes.
|
|
298
|
+
|
|
299
|
+
| File | Route |
|
|
300
|
+
|------|-------|
|
|
301
|
+
| \`home.js\` | \`/\` |
|
|
302
|
+
| \`about.js\` | \`/about\` |
|
|
303
|
+
| \`blog/post.js\` | \`/blog/post\` |
|
|
304
|
+
|
|
305
|
+
For dynamic segments, set \`route\` explicitly in the spec:
|
|
306
|
+
|
|
307
|
+
\`\`\`js
|
|
308
|
+
// src/pages/blog/show.js → set route: '/blog/:slug'
|
|
309
|
+
export default {
|
|
310
|
+
route: '/blog/:slug',
|
|
311
|
+
server: {
|
|
312
|
+
post: async (ctx) => fetchPost(ctx.params.slug),
|
|
313
|
+
},
|
|
314
|
+
// ...
|
|
315
|
+
}
|
|
316
|
+
\`\`\`
|
|
317
|
+
|
|
318
|
+
## The spec
|
|
319
|
+
|
|
320
|
+
\`\`\`js
|
|
321
|
+
export default {
|
|
322
|
+
// route: '/path' — omit to derive from filename. Required for dynamic segments.
|
|
323
|
+
|
|
324
|
+
meta: {
|
|
325
|
+
title: 'Page title',
|
|
326
|
+
description: 'Meta description',
|
|
327
|
+
styles: ['/app.css'],
|
|
328
|
+
|
|
329
|
+
// Structured data — injected as <script type="application/ld+json"> in <head>
|
|
330
|
+
schema: {
|
|
331
|
+
'@context': 'https://schema.org',
|
|
332
|
+
'@type': 'WebPage',
|
|
333
|
+
name: 'Page title',
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
// Server data — resolved before render, passed to view as second arg.
|
|
338
|
+
// ctx: { params, query, headers, cookies }
|
|
339
|
+
server: {
|
|
340
|
+
items: async (ctx) => fetchItems(ctx.query),
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
// Guard — runs before server fetchers on every request to this route.
|
|
344
|
+
// Return { redirect: '/path' } to deny access, or nothing to allow.
|
|
345
|
+
// ctx: { params, query, headers, cookies, pathname, method }
|
|
346
|
+
guard: async (ctx) => {
|
|
347
|
+
if (!ctx.cookies.session) return { redirect: '/login' }
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
// Initial client state
|
|
351
|
+
state: { count: 0 },
|
|
352
|
+
|
|
353
|
+
// Min/max bounds — always enforced after every mutation
|
|
354
|
+
constraints: {
|
|
355
|
+
count: { min: 0, max: 10 },
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
// Persist state keys to localStorage — restored on next visit
|
|
359
|
+
persist: ['count'],
|
|
360
|
+
|
|
361
|
+
// Validation rules — checked when action.validate === true
|
|
362
|
+
validation: {
|
|
363
|
+
'fields.email': { required: true, format: 'email' },
|
|
364
|
+
'fields.name': { required: true, minLength: 2 },
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
// Pure function — returns an HTML string
|
|
368
|
+
view: (state, server) => \`<main>...</main>\`,
|
|
369
|
+
|
|
370
|
+
// Synchronous state changes — return partial state to merge
|
|
371
|
+
mutations: {
|
|
372
|
+
increment: (state) => ({ count: state.count + 1 }),
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
// Async operations — form submissions, API calls
|
|
376
|
+
actions: {
|
|
377
|
+
submit: {
|
|
378
|
+
onStart: (state, formData) => ({ status: 'loading' }),
|
|
379
|
+
validate: true,
|
|
380
|
+
run: async (state, serverState, formData) => {
|
|
381
|
+
const res = await fetch('/api/endpoint', { method: 'POST', body: formData })
|
|
382
|
+
return res.json() // returned value is passed to onSuccess as second arg
|
|
383
|
+
},
|
|
384
|
+
onSuccess: (state, result) => ({ status: 'success', data: result }),
|
|
385
|
+
onError: (state, err) => ({
|
|
386
|
+
status: 'error',
|
|
387
|
+
errors: err?.validation ?? [{ message: err.message }],
|
|
388
|
+
}),
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
}
|
|
392
|
+
\`\`\`
|
|
393
|
+
|
|
394
|
+
## HTML event binding
|
|
395
|
+
|
|
396
|
+
\`\`\`html
|
|
397
|
+
<button data-event="increment">+</button> <!-- click → mutation -->
|
|
398
|
+
<input data-event="change:update"> <!-- change event → mutation -->
|
|
399
|
+
<form data-action="submit">...</form> <!-- submit → action, passes FormData -->
|
|
400
|
+
\`\`\`
|
|
401
|
+
|
|
402
|
+
## Images
|
|
403
|
+
|
|
404
|
+
Never write a bare \`<img>\` tag. Always use the \`img()\` or \`picture()\` helpers — they prevent CLS and handle loading priority correctly.
|
|
405
|
+
|
|
406
|
+
\`\`\`js
|
|
407
|
+
import { img, picture } from '@invisibleloop/pulse/image'
|
|
408
|
+
|
|
409
|
+
// Simple image — lazy loaded, prevents CLS
|
|
410
|
+
img({ src: '/photo.jpg', alt: 'A photo', width: 800, height: 600 })
|
|
411
|
+
|
|
412
|
+
// LCP hero image — eager + high fetchpriority
|
|
413
|
+
img({ src: '/hero.jpg', alt: 'Hero', width: 1200, height: 630, priority: true })
|
|
414
|
+
|
|
415
|
+
// With modern format sources (AVIF/WebP)
|
|
416
|
+
picture({
|
|
417
|
+
src: '/hero.jpg',
|
|
418
|
+
alt: 'Hero',
|
|
419
|
+
width: 1200,
|
|
420
|
+
height: 630,
|
|
421
|
+
priority: true,
|
|
422
|
+
sources: [
|
|
423
|
+
{ src: '/hero.avif', type: 'image/avif' },
|
|
424
|
+
{ src: '/hero.webp', type: 'image/webp' },
|
|
425
|
+
]
|
|
426
|
+
})
|
|
427
|
+
\`\`\`
|
|
428
|
+
|
|
429
|
+
Rules:
|
|
430
|
+
- **Always provide \`width\` and \`height\`** — without them the browser can't reserve space and CLS is non-zero
|
|
431
|
+
- **Use \`priority: true\` for the first visible image** (hero, above-fold card) — sets \`loading="eager"\` and \`fetchpriority="high"\` for LCP
|
|
432
|
+
- **All other images** default to \`loading="lazy"\`
|
|
433
|
+
- **Use \`picture()\` when you have AVIF/WebP variants** — AVIF is typically 50% smaller than JPEG
|
|
434
|
+
|
|
435
|
+
## Embedding videos (oEmbed / YouTube)
|
|
436
|
+
|
|
437
|
+
Never drop a bare YouTube \`<iframe>\` into the view — it loads ~500 KB of scripts immediately and kills LCP. Use the **facade pattern**: fetch oEmbed data in \`server\`, SSR the thumbnail as a priority \`<img>\`, and swap to the real \`youtube-nocookie.com\` iframe only on click via an inline \`<script nonce="\${server.meta.nonce}">\`. Always \`escapeHtml\` oEmbed title/URL values before injecting into HTML attributes.
|
|
438
|
+
|
|
439
|
+
## Guard (route authorization)
|
|
440
|
+
|
|
441
|
+
\`guard\` runs on every request to a route, before server data is fetched. Returning \`{ redirect: '/path' }\` sends a 302 and skips all fetchers. Returning nothing allows the request to proceed.
|
|
442
|
+
|
|
443
|
+
\`\`\`js
|
|
444
|
+
export default {
|
|
445
|
+
route: '/dashboard',
|
|
446
|
+
|
|
447
|
+
guard: async (ctx) => {
|
|
448
|
+
if (!ctx.cookies.session) return { redirect: '/login' }
|
|
449
|
+
// Role check example:
|
|
450
|
+
// const user = await getUserFromSession(ctx.cookies.session)
|
|
451
|
+
// if (!user?.isAdmin) return { redirect: '/403' }
|
|
452
|
+
},
|
|
453
|
+
|
|
454
|
+
server: {
|
|
455
|
+
profile: async (ctx) => getProfile(ctx.cookies.session),
|
|
456
|
+
},
|
|
457
|
+
|
|
458
|
+
state: {},
|
|
459
|
+
view: (state, server) => \`<main id="main-content"><h1>Welcome, \${server.profile.name}</h1></main>\`,
|
|
460
|
+
}
|
|
461
|
+
\`\`\`
|
|
462
|
+
|
|
463
|
+
Guard also works in reverse — redirect already-authenticated users away from login pages:
|
|
464
|
+
|
|
465
|
+
\`\`\`js
|
|
466
|
+
guard: async (ctx) => {
|
|
467
|
+
if (ctx.cookies.session) return { redirect: '/dashboard' }
|
|
468
|
+
},
|
|
469
|
+
\`\`\`
|
|
470
|
+
|
|
471
|
+
## ctx methods — setHeader and setCookie
|
|
472
|
+
|
|
473
|
+
Available inside \`guard\`, \`server\` fetchers, and \`render\`. Changes are included in the response automatically.
|
|
474
|
+
|
|
475
|
+
\`\`\`js
|
|
476
|
+
// Set an arbitrary response header
|
|
477
|
+
ctx.setHeader('X-Custom-Header', 'value')
|
|
478
|
+
|
|
479
|
+
// Set a cookie
|
|
480
|
+
ctx.setCookie('session', token, {
|
|
481
|
+
httpOnly: true,
|
|
482
|
+
secure: process.env.NODE_ENV === 'production',
|
|
483
|
+
sameSite: 'Lax',
|
|
484
|
+
maxAge: 86400, // seconds — omit for session cookie
|
|
485
|
+
path: '/', // default
|
|
486
|
+
})
|
|
487
|
+
\`\`\`
|
|
488
|
+
|
|
489
|
+
\`setCookie\` options: \`httpOnly\` (boolean), \`secure\` (boolean), \`sameSite\` ('Lax'|'Strict'|'None'), \`maxAge\` (number), \`path\` (string), \`domain\` (string).
|
|
490
|
+
|
|
491
|
+
Use \`render\` returning \`{ redirect: '/path' }\` for raw response specs that need to redirect (e.g. OAuth callbacks):
|
|
492
|
+
|
|
493
|
+
\`\`\`js
|
|
494
|
+
render: (ctx, server) => {
|
|
495
|
+
if (!server.session) return { redirect: '/auth/login' }
|
|
496
|
+
return { redirect: '/' }
|
|
497
|
+
}
|
|
498
|
+
\`\`\`
|
|
499
|
+
|
|
500
|
+
## Canonical URLs and trailing slashes
|
|
501
|
+
|
|
502
|
+
A \`<link rel="canonical">\` is injected into every page \`<head>\` automatically. Trailing slash behaviour is controlled by the \`trailingSlash\` option in \`createServer\`:
|
|
503
|
+
|
|
504
|
+
| Value | Behaviour | Canonical form |
|
|
505
|
+
|-------|-----------|----------------|
|
|
506
|
+
| \`"remove"\` (default) | 301 \`/about/\` → \`/about\` | no-slash |
|
|
507
|
+
| \`"add"\` | 301 \`/about\` → \`/about/\` | slash |
|
|
508
|
+
| \`"allow"\` | serve both, no redirect | no-slash |
|
|
509
|
+
|
|
510
|
+
\`\`\`js
|
|
511
|
+
createServer(specs, {
|
|
512
|
+
trailingSlash: 'add', // slash is canonical for this project
|
|
513
|
+
})
|
|
514
|
+
\`\`\`
|
|
515
|
+
|
|
516
|
+
Override the canonical URL for a specific page with \`meta.canonical\`:
|
|
517
|
+
|
|
518
|
+
\`\`\`js
|
|
519
|
+
meta: {
|
|
520
|
+
title: 'My Page',
|
|
521
|
+
canonical: 'https://example.com/my-page',
|
|
522
|
+
}
|
|
523
|
+
\`\`\`
|
|
524
|
+
|
|
525
|
+
## Raw content responses (RSS, sitemaps, JSON APIs)
|
|
526
|
+
|
|
527
|
+
For non-HTML routes, use \`contentType\` + \`render\` instead of \`view\`. The HTML pipeline is bypassed entirely — no document wrapper, no hydration.
|
|
528
|
+
|
|
529
|
+
\`\`\`js
|
|
530
|
+
// src/pages/feed.js
|
|
531
|
+
export default {
|
|
532
|
+
route: '/feed.xml',
|
|
533
|
+
|
|
534
|
+
// Fetch data server-side — same caching options as pages
|
|
535
|
+
server: {
|
|
536
|
+
posts: async () => fetchRecentPosts(),
|
|
537
|
+
},
|
|
538
|
+
|
|
539
|
+
// Tell the server to serve raw content with this MIME type
|
|
540
|
+
contentType: 'application/rss+xml; charset=utf-8',
|
|
541
|
+
|
|
542
|
+
// Pure function — (ctx, serverData) => string
|
|
543
|
+
render: (ctx, server) => \`<?xml version="1.0" encoding="UTF-8"?>
|
|
544
|
+
<rss version="2.0">
|
|
545
|
+
<channel>
|
|
546
|
+
<title>My Blog</title>
|
|
547
|
+
<link>https://example.com</link>
|
|
548
|
+
<description>Latest posts</description>
|
|
549
|
+
\${server.posts.map(p => \`
|
|
550
|
+
<item>
|
|
551
|
+
<title>\${escXml(p.title)}</title>
|
|
552
|
+
<link>https://example.com/blog/\${p.slug}</link>
|
|
553
|
+
<pubDate>\${new Date(p.date).toUTCString()}</pubDate>
|
|
554
|
+
<description>\${escXml(p.excerpt)}</description>
|
|
555
|
+
</item>\`).join('')}
|
|
556
|
+
</channel>
|
|
557
|
+
</rss>\`,
|
|
558
|
+
|
|
559
|
+
// Cache the rendered XML for 1 hour in HTTP caches
|
|
560
|
+
cache: { public: true, maxAge: 3600, staleWhileRevalidate: 86400 },
|
|
561
|
+
|
|
562
|
+
// Also cache the server data fetch in-process for 5 minutes
|
|
563
|
+
serverTtl: 300,
|
|
564
|
+
}
|
|
565
|
+
\`\`\`
|
|
566
|
+
|
|
567
|
+
Rules:
|
|
568
|
+
- **\`render(ctx, server)\`** is synchronous — do all async work in \`spec.server\`, same as \`view\`
|
|
569
|
+
- **\`state\`, \`view\`, \`mutations\`, \`actions\`** are not used and should be omitted
|
|
570
|
+
- **Always escape special characters** in XML output (\`&\` → \`&\`, \`<\` → \`<\`, \`>\` → \`>\`)
|
|
571
|
+
- **Text, XML, and JSON** responses are compressed automatically (brotli/gzip)
|
|
572
|
+
- Common content types: \`application/rss+xml; charset=utf-8\`, \`application/xml\`, \`application/json\`, \`text/plain\`
|
|
573
|
+
|
|
574
|
+
## Caching
|
|
575
|
+
|
|
576
|
+
By default HTML responses are served with \`Cache-Control: no-store\`. To enable caching on a route, add \`cache\` and/or \`serverTtl\` to the spec:
|
|
577
|
+
|
|
578
|
+
\`\`\`js
|
|
579
|
+
export default {
|
|
580
|
+
route: '/blog/:slug',
|
|
581
|
+
|
|
582
|
+
// In-process server data cache — fetchers are not called again until TTL expires.
|
|
583
|
+
// The cached result is reused to re-render the page on each request within the window.
|
|
584
|
+
serverTtl: 60, // seconds
|
|
585
|
+
|
|
586
|
+
// HTTP cache headers sent with the HTML response (prod only — dev always sends no-store)
|
|
587
|
+
cache: {
|
|
588
|
+
public: true, // use 'public' (CDN-cacheable); omit or false for 'private'
|
|
589
|
+
maxAge: 60, // max-age in seconds
|
|
590
|
+
staleWhileRevalidate: 3600, // stale-while-revalidate in seconds (optional)
|
|
591
|
+
},
|
|
592
|
+
|
|
593
|
+
// ...
|
|
594
|
+
}
|
|
595
|
+
\`\`\`
|
|
596
|
+
|
|
597
|
+
Rules:
|
|
598
|
+
- **\`serverTtl\`** caches the server data fetch result in-process — not the HTML. The page is re-rendered from cached data on every request, so the response is still dynamic (state, params etc. are live).
|
|
599
|
+
- **\`cache.public\`** marks the response as CDN-cacheable. Only use this on routes where the HTML is safe to share across users.
|
|
600
|
+
- Both settings are **ignored in dev mode** — dev always returns \`Cache-Control: no-store\`.
|
|
601
|
+
- Static assets under \`/dist/\` are always \`immutable, max-age=31536000\` — never override this.
|
|
602
|
+
|
|
603
|
+
## Keyboard accessibility
|
|
604
|
+
|
|
605
|
+
Every page must be fully navigable by keyboard alone.
|
|
606
|
+
|
|
607
|
+
**Structure**
|
|
608
|
+
- Wrap page content in \`<main id="main-content">\` — the skip link injected by the framework targets this id
|
|
609
|
+
- Use semantic elements: \`<nav>\`, \`<main>\`, \`<header>\`, \`<footer>\`, \`<section>\`, \`<article>\`, \`<aside>\`
|
|
610
|
+
- One \`<h1>\` per page — the primary heading that describes the current page
|
|
611
|
+
|
|
612
|
+
**Interactive elements**
|
|
613
|
+
- Only use \`<button>\` for actions and \`<a href>\` for navigation — never a \`<div>\` or \`<span>\` with a click handler
|
|
614
|
+
- All interactive elements must be reachable by Tab and operable by Enter/Space
|
|
615
|
+
- Buttons that toggle state must have an \`aria-expanded\` or \`aria-pressed\` attribute when appropriate
|
|
616
|
+
- Disabled buttons must use the HTML \`disabled\` attribute — not just a visual style
|
|
617
|
+
|
|
618
|
+
**Focus management**
|
|
619
|
+
- After client-side navigation, focus is moved automatically by the framework (to \`#main-content\`, \`<main>\`, or \`<h1>\`)
|
|
620
|
+
- After a mutation that opens a modal or drawer, move focus to the first interactive element inside it
|
|
621
|
+
- When a modal closes, return focus to the element that opened it
|
|
622
|
+
- Never trap focus outside of intentional modal/dialog patterns (and always provide a close path)
|
|
623
|
+
|
|
624
|
+
**Dynamic content**
|
|
625
|
+
- Status messages (loading, success, error) must use \`role="status"\` or \`role="alert"\` so screen readers announce them
|
|
626
|
+
- Use \`role="alert"\` for errors (assertive) and \`role="status"\` for non-urgent updates (polite)
|
|
627
|
+
- Example: \`<p role="alert">\${state.error}</p>\`
|
|
628
|
+
|
|
629
|
+
**Forms**
|
|
630
|
+
- Every \`<input>\`, \`<select>\`, and \`<textarea>\` must have an associated \`<label>\` (via \`for\`/\`id\` or wrapping)
|
|
631
|
+
- Error messages must be linked to their input using \`aria-describedby\`
|
|
632
|
+
- Required fields must have \`required\` (or \`aria-required="true"\`)
|
|
633
|
+
- Group related inputs with \`<fieldset>\` and \`<legend>\`
|
|
634
|
+
|
|
635
|
+
**Images and icons**
|
|
636
|
+
- Decorative images: \`alt=""\`
|
|
637
|
+
- Informative images: meaningful \`alt\` text
|
|
638
|
+
- Icon-only buttons: \`aria-label\` on the button, \`aria-hidden="true"\` on the icon
|
|
639
|
+
|
|
640
|
+
## Security defaults
|
|
641
|
+
|
|
642
|
+
Pulse applies the following security measures automatically — no configuration needed:
|
|
643
|
+
|
|
644
|
+
| Feature | Behaviour |
|
|
645
|
+
|---|---|
|
|
646
|
+
| Security headers | Sent on every response: \`X-Content-Type-Options\`, \`X-Frame-Options\`, \`Referrer-Policy\`, \`Permissions-Policy\`, \`Cross-Origin-Opener-Policy\`, \`Cross-Origin-Resource-Policy\` |
|
|
647
|
+
| CSP with nonce | Every HTML response includes a \`Content-Security-Policy\` header with a per-request cryptographic nonce. All inline scripts injected by the framework carry a matching \`nonce\` attribute |
|
|
648
|
+
| HSTS | When a request arrives with \`x-forwarded-proto: https\` or over a TLS socket, \`Strict-Transport-Security: max-age=31536000; includeSubDomains\` is added automatically |
|
|
649
|
+
| SameSite=Lax cookies | Cookies set via \`ctx.setCookie()\` default to \`SameSite=Lax\` — CSRF protection without explicit opt-in |
|
|
650
|
+
| POST gating | POST/PUT/DELETE to a page spec returns 405. Raw response specs (\`contentType\` + \`render\`) accept any method — use these for webhooks |
|
|
651
|
+
|
|
652
|
+
### Escaping user data in views
|
|
653
|
+
|
|
654
|
+
Import \`escHtml\` to safely embed untrusted data in HTML strings:
|
|
655
|
+
|
|
656
|
+
\`\`\`js
|
|
657
|
+
import { escHtml } from '@invisibleloop/pulse/html'
|
|
658
|
+
|
|
659
|
+
view: (state) => \`
|
|
660
|
+
<p>Hello, \${escHtml(state.username)}</p>
|
|
661
|
+
\`
|
|
662
|
+
\`\`\`
|
|
663
|
+
|
|
664
|
+
**Always use \`escHtml\`** around any value that originates from user input, URL params, or external APIs. Omitting it is an XSS vulnerability.
|
|
665
|
+
|
|
666
|
+
### Using ctx.nonce in view functions
|
|
667
|
+
|
|
668
|
+
The per-request nonce is available as \`ctx.nonce\` inside \`server\` fetchers and \`guard\`. If a view needs to emit its own inline \`<script>\`, pass the nonce through server data:
|
|
669
|
+
|
|
670
|
+
\`\`\`js
|
|
671
|
+
server: {
|
|
672
|
+
meta: async (ctx) => ({ nonce: ctx.nonce }),
|
|
673
|
+
},
|
|
674
|
+
|
|
675
|
+
view: (state, server) => \`
|
|
676
|
+
<script nonce="\${server.meta.nonce}">console.log('inline ok')</script>
|
|
677
|
+
\`
|
|
678
|
+
\`\`\`
|
|
679
|
+
|
|
680
|
+
Inline scripts without the matching nonce are blocked by the CSP.
|
|
681
|
+
|
|
682
|
+
## Testing
|
|
683
|
+
|
|
684
|
+
Tests use Node's built-in test runner — no test framework, no extra dependencies. Run with:
|
|
685
|
+
|
|
686
|
+
\`\`\`bash
|
|
687
|
+
node src/pages/home.test.js # single file
|
|
688
|
+
node --test src/**/*.test.js # all tests
|
|
689
|
+
\`\`\`
|
|
690
|
+
|
|
691
|
+
Place test files alongside the spec they test: \`src/pages/home.test.js\` next to \`src/pages/home.js\`.
|
|
692
|
+
|
|
693
|
+
### Minimal test harness
|
|
694
|
+
|
|
695
|
+
Each test file uses a minimal inline harness (no imports) — \`async function test(label, fn)\` + \`function assert(condition, msg)\`. Call spec functions directly: mutations/view/action stages are pure functions, pass mocks for \`ctx\`. For HTTP tests use \`createServer\` with an incrementing port counter.
|
|
696
|
+
|
|
697
|
+
### What to test
|
|
698
|
+
|
|
699
|
+
| Thing | Test it? | How |
|
|
700
|
+
|---|---|---|
|
|
701
|
+
| Mutations | Yes — always | Call directly, assert returned state |
|
|
702
|
+
| View functions | Yes — key states | Call directly, assert HTML contains expected content |
|
|
703
|
+
| Server fetchers | Yes — happy path + errors | Mock ctx, assert return shape |
|
|
704
|
+
| Action lifecycles | Yes — each stage | Call onStart/onSuccess/onError directly |
|
|
705
|
+
| Full page HTTP | Yes — smoke test each page | \`withServer\` + \`get()\` |
|
|
706
|
+
| CSS / visual output | No | Covered by Lighthouse audits |
|
|
707
|
+
|
|
708
|
+
## CSS conventions
|
|
709
|
+
|
|
710
|
+
All styles live in \`public/app.css\`. There is no CSS-in-JS, no scoped styles, no Tailwind.
|
|
711
|
+
|
|
712
|
+
**Token-first** — every colour, radius, and spacing value must come from a CSS custom property defined in \`:root\`. Never write raw hex values or magic numbers in component rules.
|
|
713
|
+
|
|
714
|
+
\`\`\`css
|
|
715
|
+
/* wrong */
|
|
716
|
+
.card { background: #1a1a1a; border-radius: 8px; color: #888; }
|
|
717
|
+
|
|
718
|
+
/* right */
|
|
719
|
+
.card { background: var(--surface); border-radius: var(--radius); color: var(--muted); }
|
|
720
|
+
\`\`\`
|
|
721
|
+
|
|
722
|
+
**Modifier pattern** — write one base class and extend it with modifiers. Never create two parallel classes that do the same thing with different values.
|
|
723
|
+
|
|
724
|
+
\`\`\`css
|
|
725
|
+
/* wrong — two separate button classes */
|
|
726
|
+
.btn-primary { display: inline-flex; padding: .65rem 1.4rem; background: var(--accent); ... }
|
|
727
|
+
.btn-ghost { display: inline-flex; padding: .65rem 1.4rem; background: transparent; ... }
|
|
728
|
+
|
|
729
|
+
/* right — shared base, modifier overrides only what changes */
|
|
730
|
+
.btn { display: inline-flex; align-items: center; gap: .5rem; padding: .65rem 1.4rem; border-radius: var(--radius); font-weight: 600; text-decoration: none; transition: all .15s; }
|
|
731
|
+
.btn--primary { background: var(--accent); color: var(--bg); }
|
|
732
|
+
.btn--ghost { background: transparent; color: var(--text); border: 1px solid var(--border); }
|
|
733
|
+
\`\`\`
|
|
734
|
+
|
|
735
|
+
**Utility classes for repeated patterns** — if the same combination of properties appears on three or more elements, extract it into a named utility. Common candidates:
|
|
736
|
+
|
|
737
|
+
\`\`\`css
|
|
738
|
+
/* label utility — uppercase, small, muted, tracked */
|
|
739
|
+
.label { font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
|
|
740
|
+
\`\`\`
|
|
741
|
+
|
|
742
|
+
**No duplication** — before writing a new class, check \`app.css\` for an existing one that covers it. If a class already exists, use it. If it almost fits, extend it with a modifier — never copy-paste and rename.
|
|
743
|
+
|
|
744
|
+
**Dead code** — if a class is no longer referenced in any component, remove it from \`app.css\`.
|
|
745
|
+
|
|
746
|
+
## Development workflow
|
|
747
|
+
|
|
748
|
+
Follow these steps in order. Do not skip steps or reorder them.
|
|
749
|
+
|
|
750
|
+
### Creating a new page
|
|
751
|
+
|
|
752
|
+
1. **Inventory** — run \`pulse_list_structure\` to see what pages and components already exist. Reuse anything that fits rather than creating from scratch.
|
|
753
|
+
2. **Plan** — draft the spec mentally. If the user asked to preview first, output it as a code block and wait for confirmation before continuing.
|
|
754
|
+
3. **Validate** — run \`pulse_validate\` on the spec before writing any file. Fix all validation errors before proceeding.
|
|
755
|
+
4. **Write the spec** — call \`pulse_create_page\`. Register it in \`server.js\`.
|
|
756
|
+
5. **Write tests** — create \`src/pages/[name].test.js\`. Cover: all mutations, view output for key states, server fetcher shape, action lifecycle stages, and an HTTP smoke test.
|
|
757
|
+
6. **Run tests** — \`node src/pages/[name].test.js\`. All must pass before continuing.
|
|
758
|
+
7. **Restart the dev server** — run \`/pulse-dev\` (new files require a restart; hot reload only covers edits to existing files).
|
|
759
|
+
8. **Lighthouse audit** — run a full audit on the new route. All four scores must be **100**. Fix any failures before marking the task done.
|
|
760
|
+
9. **Save the report** — save Lighthouse results to the report store via \`pulse save-report\`.
|
|
761
|
+
|
|
762
|
+
### Creating a new component
|
|
763
|
+
|
|
764
|
+
1. **Inventory** — run \`pulse_list_structure\`. If a similar component exists, extend it rather than creating a new one.
|
|
765
|
+
2. **Plan** — if the user asked to preview, show the component as a code block and wait for confirmation.
|
|
766
|
+
3. **Write the component** — call \`pulse_create_component\`.
|
|
767
|
+
4. **Write tests** — create \`src/components/[name].test.js\`. Test the render function output for all meaningful states.
|
|
768
|
+
5. **Run tests** — \`node src/components/[name].test.js\`. All must pass.
|
|
769
|
+
6. **Restart the dev server** — run \`/pulse-dev\`.
|
|
770
|
+
7. **Lighthouse audit** — audit every page that uses the component. All scores must remain **100**.
|
|
771
|
+
8. **Save the report** — save results for each audited route.
|
|
772
|
+
|
|
773
|
+
### Editing an existing page or component
|
|
774
|
+
|
|
775
|
+
1. **Read first** — read the current file before making any changes. Never edit blind.
|
|
776
|
+
2. **Check tests** — read the existing test file to understand what is already covered.
|
|
777
|
+
3. **Edit** — make the change.
|
|
778
|
+
4. **Run tests** — run the test file for the changed spec. All must pass.
|
|
779
|
+
5. **Lighthouse audit** — if the change affects rendered output, run a full audit. All scores must remain **100**.
|
|
780
|
+
6. **Save the report** — if an audit was run, save the results.
|
|
781
|
+
|
|
782
|
+
### Adding CSS
|
|
783
|
+
|
|
784
|
+
1. **Read \`public/app.css\` first** — check for an existing class or token that covers the need.
|
|
785
|
+
2. **Extend before adding** — use a modifier on an existing class if possible.
|
|
786
|
+
3. **Tokens only** — never write raw hex values or magic numbers. Use \`var(--token)\`.
|
|
787
|
+
4. **Add the class** — write it in \`app.css\`, not inline in the component.
|
|
788
|
+
5. **Check for dead code** — if a class was replaced or renamed, remove the old one.
|
|
789
|
+
|
|
790
|
+
## Component library
|
|
791
|
+
|
|
792
|
+
Import from \`@invisibleloop/pulse/ui\`. Add \`pulse-ui.css\` to \`meta.styles\`.
|
|
793
|
+
|
|
794
|
+
\`\`\`js
|
|
795
|
+
import { button, card, input, alert, badge, stat, avatar, empty, table, select, textarea } from '@invisibleloop/pulse/ui'
|
|
796
|
+
|
|
797
|
+
meta: { styles: ['/pulse-ui.css', '/app.css'] }
|
|
798
|
+
\`\`\`
|
|
799
|
+
|
|
800
|
+
### Components
|
|
801
|
+
|
|
802
|
+
| Component | Props | Notes |
|
|
803
|
+
|---|---|---|
|
|
804
|
+
| \`button\` | \`label\`, \`variant\` (primary/secondary/ghost/danger), \`size\` (sm/md/lg), \`href\`, \`disabled\`, \`type\`, \`icon\`, \`iconAfter\`, \`fullWidth\`, \`class\`, \`attrs\` | Renders \`<a>\` when \`href\` set, \`<button>\` otherwise |
|
|
805
|
+
| \`badge\` | \`label\`, \`variant\` (default/success/warning/error/info), \`class\` | Inline status label |
|
|
806
|
+
| \`card\` | \`title\`, \`content\`, \`footer\`, \`flush\`, \`class\` | \`content\` and \`footer\` are HTML strings — escape user data before passing |
|
|
807
|
+
| \`input\` | \`name\`, \`label\`, \`type\`, \`placeholder\`, \`value\`, \`error\`, \`hint\`, \`required\`, \`disabled\`, \`id\`, \`class\`, \`attrs\` | Label/error wired via \`for\`/\`aria-describedby\` automatically |
|
|
808
|
+
| \`select\` | \`name\`, \`label\`, \`options\` (strings or \`{value,label}\`), \`value\`, \`error\`, \`hint\`, \`required\`, \`disabled\`, \`id\`, \`class\` | |
|
|
809
|
+
| \`textarea\` | \`name\`, \`label\`, \`placeholder\`, \`value\`, \`rows\`, \`error\`, \`hint\`, \`required\`, \`disabled\`, \`id\`, \`class\`, \`attrs\` | |
|
|
810
|
+
| \`alert\` | \`variant\` (info/success/warning/error), \`title\`, \`content\`, \`class\` | \`error\`/\`warning\` use \`role="alert"\`; \`info\`/\`success\` use \`role="status"\` |
|
|
811
|
+
| \`stat\` | \`label\`, \`value\`, \`change\`, \`trend\` (up/down/neutral), \`class\` | |
|
|
812
|
+
| \`avatar\` | \`src\`, \`alt\`, \`size\` (sm/md/lg/xl), \`initials\`, \`class\` | Renders \`<img>\` with src, \`<span>\` with initials fallback |
|
|
813
|
+
| \`empty\` | \`title\`, \`description\`, \`action\` (\`{label,href,variant}\`), \`class\` | |
|
|
814
|
+
| \`table\` | \`headers\`, \`rows\` (2D array of HTML strings), \`caption\`, \`class\` | Scroll wrapper has \`role="region"\` + \`tabindex="0"\` |
|
|
815
|
+
|
|
816
|
+
### Rules
|
|
817
|
+
|
|
818
|
+
- **Check for an existing component first.** Run \`pulse_list_structure\` before creating a new UI element. If a component covers the need, use it — do not recreate it inline.
|
|
819
|
+
- **Theming is CSS-only.** Override \`--ui-*\` custom properties in \`:root\` in \`app.css\`. Never pass \`style=""\` to a component.
|
|
820
|
+
- **Extension is modifier classes.** Add new variants with a CSS class (e.g. \`.ui-btn--brand\`). Never fork or modify a component source file.
|
|
821
|
+
- **User data must be escaped before passing.** \`content\`, \`footer\`, and \`rows\` in \`card\`/\`table\` accept HTML strings — they are not automatically escaped. Use \`escHtml()\` from \`@invisibleloop/pulse/html\` on any user-supplied content before passing it.
|
|
822
|
+
- **Missing variant → safe fallback.** All components fall back to their default variant when an unknown value is passed — they never throw.
|
|
823
|
+
|
|
824
|
+
## When Pulse doesn't have a built-in pattern
|
|
825
|
+
|
|
826
|
+
If asked to implement something with no direct Pulse equivalent, identify which escape hatch fits before reaching for an external library:
|
|
827
|
+
|
|
828
|
+
| Need | Approach |
|
|
829
|
+
|---|---|
|
|
830
|
+
| Middleware — logging, rate limiting, IP blocking, custom headers | \`onRequest\` hook in \`createServer\` |
|
|
831
|
+
| Non-HTML responses — JSON APIs, webhooks, RSS | Raw response spec (\`contentType\` + \`render\`) |
|
|
832
|
+
| WebSockets | \`server.on('upgrade')\` on the instance returned by \`createServer\` |
|
|
833
|
+
| Server-Sent Events | \`onRequest\` — write \`text/event-stream\` response and return \`false\` |
|
|
834
|
+
| Custom error pages | \`onError\` hook in \`createServer\` |
|
|
835
|
+
| Browser-only behaviour | Inline \`<script nonce="\${server.meta.nonce}">\` in the view |
|
|
836
|
+
|
|
837
|
+
If none of these cover the requirement, explain the limitation honestly. Do not introduce client-side JS frameworks or npm packages to work around a missing Pulse feature.
|
|
838
|
+
|
|
839
|
+
## Environments
|
|
840
|
+
|
|
841
|
+
\`pulse.config.js\` supports named environments for running tests and audits against different targets:
|
|
842
|
+
|
|
843
|
+
\`\`\`js
|
|
844
|
+
environments: {
|
|
845
|
+
local: { url: 'http://localhost:3000', default: true },
|
|
846
|
+
staging: {
|
|
847
|
+
url: 'https://staging.myapp.com',
|
|
848
|
+
headers: { Authorization: \`Bearer \${process.env.STAGING_TOKEN}\` },
|
|
849
|
+
load: { duration: 30, connections: 50 },
|
|
850
|
+
lighthouse: { performance: 90 },
|
|
851
|
+
},
|
|
852
|
+
production: { url: 'https://myapp.com' },
|
|
853
|
+
}
|
|
854
|
+
\`\`\`
|
|
855
|
+
|
|
856
|
+
- **Environment names are bespoke** — choose names that fit the project (e.g. \`local\`, \`staging\`, \`prod\`, \`preview\`)
|
|
857
|
+
- **\`url\`** — base URL to test against; if it contains \`localhost\` or \`127.0.0.1\`, a local production build and temporary server are used automatically; remote URLs are tested directly
|
|
858
|
+
- **\`default: true\`** — the environment used when none is specified; if no default is set the agent asks the user to choose
|
|
859
|
+
- **\`headers\`** — HTTP headers sent with every request (useful for auth tokens on protected environments); always read values from \`process.env\` — never hardcode credentials
|
|
860
|
+
- **\`load\`** and **\`lighthouse\`** — per-environment threshold overrides; merged on top of global config and below per-route overrides
|
|
861
|
+
|
|
862
|
+
Threshold merge order: **global config → environment override → per-route override**
|
|
863
|
+
|
|
864
|
+
## Edge cases
|
|
865
|
+
|
|
866
|
+
### Routing
|
|
867
|
+
- **Static vs dynamic route conflicts** — if \`/blog/new\` and \`/blog/:slug\` both exist, the static route wins. Register static routes before dynamic ones in \`server.js\`.
|
|
868
|
+
- **Route params vs query params** — \`:param\` segments are in \`ctx.params\`; \`?key=value\` strings are in \`ctx.query\`. Never mix them.
|
|
869
|
+
- **Trailing slashes** — the default \`trailingSlash: 'remove'\` setting 301-redirects \`/about/\` → \`/about\`. Do not create routes with trailing slashes.
|
|
870
|
+
|
|
871
|
+
### State & mutations
|
|
872
|
+
- **Shallow merge** — mutations return a partial state object that is shallow-merged. To update a nested field, spread the parent: \`{ form: { ...state.form, email } }\`.
|
|
873
|
+
- **Mutation returning undefined** — a mutation that falls through without returning silently skips the state update. Every code path must return a partial state object.
|
|
874
|
+
- **Constraints apply after every mutation** — transient out-of-bounds values between two mutations are not possible. Constraints clamp immediately after each one.
|
|
875
|
+
|
|
876
|
+
### Actions
|
|
877
|
+
- **FormData in run()** — fields are available in both \`onStart\` and \`run\`. However, read \`File\` entries to \`ArrayBuffer\` at the start of \`run\` before any \`await\` — file references can be dropped across async boundaries in some environments.
|
|
878
|
+
- **run() return value** — whatever \`run()\` returns is passed as the second argument to \`onSuccess\`. If \`run()\` returns nothing, \`onSuccess\` receives \`undefined\`. Always \`return\` the response.
|
|
879
|
+
- **Redirect after action** — return \`{ redirect: '/path' }\` from \`onSuccess\` to navigate without a full page reload.
|
|
880
|
+
- **Validation error shape** — \`onError\` receives either an error with \`err.validation\` (array of \`{ field, message }\`) when validation fails, or a plain \`Error\` at runtime. Always handle both: \`err?.validation ?? [{ message: err.message }]\`.
|
|
881
|
+
|
|
882
|
+
### Server fetchers
|
|
883
|
+
- **\`cache.public\` on user-specific routes** — a CDN will cache one user's response and serve it to everyone. Only use \`cache.public: true\` on routes where the HTML is identical for all visitors.
|
|
884
|
+
- **Parallel fetchers** — multiple keys in \`server:\` run in parallel. If one fetcher depends on another's result, combine them into a single fetcher using \`Promise.all\`.
|
|
885
|
+
- **Fetcher throwing** — an unhandled throw goes to \`onError\` in \`createServer\`. Catch expected errors inside the fetcher and return a meaningful value (\`null\`, empty array) rather than throwing.
|
|
886
|
+
|
|
887
|
+
### Streaming
|
|
888
|
+
- **Segment names must match exactly** — if \`stream.deferred\` lists \`['feed']\`, the \`view\` object must have a key \`feed\`. A mismatch means the segment never resolves.
|
|
889
|
+
- **Shell is already sent when a deferred segment throws** — wrap deferred segment logic in try/catch and render a graceful error state rather than throwing.
|
|
890
|
+
- **Shell-only streaming** — \`stream: { shell: ['header'], deferred: [] }\` is valid. The shell streams immediately; the rest renders normally.
|
|
891
|
+
|
|
892
|
+
### Security
|
|
893
|
+
- **Inline \`style=""\` attributes are blocked by the default CSP** — \`style-src 'self'\` blocks all inline style attributes. Use CSS classes. If dynamic inline styles are genuinely needed, use a \`<style nonce="...">\` block and pass \`ctx.nonce\` through a server fetcher.
|
|
894
|
+
- **\`SameSite=None\` requires \`Secure\`** — browsers silently ignore \`SameSite=None\` cookies without \`Secure: true\`. Only use \`None\` for cross-site embedding, and only in production over HTTPS.
|
|
895
|
+
- **\`guard\` throwing vs returning** — a throw inside \`guard\` goes to \`onError\`. A returned \`{ redirect }\` sends a clean 302. Always catch auth/database errors inside guard and return a redirect rather than rethrowing.
|
|
896
|
+
- **Redirect loops** — if \`/dashboard\` guards redirect to \`/login\`, and \`/login\` guards redirect authenticated users to \`/dashboard\`, test both directions to confirm there is no loop.
|
|
897
|
+
|
|
898
|
+
### Performance & Lighthouse
|
|
899
|
+
- **Missing \`<main id="main-content">\`** — the framework injects a skip link targeting \`#main-content\` on every page. A missing element breaks the skip link and fails Lighthouse Accessibility.
|
|
900
|
+
- **Multiple \`<h1>\` elements** — one \`<h1>\` per page. Multiple at the same level fail Lighthouse Accessibility.
|
|
901
|
+
- **Images without \`width\` and \`height\`** — omitting dimensions prevents the browser from reserving space, causing layout shift and a non-zero CLS. Always provide both.
|
|
902
|
+
- **Colour contrast** — \`#888\` on \`#111\` is the practical minimum for muted text that passes WCAG AA. Lighter greys will fail. Check new colour tokens before committing.
|
|
903
|
+
- **Missing \`meta.description\`** — every page needs one. Omitting it fails Lighthouse SEO.
|
|
904
|
+
|
|
905
|
+
### Raw response specs
|
|
906
|
+
- **\`state\`, \`view\`, \`mutations\`, \`actions\` are ignored** on raw response specs (\`contentType\` + \`render\`). Do not include them.
|
|
907
|
+
- **\`render\` is synchronous** — all async work must happen in \`server\` fetchers. \`render(ctx, server)\` receives already-resolved data.
|
|
908
|
+
|
|
909
|
+
## Rules the agent must follow
|
|
910
|
+
|
|
911
|
+
- **Never set \`hydrate\`** — the framework sets it automatically.
|
|
912
|
+
- **Always wrap page content in \`<main id="main-content">\`** — the framework injects a skip link targeting this id on every page.
|
|
913
|
+
- **Never use \`data-event\` on text inputs** to mirror value into state. It destroys focus on every keystroke. Use uncontrolled inputs and read values from \`FormData\` in \`action.onStart\` instead. For client-side filtering/search, render all items in the HTML and use an inline \`<script>\` to show/hide elements — no state or re-render needed.
|
|
914
|
+
- **Never add \`<script>\` tags manually** — hydration is handled by the framework.
|
|
915
|
+
- **Never add external npm packages** for client-side behaviour — express it in the spec.
|
|
916
|
+
- **Always use \`pulse_list_structure\`** before creating pages or components to avoid duplicating what already exists.
|
|
917
|
+
- **Always validate with \`pulse_validate\`** before writing a spec file.
|
|
918
|
+
- **Preview on request** — if the user says "preview", "show me first", "draft the spec", or similar, generate the full spec as a code block and ask "Shall I write this?" before calling any \`pulse_create_*\` tool. Do not write any files until the user confirms.
|
|
919
|
+
- **Never edit \`pulse.config.js\` without explicit permission.** When a change to \`pulse.config.js\` is needed, show the exact proposed diff or updated block as a code snippet and ask "Shall I apply this?" before writing. Do not assume that asking to run a report or load test implies permission to change the config.
|
|
920
|
+
- **Never read or write \`.env\` files.** If an environment variable needs to be added or changed, tell the developer the variable name and value to set — do not touch the file. Credentials and secrets belong in the developer's environment, not in the agent's context.
|
|
921
|
+
- **Restart the dev server with \`/pulse-dev\`** after creating or renaming files (hot reload handles edits to existing files).
|
|
922
|
+
- **Always run a Lighthouse audit** after creating or significantly changing a page. Before checking results, read \`pulse.config.js\` and resolve the effective thresholds: start with global \`lighthouse\` config (defaults: all category scores 100; LCP 2500ms, CLS 0.1, TBT 200ms, FCP 1800ms, SI 3400ms, INP 200ms), merge selected environment's \`lighthouse\` overrides (if any), then merge \`routes['/path'].lighthouse\` on top. All scores and metrics must meet their effective threshold. Fix any failures before considering the task done. Common failures to watch for: colour contrast (use \`#888\` minimum for muted text on \`#111\` backgrounds), missing alt text, missing meta description.
|
|
923
|
+
- **Load testing is opt-in** — run \`/pulse-load\` when the user asks, or before shipping a page that fetches server data or handles significant traffic. Read \`pulse.config.js\` for \`load\` thresholds and environment/route overrides before checking results. Results are saved to the same report dashboard under the Load Tests tab.
|
|
924
|
+
- **Environments** — when \`environments\` is configured in \`pulse.config.js\`, select the environment before running \`/pulse-load\` or \`/pulse-report\`: use the \`default: true\` entry automatically (telling the user which one), or ask the user to choose if no default is set. Localhost environments follow the standard local build approach; remote environments are tested directly against their URL.
|
|
925
|
+
- **Always save Lighthouse results** to the report store immediately after every audit — even routine ones run during development. Run Lighthouse via \`npx --yes lighthouse <url> --output json --output-path /tmp/pulse-lhr.json --chrome-flags="--headless=new" --quiet 2>/dev/null\`, then extract all metrics with the node script in \`/pulse-report\` step 4, then run \`pulse save-report --url <url> --data '<extracted json>'\`. This builds the historical record used by \`/pulse-report\`. Do not use \`mcp__chrome-devtools__lighthouse_audit\` for saving reports — it does not return Performance scores or web vitals.
|
|
926
|
+
`
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function devCmd() {
|
|
930
|
+
return `Start (or restart) the Pulse dev server for this project.
|
|
931
|
+
|
|
932
|
+
\`\`\`bash
|
|
933
|
+
pulse stop; pulse dev
|
|
934
|
+
\`\`\`
|
|
935
|
+
|
|
936
|
+
The server port is read from \`pulse.config.js\` (defaults to 3000).
|
|
937
|
+
`
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function stopCmd() {
|
|
941
|
+
return `Stop the Pulse dev server for this project.
|
|
942
|
+
|
|
943
|
+
\`\`\`bash
|
|
944
|
+
pulse stop
|
|
945
|
+
\`\`\`
|
|
946
|
+
`
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function reportCmd() {
|
|
950
|
+
return `Run a Lighthouse audit on a page, save the results, and open the report dashboard.
|
|
951
|
+
|
|
952
|
+
## Steps
|
|
953
|
+
|
|
954
|
+
1. Read \`pulse.config.js\` to find the dev port (default 3000), Lighthouse thresholds, and environments.
|
|
955
|
+
|
|
956
|
+
**Resolve the effective environment:**
|
|
957
|
+
- Check if \`environments\` is defined in \`pulse.config.js\`
|
|
958
|
+
- If it is: use the entry marked \`default: true\` automatically (tell the user which one); if no default is set, list all environment names and ask the user to choose
|
|
959
|
+
- If \`environments\` is not defined, no selection is needed — local build approach is used
|
|
960
|
+
|
|
961
|
+
**Resolve effective Lighthouse thresholds** (apply in order, later values win):
|
|
962
|
+
1. Global \`lighthouse\` config (defaults: all category scores 100; LCP 2500ms, CLS 0.1, TBT 200ms, FCP 1800ms, SI 3400ms, INP 200ms)
|
|
963
|
+
2. Selected environment's \`lighthouse\` overrides (if any)
|
|
964
|
+
3. \`routes['/path'].lighthouse\` overrides (if any)
|
|
965
|
+
|
|
966
|
+
2. List available routes by reading \`src/pages/\` filenames. Present them to the user and ask which page to audit — e.g. \`/about\`. If only one page exists, proceed with it automatically.
|
|
967
|
+
|
|
968
|
+
3. **If the target URL is localhost** (environment \`url\` contains \`localhost\` or \`127.0.0.1\`, or no environment is configured):
|
|
969
|
+
|
|
970
|
+
a. Check the dev server is running:
|
|
971
|
+
\`\`\`bash
|
|
972
|
+
lsof -ti:<port> > /dev/null 2>&1 && echo "running" || echo "stopped"
|
|
973
|
+
\`\`\`
|
|
974
|
+
If it is not running, start it first with \`/pulse-dev\` and wait for it to be ready.
|
|
975
|
+
|
|
976
|
+
b. Build the project (required for accurate scores — dev mode inflates bundle sizes and skips compression):
|
|
977
|
+
\`\`\`bash
|
|
978
|
+
pulse build
|
|
979
|
+
\`\`\`
|
|
980
|
+
|
|
981
|
+
c. Start a temporary production server on \`devPort + 2\` (keeps the dev server untouched):
|
|
982
|
+
\`\`\`bash
|
|
983
|
+
pulse start --port <devPort+2> &
|
|
984
|
+
\`\`\`
|
|
985
|
+
Wait for it to be ready:
|
|
986
|
+
\`\`\`bash
|
|
987
|
+
node -e "
|
|
988
|
+
const http = require('http')
|
|
989
|
+
const port = <devPort+2>
|
|
990
|
+
const poll = (n) => {
|
|
991
|
+
if (n <= 0) { console.error('prod server did not start'); process.exit(1) }
|
|
992
|
+
http.get('http://localhost:' + port, () => process.exit(0))
|
|
993
|
+
.on('error', () => setTimeout(() => poll(n - 1), 300))
|
|
994
|
+
}
|
|
995
|
+
poll(20)
|
|
996
|
+
"
|
|
997
|
+
\`\`\`
|
|
998
|
+
|
|
999
|
+
d. The audit URL is \`http://localhost:<devPort+2>/<path>\`
|
|
1000
|
+
|
|
1001
|
+
4. **If the target URL is remote** (e.g. staging or production environment):
|
|
1002
|
+
|
|
1003
|
+
a. The audit URL is \`<envUrl>/<path>\` — use the environment URL directly
|
|
1004
|
+
b. No build or local server steps needed
|
|
1005
|
+
|
|
1006
|
+
5. Run Lighthouse against the audit URL. If the environment has \`headers\`, pass them via \`--extra-headers\`:
|
|
1007
|
+
\`\`\`bash
|
|
1008
|
+
# No headers:
|
|
1009
|
+
npx --yes lighthouse <auditUrl> --output json --output-path /tmp/pulse-lhr.json --chrome-flags="--headless=new" --quiet 2>/dev/null
|
|
1010
|
+
|
|
1011
|
+
# With headers (JSON object):
|
|
1012
|
+
npx --yes lighthouse <auditUrl> --output json --output-path /tmp/pulse-lhr.json --extra-headers='{"Key":"Value"}' --chrome-flags="--headless=new" --quiet 2>/dev/null
|
|
1013
|
+
\`\`\`
|
|
1014
|
+
|
|
1015
|
+
6. **If localhost target:** Kill the temporary production server:
|
|
1016
|
+
\`\`\`bash
|
|
1017
|
+
lsof -ti:<devPort+2> | xargs kill -9 2>/dev/null; true
|
|
1018
|
+
\`\`\`
|
|
1019
|
+
|
|
1020
|
+
7. Parse the Lighthouse JSON and extract all metrics:
|
|
1021
|
+
\`\`\`bash
|
|
1022
|
+
node -e "
|
|
1023
|
+
const lhr = JSON.parse(require('fs').readFileSync('/tmp/pulse-lhr.json', 'utf8'))
|
|
1024
|
+
const s = lhr.categories
|
|
1025
|
+
const a = lhr.audits
|
|
1026
|
+
const rs = (a['resource-summary']?.details?.items) || []
|
|
1027
|
+
const total = rs.find(i => i.resourceType === 'total')
|
|
1028
|
+
const js = rs.find(i => i.resourceType === 'script')
|
|
1029
|
+
const css = rs.find(i => i.resourceType === 'stylesheet')
|
|
1030
|
+
const d = {
|
|
1031
|
+
scores: {
|
|
1032
|
+
performance: Math.round(s.performance.score * 100),
|
|
1033
|
+
accessibility: Math.round(s.accessibility.score * 100),
|
|
1034
|
+
bestPractices: Math.round(s['best-practices'].score * 100),
|
|
1035
|
+
seo: Math.round(s.seo.score * 100),
|
|
1036
|
+
},
|
|
1037
|
+
metrics: {
|
|
1038
|
+
lcp: Math.round(a['largest-contentful-paint'].numericValue),
|
|
1039
|
+
cls: parseFloat(a['cumulative-layout-shift'].numericValue.toFixed(2)),
|
|
1040
|
+
fcp: Math.round(a['first-contentful-paint'].numericValue),
|
|
1041
|
+
tbt: Math.round(a['total-blocking-time'].numericValue),
|
|
1042
|
+
ttfb: Math.round(a['server-response-time'].numericValue),
|
|
1043
|
+
si: Math.round(a['speed-index'].numericValue),
|
|
1044
|
+
pageWeight: total ? parseFloat((total.transferSize/1024).toFixed(1)) : undefined,
|
|
1045
|
+
jsBytes: js ? parseFloat((js.transferSize/1024).toFixed(1)) : undefined,
|
|
1046
|
+
cssBytes: css ? parseFloat((css.transferSize/1024).toFixed(1)) : undefined,
|
|
1047
|
+
requests: total ? total.requestCount : undefined,
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
Object.keys(d.metrics).forEach(k => { if (d.metrics[k] === undefined || (typeof d.metrics[k] === 'number' && isNaN(d.metrics[k]))) delete d.metrics[k] })
|
|
1051
|
+
console.log(JSON.stringify(d))
|
|
1052
|
+
"
|
|
1053
|
+
\`\`\`
|
|
1054
|
+
|
|
1055
|
+
8. Check results against effective thresholds (from step 1). Fail and report any score or metric that falls outside its threshold.
|
|
1056
|
+
|
|
1057
|
+
9. Save the report — always use the **dev server URL** (\`http://localhost:<devPort>/<path>\`) as the canonical URL so reports are grouped by page, not by environment:
|
|
1058
|
+
\`\`\`bash
|
|
1059
|
+
pulse save-report --url http://localhost:<devPort>/<path> --data '<json from step 7>'
|
|
1060
|
+
\`\`\`
|
|
1061
|
+
|
|
1062
|
+
10. Start (or restart) the report server and wait until it is ready:
|
|
1063
|
+
\`\`\`bash
|
|
1064
|
+
node -e "
|
|
1065
|
+
import('./pulse.config.js').then(m => {
|
|
1066
|
+
const port = (m.default?.reportPort) || (m.default?.port || 3000) + 1
|
|
1067
|
+
process.stdout.write(String(port))
|
|
1068
|
+
}).catch(() => process.stdout.write('3001'))
|
|
1069
|
+
" | xargs -I{} sh -c '
|
|
1070
|
+
lsof -ti:{} | xargs kill -9 2>/dev/null
|
|
1071
|
+
pulse report-server --port {} &
|
|
1072
|
+
node -e "
|
|
1073
|
+
const http = require(\\"http\\")
|
|
1074
|
+
const port = {}
|
|
1075
|
+
const poll = (n) => {
|
|
1076
|
+
if (n <= 0) process.exit(1)
|
|
1077
|
+
http.get(\\"http://localhost:\\" + port, () => process.exit(0))
|
|
1078
|
+
.on(\\"error\\", () => setTimeout(() => poll(n - 1), 300))
|
|
1079
|
+
}
|
|
1080
|
+
poll(20)
|
|
1081
|
+
"
|
|
1082
|
+
'
|
|
1083
|
+
\`\`\`
|
|
1084
|
+
|
|
1085
|
+
11. Tell the user their report is ready at \`http://localhost:[devPort+1]\` (e.g. \`http://localhost:3001\`). Include which environment was audited.
|
|
1086
|
+
`
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function loadCmd() {
|
|
1090
|
+
return `Run a load test, save the results, and open the load report tab.
|
|
1091
|
+
|
|
1092
|
+
## Steps
|
|
1093
|
+
|
|
1094
|
+
1. Read \`pulse.config.js\` to find the dev port (default 3000), load config, and environments.
|
|
1095
|
+
|
|
1096
|
+
**Resolve the effective environment:**
|
|
1097
|
+
- Check if \`environments\` is defined in \`pulse.config.js\`
|
|
1098
|
+
- If it is: use the entry marked \`default: true\` automatically (tell the user which one); if no default is set, list all environment names and ask the user to choose
|
|
1099
|
+
- If \`environments\` is not defined, no selection is needed — local build approach is used
|
|
1100
|
+
|
|
1101
|
+
**Merge load config** (apply in order, later values win):
|
|
1102
|
+
\`\`\`js
|
|
1103
|
+
// Defaults:
|
|
1104
|
+
// load.duration = 10 (seconds)
|
|
1105
|
+
// load.connections = 10 (concurrent)
|
|
1106
|
+
// load.thresholds = { rps: undefined, p99: undefined, errors: 0 }
|
|
1107
|
+
\`\`\`
|
|
1108
|
+
Global \`load\` → selected environment's \`load\` overrides → \`routes['/path'].load\` overrides.
|
|
1109
|
+
|
|
1110
|
+
2. List available routes from \`src/pages/\` and ask the user which route to test. If only one route exists, proceed automatically.
|
|
1111
|
+
|
|
1112
|
+
3. **If the target URL is localhost** (environment \`url\` contains \`localhost\` or \`127.0.0.1\`, or no environment is configured):
|
|
1113
|
+
|
|
1114
|
+
a. Build the project:
|
|
1115
|
+
\`\`\`bash
|
|
1116
|
+
pulse build
|
|
1117
|
+
\`\`\`
|
|
1118
|
+
|
|
1119
|
+
b. Start a temporary production server on \`devPort + 2\` (keeps the dev server untouched):
|
|
1120
|
+
\`\`\`bash
|
|
1121
|
+
pulse start --port <devPort+2> &
|
|
1122
|
+
\`\`\`
|
|
1123
|
+
Wait for it to be ready:
|
|
1124
|
+
\`\`\`bash
|
|
1125
|
+
node -e "
|
|
1126
|
+
const http = require('http')
|
|
1127
|
+
const port = <devPort+2>
|
|
1128
|
+
const poll = (n) => {
|
|
1129
|
+
if (n <= 0) { console.error('prod server did not start'); process.exit(1) }
|
|
1130
|
+
http.get('http://localhost:' + port, () => process.exit(0))
|
|
1131
|
+
.on('error', () => setTimeout(() => poll(n - 1), 300))
|
|
1132
|
+
}
|
|
1133
|
+
poll(20)
|
|
1134
|
+
"
|
|
1135
|
+
\`\`\`
|
|
1136
|
+
|
|
1137
|
+
c. The test URL is \`http://localhost:<devPort+2>/<path>\`
|
|
1138
|
+
|
|
1139
|
+
4. **If the target URL is remote** (e.g. staging or production environment):
|
|
1140
|
+
|
|
1141
|
+
a. The test URL is \`<envUrl>/<path>\` — use the environment URL directly
|
|
1142
|
+
b. No build or local server steps needed
|
|
1143
|
+
|
|
1144
|
+
5. Run the load test. Pass each header from the environment's \`headers\` object as a separate \`--header "Key: Value"\` argument:
|
|
1145
|
+
\`\`\`bash
|
|
1146
|
+
pulse load-test --url <testUrl> --duration <duration> --connections <connections> [--header "Key: Value" ...]
|
|
1147
|
+
\`\`\`
|
|
1148
|
+
This prints a JSON result to stdout. Capture it.
|
|
1149
|
+
|
|
1150
|
+
6. **If localhost target:** Kill the temporary production server:
|
|
1151
|
+
\`\`\`bash
|
|
1152
|
+
lsof -ti:<devPort+2> | xargs kill -9 2>/dev/null; true
|
|
1153
|
+
\`\`\`
|
|
1154
|
+
|
|
1155
|
+
7. Check results against effective thresholds (from step 1). Fail and report if:
|
|
1156
|
+
- \`rps\` is below \`thresholds.rps\` (if set)
|
|
1157
|
+
- \`latency.p99\` exceeds \`thresholds.p99\` (if set)
|
|
1158
|
+
- \`requests.errors\` exceeds \`thresholds.errors\` (default: 0)
|
|
1159
|
+
|
|
1160
|
+
8. Save the result — always use the **dev server URL** (\`http://localhost:<devPort>/<path>\`) as the canonical URL so reports are grouped by page, not by environment:
|
|
1161
|
+
\`\`\`bash
|
|
1162
|
+
pulse save-load-report --url http://localhost:<devPort>/<path> --data '<json from step 5>'
|
|
1163
|
+
\`\`\`
|
|
1164
|
+
|
|
1165
|
+
9. Start (or restart) the report server and wait until ready:
|
|
1166
|
+
\`\`\`bash
|
|
1167
|
+
node -e "
|
|
1168
|
+
import('./pulse.config.js').then(m => {
|
|
1169
|
+
const port = (m.default?.reportPort) || (m.default?.port || 3000) + 1
|
|
1170
|
+
process.stdout.write(String(port))
|
|
1171
|
+
}).catch(() => process.stdout.write('3001'))
|
|
1172
|
+
" | xargs -I{} sh -c '
|
|
1173
|
+
lsof -ti:{} | xargs kill -9 2>/dev/null
|
|
1174
|
+
pulse report-server --port {} &
|
|
1175
|
+
node -e "
|
|
1176
|
+
const http = require(\\"http\\")
|
|
1177
|
+
const port = {}
|
|
1178
|
+
const poll = (n) => {
|
|
1179
|
+
if (n <= 0) process.exit(1)
|
|
1180
|
+
http.get(\\"http://localhost:\\" + port, () => process.exit(0))
|
|
1181
|
+
.on(\\"error\\", () => setTimeout(() => poll(n - 1), 300))
|
|
1182
|
+
}
|
|
1183
|
+
poll(20)
|
|
1184
|
+
"
|
|
1185
|
+
'
|
|
1186
|
+
\`\`\`
|
|
1187
|
+
|
|
1188
|
+
10. Tell the user the load report is ready at \`http://localhost:[reportPort]/[slug]/load\` (e.g. \`http://localhost:3001/home/load\`). Include which environment was tested. For localhost results, remind them that numbers are useful for relative comparison across runs — not as production capacity estimates.
|
|
1189
|
+
`
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function buildCmd() {
|
|
1193
|
+
return `Build this Pulse project for production.
|
|
1194
|
+
|
|
1195
|
+
Run:
|
|
1196
|
+
|
|
1197
|
+
\`\`\`bash
|
|
1198
|
+
pulse build
|
|
1199
|
+
\`\`\`
|
|
1200
|
+
|
|
1201
|
+
This bundles all pages into \`public/dist/\`. When complete, confirm the build succeeded and list the generated files.
|
|
1202
|
+
`
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function startCmd() {
|
|
1206
|
+
return `Start the Pulse production server for this project.
|
|
1207
|
+
|
|
1208
|
+
First ensure a build exists (\`public/dist/\`). Then run:
|
|
1209
|
+
|
|
1210
|
+
\`\`\`bash
|
|
1211
|
+
pulse start
|
|
1212
|
+
\`\`\`
|
|
1213
|
+
|
|
1214
|
+
The production server starts at http://localhost:3000.
|
|
1215
|
+
`
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function contributeCmd() {
|
|
1219
|
+
return `Implement a Pulse framework-level feature and open a pull request to the main repository.
|
|
1220
|
+
|
|
1221
|
+
Use this when the current project needs something Pulse does not yet support, and the right fix is to add it to the framework itself rather than work around it.
|
|
1222
|
+
|
|
1223
|
+
## Step 1 — Describe and scope the change
|
|
1224
|
+
|
|
1225
|
+
Before touching any code, write a one-paragraph description of:
|
|
1226
|
+
- What the feature does
|
|
1227
|
+
- Which part of the framework needs to change (spec schema, server, SSR, client runtime, CLI, or build)
|
|
1228
|
+
- Why it cannot be done with existing escape hatches (\`onRequest\`, raw response spec, \`onError\`, inline \`<script nonce>\`)
|
|
1229
|
+
|
|
1230
|
+
Show this to the developer and confirm before proceeding.
|
|
1231
|
+
|
|
1232
|
+
## Step 2 — Locate the framework source
|
|
1233
|
+
|
|
1234
|
+
Check for the framework repo in this order:
|
|
1235
|
+
|
|
1236
|
+
\`\`\`bash
|
|
1237
|
+
# 1. Already cloned alongside this project?
|
|
1238
|
+
ls ../pulse2/src 2>/dev/null && echo "found at ../pulse2"
|
|
1239
|
+
|
|
1240
|
+
# 2. Available elsewhere on disk?
|
|
1241
|
+
find ~ -maxdepth 5 -name "pulse2" -type d 2>/dev/null | head -3
|
|
1242
|
+
|
|
1243
|
+
# 3. Clone it if not found
|
|
1244
|
+
gh repo clone invisibleloop/pulse /tmp/pulse-contrib
|
|
1245
|
+
\`\`\`
|
|
1246
|
+
|
|
1247
|
+
Use whichever path is found. All subsequent steps run from that directory.
|
|
1248
|
+
|
|
1249
|
+
## Step 3 — Read before writing
|
|
1250
|
+
|
|
1251
|
+
Read the files relevant to your change. Match the existing patterns exactly — do not introduce new patterns without a strong reason.
|
|
1252
|
+
|
|
1253
|
+
| Adding | Read first |
|
|
1254
|
+
|--------|-----------|
|
|
1255
|
+
| New spec property | \`src/spec/schema.js\`, \`src/server/index.js\`, \`src/runtime/ssr.js\` |
|
|
1256
|
+
| New server behaviour | \`src/server/index.js\` |
|
|
1257
|
+
| New client behaviour | \`src/runtime/index.js\`, \`src/runtime/navigate.js\` |
|
|
1258
|
+
| New CLI command | \`src/cli/index.js\` and one existing command file for style reference |
|
|
1259
|
+
| New build option | \`scripts/build.js\` |
|
|
1260
|
+
| Scaffold change | \`src/cli/scaffold.js\` |
|
|
1261
|
+
|
|
1262
|
+
Also read the existing test file(s) for each file you will change, so your tests follow the same style.
|
|
1263
|
+
|
|
1264
|
+
## Step 4 — Create a branch
|
|
1265
|
+
|
|
1266
|
+
\`\`\`bash
|
|
1267
|
+
cd <framework-path>
|
|
1268
|
+
git checkout main
|
|
1269
|
+
git pull origin main
|
|
1270
|
+
git checkout -b feat/<short-description>
|
|
1271
|
+
\`\`\`
|
|
1272
|
+
|
|
1273
|
+
Use \`feat/\`, \`fix/\`, \`docs/\` prefixes matching the change type.
|
|
1274
|
+
|
|
1275
|
+
## Step 5 — Implement
|
|
1276
|
+
|
|
1277
|
+
Make the smallest change that fully implements the feature. Constraints:
|
|
1278
|
+
|
|
1279
|
+
- **Match the existing code style exactly** — spacing, naming, comment style, error message format.
|
|
1280
|
+
- **No new runtime dependencies.** The server and client runtime are zero-dependency. esbuild is the only dev dependency.
|
|
1281
|
+
- **Schema changes must be backward-compatible.** New spec properties must be optional.
|
|
1282
|
+
- **Error messages must be actionable.** Say what was wrong and what to provide instead.
|
|
1283
|
+
- **Security headers are on by default.** Any new HTTP response path must include the full security header set.
|
|
1284
|
+
|
|
1285
|
+
### Checklist for adding a spec property
|
|
1286
|
+
|
|
1287
|
+
- [ ] \`src/spec/schema.js\` — add property definition, type check, and validation error message
|
|
1288
|
+
- [ ] \`src/server/index.js\` — read the property and pass it to the renderer
|
|
1289
|
+
- [ ] \`src/runtime/ssr.js\` — render it into the HTML output
|
|
1290
|
+
- [ ] \`src/runtime/index.js\` — handle on the client if there is client-side behaviour
|
|
1291
|
+
- [ ] All relevant test files updated
|
|
1292
|
+
|
|
1293
|
+
## Step 6 — Write tests
|
|
1294
|
+
|
|
1295
|
+
Add tests in the \`.test.js\` file alongside each file changed. Use Node's built-in test runner:
|
|
1296
|
+
|
|
1297
|
+
\`\`\`js
|
|
1298
|
+
import { test } from 'node:test'
|
|
1299
|
+
import assert from 'node:assert/strict'
|
|
1300
|
+
\`\`\`
|
|
1301
|
+
|
|
1302
|
+
Cover the happy path, invalid input with the correct error message, and edge cases. Do not delete or modify existing tests unless the change intentionally breaks backward compatibility (confirm with the developer first).
|
|
1303
|
+
|
|
1304
|
+
## Step 7 — Run all tests
|
|
1305
|
+
|
|
1306
|
+
\`\`\`bash
|
|
1307
|
+
cd <framework-path>
|
|
1308
|
+
npm test
|
|
1309
|
+
\`\`\`
|
|
1310
|
+
|
|
1311
|
+
All tests must pass. Fix any failures before continuing.
|
|
1312
|
+
|
|
1313
|
+
## Step 8 — Commit
|
|
1314
|
+
|
|
1315
|
+
\`\`\`bash
|
|
1316
|
+
git add -p
|
|
1317
|
+
git commit -m "feat: <short description>
|
|
1318
|
+
|
|
1319
|
+
<one or two sentences on what was added and why>"
|
|
1320
|
+
\`\`\`
|
|
1321
|
+
|
|
1322
|
+
## Step 9 — Push and open a PR
|
|
1323
|
+
|
|
1324
|
+
\`\`\`bash
|
|
1325
|
+
git push origin feat/<short-description>
|
|
1326
|
+
|
|
1327
|
+
gh pr create \\
|
|
1328
|
+
--title "feat: <short description>" \\
|
|
1329
|
+
--body "$(cat <<'EOF'
|
|
1330
|
+
## What
|
|
1331
|
+
|
|
1332
|
+
<1–3 sentences describing what this adds or fixes.>
|
|
1333
|
+
|
|
1334
|
+
## Why
|
|
1335
|
+
|
|
1336
|
+
<1–2 sentences on the motivation — what could not be done before, or what was broken.>
|
|
1337
|
+
|
|
1338
|
+
## Changes
|
|
1339
|
+
|
|
1340
|
+
- \`src/spec/schema.js\` — <what changed>
|
|
1341
|
+
- \`src/server/index.js\` — <what changed>
|
|
1342
|
+
|
|
1343
|
+
## Tests
|
|
1344
|
+
|
|
1345
|
+
- [ ] All existing tests pass (\`npm test\`)
|
|
1346
|
+
- [ ] New tests added for the happy path
|
|
1347
|
+
- [ ] New tests added for invalid input
|
|
1348
|
+
- [ ] Manually tested in a Pulse project
|
|
1349
|
+
|
|
1350
|
+
## Notes
|
|
1351
|
+
|
|
1352
|
+
<Any tradeoffs, limitations, or follow-up work worth flagging.>
|
|
1353
|
+
EOF
|
|
1354
|
+
)"
|
|
1355
|
+
\`\`\`
|
|
1356
|
+
|
|
1357
|
+
## Step 10 — Report back
|
|
1358
|
+
|
|
1359
|
+
Tell the developer the PR URL, a plain-English summary of what changed and which files, and any limitations or follow-up work they should know about.
|
|
1360
|
+
`
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// ---------------------------------------------------------------------------
|
|
1364
|
+
// Helpers
|
|
1365
|
+
// ---------------------------------------------------------------------------
|
|
1366
|
+
|
|
1367
|
+
function write(dir, relPath, content) {
|
|
1368
|
+
const filePath = path.join(dir, relPath)
|
|
1369
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
1370
|
+
fs.writeFileSync(filePath, content, 'utf8')
|
|
1371
|
+
}
|