@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,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse Testing — Minimal HTML tokenizer and CSS selector engine
|
|
3
|
+
*
|
|
4
|
+
* No dependencies. Handles common HTML patterns well enough for test assertions.
|
|
5
|
+
* Supports: tag, .class, #id, [attr], [attr="value"] and combinations thereof.
|
|
6
|
+
* Does NOT support descendant combinators (div p) — match within el.findAll() instead.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Constants
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
const VOID_TAGS = new Set([
|
|
14
|
+
'area','base','br','col','embed','hr','img','input',
|
|
15
|
+
'link','meta','param','source','track','wbr',
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
// These tags contain raw text — don't parse their content as HTML
|
|
19
|
+
const RAW_TAGS = new Set(['script','style','noscript'])
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Tokenizer
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Find the position of the closing > of a tag, respecting quoted attribute values.
|
|
27
|
+
* @param {string} html
|
|
28
|
+
* @param {number} from Index just after the opening <
|
|
29
|
+
*/
|
|
30
|
+
function findTagEnd(html, from) {
|
|
31
|
+
let i = from
|
|
32
|
+
let inSingle = false, inDouble = false
|
|
33
|
+
while (i < html.length) {
|
|
34
|
+
const c = html[i]
|
|
35
|
+
if (c === '"' && !inSingle) inDouble = !inDouble
|
|
36
|
+
else if (c === "'" && !inDouble) inSingle = !inSingle
|
|
37
|
+
else if (c === '>' && !inSingle && !inDouble) return i
|
|
38
|
+
i++
|
|
39
|
+
}
|
|
40
|
+
return -1
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse an attribute string into a plain object.
|
|
45
|
+
* Boolean attributes (no value) are stored as true.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} str
|
|
48
|
+
* @returns {Record<string, string|true>}
|
|
49
|
+
*/
|
|
50
|
+
function parseAttrs(str) {
|
|
51
|
+
const attrs = {}
|
|
52
|
+
// Matches: name name="val" name='val' name=val
|
|
53
|
+
const re = /([a-zA-Z_][\w\-:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]*)))?/g
|
|
54
|
+
let m
|
|
55
|
+
while ((m = re.exec(str)) !== null) {
|
|
56
|
+
const name = m[1].toLowerCase()
|
|
57
|
+
const val = m[2] !== undefined ? m[2]
|
|
58
|
+
: m[3] !== undefined ? m[3]
|
|
59
|
+
: m[4] !== undefined ? m[4]
|
|
60
|
+
: true
|
|
61
|
+
attrs[name] = val
|
|
62
|
+
}
|
|
63
|
+
return attrs
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function decodeEntities(str) {
|
|
67
|
+
return str
|
|
68
|
+
.replace(/&/g, '&')
|
|
69
|
+
.replace(/</g, '<')
|
|
70
|
+
.replace(/>/g, '>')
|
|
71
|
+
.replace(/"/g, '"')
|
|
72
|
+
.replace(/�?39;/g, "'")
|
|
73
|
+
.replace(/'/g, "'")
|
|
74
|
+
.replace(/ /g, ' ')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Tokenize an HTML string into a flat array of tokens.
|
|
79
|
+
*
|
|
80
|
+
* Token shapes:
|
|
81
|
+
* { type: 'open', tag, attrs, selfClose }
|
|
82
|
+
* { type: 'close', tag }
|
|
83
|
+
* { type: 'text', text }
|
|
84
|
+
*
|
|
85
|
+
* @param {string} html
|
|
86
|
+
* @returns {Array}
|
|
87
|
+
*/
|
|
88
|
+
export function tokenize(html) {
|
|
89
|
+
const tokens = []
|
|
90
|
+
let i = 0
|
|
91
|
+
|
|
92
|
+
while (i < html.length) {
|
|
93
|
+
// Text node
|
|
94
|
+
if (html[i] !== '<') {
|
|
95
|
+
const next = html.indexOf('<', i)
|
|
96
|
+
const slice = next === -1 ? html.slice(i) : html.slice(i, next)
|
|
97
|
+
const text = decodeEntities(slice.trim())
|
|
98
|
+
if (text) tokens.push({ type: 'text', text })
|
|
99
|
+
i = next === -1 ? html.length : next
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const tagEnd = findTagEnd(html, i + 1)
|
|
104
|
+
if (tagEnd === -1) break
|
|
105
|
+
|
|
106
|
+
const raw = html.slice(i + 1, tagEnd).trim()
|
|
107
|
+
|
|
108
|
+
// Skip doctype, comments, CDATA, processing instructions
|
|
109
|
+
if (raw.startsWith('!') || raw.startsWith('?')) {
|
|
110
|
+
i = tagEnd + 1
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Closing tag
|
|
115
|
+
if (raw.startsWith('/')) {
|
|
116
|
+
const tag = raw.slice(1).trim().split(/\s/)[0].toLowerCase()
|
|
117
|
+
tokens.push({ type: 'close', tag })
|
|
118
|
+
i = tagEnd + 1
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Opening tag (possibly self-closing)
|
|
123
|
+
const hasSelfSlash = raw.endsWith('/')
|
|
124
|
+
const content = hasSelfSlash ? raw.slice(0, -1).trimEnd() : raw
|
|
125
|
+
const spaceIdx = content.search(/\s/)
|
|
126
|
+
const tag = (spaceIdx === -1 ? content : content.slice(0, spaceIdx)).toLowerCase()
|
|
127
|
+
const attrsStr = spaceIdx === -1 ? '' : content.slice(spaceIdx)
|
|
128
|
+
const attrs = parseAttrs(attrsStr)
|
|
129
|
+
const selfClose = hasSelfSlash || VOID_TAGS.has(tag)
|
|
130
|
+
|
|
131
|
+
tokens.push({ type: 'open', tag, attrs, selfClose })
|
|
132
|
+
if (selfClose) tokens.push({ type: 'close', tag })
|
|
133
|
+
|
|
134
|
+
i = tagEnd + 1
|
|
135
|
+
|
|
136
|
+
// Skip raw content of script/style
|
|
137
|
+
if (RAW_TAGS.has(tag) && !selfClose) {
|
|
138
|
+
const closeTag = `</${tag}`
|
|
139
|
+
const closeIdx = html.toLowerCase().indexOf(closeTag, i)
|
|
140
|
+
if (closeIdx !== -1) {
|
|
141
|
+
const closeTagEnd = html.indexOf('>', closeIdx)
|
|
142
|
+
tokens.push({ type: 'close', tag })
|
|
143
|
+
i = closeTagEnd === -1 ? html.length : closeTagEnd + 1
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return tokens
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Selector parsing
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Parse a simple CSS selector string.
|
|
157
|
+
* Supports: tag .class #id [attr] [attr="value"] and combinations.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} selector
|
|
160
|
+
* @returns {{ tag: string|null, id: string|null, classes: string[], attrs: Array<{name,value}> }}
|
|
161
|
+
*/
|
|
162
|
+
export function parseSelector(selector) {
|
|
163
|
+
const result = { tag: null, id: null, classes: [], attrs: [] }
|
|
164
|
+
let s = selector.trim()
|
|
165
|
+
|
|
166
|
+
// Optional leading tag name
|
|
167
|
+
const tagMatch = s.match(/^([a-zA-Z][a-zA-Z0-9]*)/)
|
|
168
|
+
if (tagMatch) {
|
|
169
|
+
result.tag = tagMatch[1].toLowerCase()
|
|
170
|
+
s = s.slice(tagMatch[0].length)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// .class, #id, [attr], [attr="value"]
|
|
174
|
+
const partRe = /([.#[])([^\s.[#\]"'=]+)(?:="([^"]*)")?]?/g
|
|
175
|
+
let m
|
|
176
|
+
while ((m = partRe.exec(s)) !== null) {
|
|
177
|
+
const prefix = m[1]
|
|
178
|
+
if (prefix === '.') result.classes.push(m[2])
|
|
179
|
+
else if (prefix === '#') result.id = m[2]
|
|
180
|
+
else if (prefix === '[') result.attrs.push({ name: m[2].toLowerCase(), value: m[3] ?? null })
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return result
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Matching
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Test whether an 'open' token matches a parsed selector.
|
|
192
|
+
*
|
|
193
|
+
* @param {{ type, tag, attrs }} token
|
|
194
|
+
* @param {{ tag, id, classes, attrs }} sel
|
|
195
|
+
*/
|
|
196
|
+
export function matchesToken(token, sel) {
|
|
197
|
+
if (token.type !== 'open') return false
|
|
198
|
+
if (sel.tag !== null && token.tag !== sel.tag) return false
|
|
199
|
+
if (sel.id !== null && token.attrs['id'] !== sel.id) return false
|
|
200
|
+
if (sel.classes.length) {
|
|
201
|
+
const cls = String(token.attrs['class'] || '').split(/\s+/)
|
|
202
|
+
if (!sel.classes.every(c => cls.includes(c))) return false
|
|
203
|
+
}
|
|
204
|
+
for (const { name, value } of sel.attrs) {
|
|
205
|
+
if (!(name in token.attrs)) return false
|
|
206
|
+
if (value !== null && token.attrs[name] !== value) return false
|
|
207
|
+
}
|
|
208
|
+
return true
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Tree navigation
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Return the slice of tokens representing the inner content of an open element.
|
|
217
|
+
* Handles nested same-tag elements correctly.
|
|
218
|
+
*
|
|
219
|
+
* @param {Array} tokens
|
|
220
|
+
* @param {number} openIdx Index of the 'open' token
|
|
221
|
+
* @returns {Array}
|
|
222
|
+
*/
|
|
223
|
+
export function getInnerTokens(tokens, openIdx) {
|
|
224
|
+
if (tokens[openIdx].selfClose) return []
|
|
225
|
+
const tag = tokens[openIdx].tag
|
|
226
|
+
let depth = 1
|
|
227
|
+
let i = openIdx + 1
|
|
228
|
+
while (i < tokens.length && depth > 0) {
|
|
229
|
+
const tok = tokens[i]
|
|
230
|
+
if (tok.type === 'open' && tok.tag === tag && !tok.selfClose) depth++
|
|
231
|
+
else if (tok.type === 'close' && tok.tag === tag) depth--
|
|
232
|
+
i++
|
|
233
|
+
}
|
|
234
|
+
return tokens.slice(openIdx + 1, depth === 0 ? i - 1 : i)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Extract all text content from a token list, space-separated.
|
|
239
|
+
*
|
|
240
|
+
* @param {Array} tokens
|
|
241
|
+
* @returns {string}
|
|
242
|
+
*/
|
|
243
|
+
export function extractText(tokens) {
|
|
244
|
+
return tokens
|
|
245
|
+
.filter(t => t.type === 'text')
|
|
246
|
+
.map(t => t.text)
|
|
247
|
+
.join(' ')
|
|
248
|
+
.replace(/\s+/g, ' ')
|
|
249
|
+
.trim()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Query API
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Find the first token matching selector.
|
|
258
|
+
* @param {Array} tokens
|
|
259
|
+
* @param {string} selector
|
|
260
|
+
* @returns {{ token, index } | null}
|
|
261
|
+
*/
|
|
262
|
+
export function findFirst(tokens, selector) {
|
|
263
|
+
const sel = parseSelector(selector)
|
|
264
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
265
|
+
if (matchesToken(tokens[i], sel)) return { token: tokens[i], index: i }
|
|
266
|
+
}
|
|
267
|
+
return null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Find all tokens matching selector.
|
|
272
|
+
* @param {Array} tokens
|
|
273
|
+
* @param {string} selector
|
|
274
|
+
* @returns {Array<{ token, index }>}
|
|
275
|
+
*/
|
|
276
|
+
export function findAll(tokens, selector) {
|
|
277
|
+
const sel = parseSelector(selector)
|
|
278
|
+
const results = []
|
|
279
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
280
|
+
if (matchesToken(tokens[i], sel)) results.push({ token: tokens[i], index: i })
|
|
281
|
+
}
|
|
282
|
+
return results
|
|
283
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse Testing — View testing helpers
|
|
3
|
+
*
|
|
4
|
+
* Render a spec's view in tests and assert on the HTML output.
|
|
5
|
+
* No DOM, no jsdom, no browser — pure Node.js.
|
|
6
|
+
*
|
|
7
|
+
* Two entry points:
|
|
8
|
+
* renderSync(spec, options?) — synchronous, calls view directly
|
|
9
|
+
* render(spec, options?) — async, runs real spec.server fetchers
|
|
10
|
+
*
|
|
11
|
+
* Both return a RenderResult with HTML query helpers.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { tokenize, findFirst, findAll, getInnerTokens, extractText } from './html.js'
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Element
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Wrap a matched token as a queryable Element.
|
|
22
|
+
*
|
|
23
|
+
* @param {{ type, tag, attrs, selfClose }} token
|
|
24
|
+
* @param {Array} allTokens Full token list (for inner content extraction)
|
|
25
|
+
* @param {number} idx Position of this token in allTokens
|
|
26
|
+
*/
|
|
27
|
+
function makeElement(token, allTokens, idx) {
|
|
28
|
+
const inner = getInnerTokens(allTokens, idx)
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
/** Element tag name (lowercase) */
|
|
32
|
+
tag: token.tag,
|
|
33
|
+
/** Parsed attribute map. Boolean attrs (e.g. disabled) have value true. */
|
|
34
|
+
attrs: token.attrs,
|
|
35
|
+
/** All text content within this element, whitespace-collapsed. */
|
|
36
|
+
text: extractText(inner),
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get an attribute value. Returns '' for boolean attrs, null if absent.
|
|
40
|
+
* Mirrors DOM getAttribute() behaviour.
|
|
41
|
+
* @param {string} name
|
|
42
|
+
* @returns {string | null}
|
|
43
|
+
*/
|
|
44
|
+
attr(name) {
|
|
45
|
+
const v = token.attrs[name.toLowerCase()]
|
|
46
|
+
if (v === undefined) return null
|
|
47
|
+
if (v === true) return ''
|
|
48
|
+
return String(v)
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Find the first descendant matching selector. Returns null if not found.
|
|
53
|
+
* @param {string} selector
|
|
54
|
+
*/
|
|
55
|
+
find(selector) {
|
|
56
|
+
const m = findFirst(inner, selector)
|
|
57
|
+
return m ? makeElement(m.token, inner, m.index) : null
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Find all descendants matching selector.
|
|
62
|
+
* @param {string} selector
|
|
63
|
+
* @returns {Element[]}
|
|
64
|
+
*/
|
|
65
|
+
findAll(selector) {
|
|
66
|
+
return findAll(inner, selector).map(({ token: t, index: i }) => makeElement(t, inner, i))
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* True if any descendant matches selector.
|
|
71
|
+
* @param {string} selector
|
|
72
|
+
*/
|
|
73
|
+
has(selector) {
|
|
74
|
+
return findFirst(inner, selector) !== null
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// RenderResult
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build a RenderResult from raw HTML + the state/server used.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} html
|
|
87
|
+
* @param {object} state
|
|
88
|
+
* @param {object} server
|
|
89
|
+
*/
|
|
90
|
+
function makeResult(html, state, server) {
|
|
91
|
+
const tokens = tokenize(html)
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
/** Raw HTML string returned by the view function(s). */
|
|
95
|
+
html,
|
|
96
|
+
/** Client state used for rendering. */
|
|
97
|
+
state,
|
|
98
|
+
/** Server state used for rendering. */
|
|
99
|
+
server,
|
|
100
|
+
|
|
101
|
+
/** All text content in the output, whitespace-collapsed. */
|
|
102
|
+
text() {
|
|
103
|
+
return extractText(tokens)
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* True if any element in the output matches selector.
|
|
108
|
+
* @param {string} selector
|
|
109
|
+
*/
|
|
110
|
+
has(selector) {
|
|
111
|
+
return findFirst(tokens, selector) !== null
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Find the first element matching selector. Returns null if not found.
|
|
116
|
+
* @param {string} selector
|
|
117
|
+
* @returns {Element | null}
|
|
118
|
+
*/
|
|
119
|
+
find(selector) {
|
|
120
|
+
const m = findFirst(tokens, selector)
|
|
121
|
+
return m ? makeElement(m.token, tokens, m.index) : null
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Find the first element matching selector. Throws if not found.
|
|
126
|
+
* Use this when the element must exist — it gives a clear failure message.
|
|
127
|
+
* @param {string} selector
|
|
128
|
+
* @returns {Element}
|
|
129
|
+
*/
|
|
130
|
+
get(selector) {
|
|
131
|
+
const el = this.find(selector)
|
|
132
|
+
if (!el) throw new Error(`Element not found: "${selector}"`)
|
|
133
|
+
return el
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Find all elements matching selector.
|
|
138
|
+
* @param {string} selector
|
|
139
|
+
* @returns {Element[]}
|
|
140
|
+
*/
|
|
141
|
+
findAll(selector) {
|
|
142
|
+
return findAll(tokens, selector).map(({ token, index }) => makeElement(token, tokens, index))
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get an attribute value from the first element matching selector.
|
|
147
|
+
* Returns null if the element or attribute is not found.
|
|
148
|
+
* @param {string} selector
|
|
149
|
+
* @param {string} name
|
|
150
|
+
* @returns {string | null}
|
|
151
|
+
*/
|
|
152
|
+
attr(selector, name) {
|
|
153
|
+
return this.find(selector)?.attr(name) ?? null
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Count elements matching selector.
|
|
158
|
+
* @param {string} selector
|
|
159
|
+
* @returns {number}
|
|
160
|
+
*/
|
|
161
|
+
count(selector) {
|
|
162
|
+
return findAll(tokens, selector).length
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// renderSync
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Render a spec's view synchronously.
|
|
173
|
+
*
|
|
174
|
+
* Calls the view function directly — no server fetcher resolution.
|
|
175
|
+
* Pass server state manually via options.server.
|
|
176
|
+
*
|
|
177
|
+
* Ideal for unit testing pure view functions where server data is mocked.
|
|
178
|
+
*
|
|
179
|
+
* @param {import('../../types/schema.js').PulseSpec} spec
|
|
180
|
+
* @param {{ state?: object, server?: object }} [options]
|
|
181
|
+
* @returns {RenderResult}
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* const result = renderSync(counterSpec, { state: { count: 5 } })
|
|
185
|
+
* assert(result.has('span'))
|
|
186
|
+
* assert(result.get('span').text === '5')
|
|
187
|
+
*/
|
|
188
|
+
export function renderSync(spec, options = {}) {
|
|
189
|
+
const { state: stateOverrides = {}, server = {} } = options
|
|
190
|
+
const state = { ...spec.state, ...stateOverrides }
|
|
191
|
+
|
|
192
|
+
const html = typeof spec.view === 'function'
|
|
193
|
+
? spec.view(state, server)
|
|
194
|
+
: Object.values(spec.view).map(fn => fn(state, server)).join('')
|
|
195
|
+
|
|
196
|
+
return makeResult(String(html), state, server)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// render
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Render a spec's view asynchronously, running real server fetchers.
|
|
205
|
+
*
|
|
206
|
+
* If options.server is provided, those values are used directly and fetchers
|
|
207
|
+
* are skipped entirely — this is the fast mock path for unit tests.
|
|
208
|
+
*
|
|
209
|
+
* If options.server is omitted, spec.server fetchers are called with options.ctx
|
|
210
|
+
* (default: empty object). Use this for integration tests.
|
|
211
|
+
*
|
|
212
|
+
* @param {import('../../types/schema.js').PulseSpec} spec
|
|
213
|
+
* @param {{ state?: object, server?: object, ctx?: object }} [options]
|
|
214
|
+
* @returns {Promise<RenderResult>}
|
|
215
|
+
*
|
|
216
|
+
* @example
|
|
217
|
+
* // Mock server data — no real fetcher calls
|
|
218
|
+
* const result = await render(productSpec, {
|
|
219
|
+
* server: { product: { id: 1, name: 'Widget' } }
|
|
220
|
+
* })
|
|
221
|
+
* assert.equal(result.get('h1').text, 'Widget')
|
|
222
|
+
*
|
|
223
|
+
* // Real fetchers — integration test
|
|
224
|
+
* const result = await render(productSpec, { ctx: { params: { id: '1' } } })
|
|
225
|
+
*/
|
|
226
|
+
export async function render(spec, options = {}) {
|
|
227
|
+
const { state: stateOverrides = {}, server: serverOverrides, ctx = {} } = options
|
|
228
|
+
const state = { ...spec.state, ...stateOverrides }
|
|
229
|
+
|
|
230
|
+
let server
|
|
231
|
+
if (serverOverrides !== undefined) {
|
|
232
|
+
// Caller provided mock server state — skip fetchers
|
|
233
|
+
server = serverOverrides
|
|
234
|
+
} else if (spec.server) {
|
|
235
|
+
// Run real fetchers in parallel
|
|
236
|
+
const entries = await Promise.all(
|
|
237
|
+
Object.entries(spec.server).map(async ([key, fn]) => [key, await fn(ctx)])
|
|
238
|
+
)
|
|
239
|
+
server = Object.fromEntries(entries)
|
|
240
|
+
} else {
|
|
241
|
+
server = {}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const html = typeof spec.view === 'function'
|
|
245
|
+
? spec.view(state, server)
|
|
246
|
+
: Object.values(spec.view).map(fn => fn(state, server)).join('')
|
|
247
|
+
|
|
248
|
+
return makeResult(String(html), state, server)
|
|
249
|
+
}
|