@abide/abide 0.31.1 → 0.32.1

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.
Files changed (41) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +26 -0
  3. package/package.json +1 -2
  4. package/src/checkAbide.ts +11 -6
  5. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +4 -3
  6. package/src/lib/server/runtime/createServer.ts +28 -23
  7. package/src/lib/server/runtime/createUiPageRenderer.ts +1 -1
  8. package/src/lib/ui/compile/AbideCompileError.ts +16 -0
  9. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +0 -1
  10. package/src/lib/ui/compile/abideUiPlugin.ts +25 -2
  11. package/src/lib/ui/compile/bindListenEvent.ts +19 -0
  12. package/src/lib/ui/compile/compileShadow.ts +65 -52
  13. package/src/lib/ui/compile/componentWrapperTag.ts +20 -0
  14. package/src/lib/ui/compile/generateBuild.ts +114 -212
  15. package/src/lib/ui/compile/generateSSR.ts +54 -88
  16. package/src/lib/ui/compile/lowerContext.ts +64 -0
  17. package/src/lib/ui/compile/lowerDocAccess.ts +6 -1
  18. package/src/lib/ui/compile/offsetToLineColumn.ts +16 -0
  19. package/src/lib/ui/compile/scopeAttr.ts +9 -0
  20. package/src/lib/ui/compile/staticAttr.ts +11 -0
  21. package/src/lib/ui/compile/staticTextPart.ts +12 -0
  22. package/src/lib/ui/compile/unwrapParens.ts +10 -0
  23. package/src/lib/ui/dom/applyResolved.ts +9 -6
  24. package/src/lib/ui/dom/awaitBlock.ts +27 -21
  25. package/src/lib/ui/dom/clearBetween.ts +16 -0
  26. package/src/lib/ui/dom/each.ts +64 -38
  27. package/src/lib/ui/dom/eachAsync.ts +41 -54
  28. package/src/lib/ui/dom/fillBefore.ts +16 -0
  29. package/src/lib/ui/dom/moveRange.ts +19 -0
  30. package/src/lib/ui/dom/openMarker.ts +22 -0
  31. package/src/lib/ui/dom/removeRange.ts +18 -0
  32. package/src/lib/ui/dom/switchBlock.ts +32 -40
  33. package/src/lib/ui/dom/tryBlock.ts +31 -35
  34. package/src/lib/ui/dom/types/EachRow.ts +10 -3
  35. package/src/lib/ui/dom/types/SwitchCase.ts +3 -2
  36. package/src/lib/ui/dom/when.ts +34 -43
  37. package/src/lib/ui/installHotBridge.ts +0 -2
  38. package/src/lib/ui/renderToStream.ts +14 -11
  39. package/src/lib/ui/state.ts +14 -5
  40. package/src/lib/ui/compile/branchElements.ts +0 -50
  41. package/src/lib/ui/dom/openRoot.ts +0 -20
package/AGENTS.md CHANGED
@@ -174,13 +174,14 @@ Same selector grammar as `cache.invalidate`; also accept a `Subscribable`. See C
174
174
  - `abide/ui/router(...)`, `abide/ui/startClient(...)`, `abide/ui/renderToStream(render)` — bootstrap/render runtime (compiler/launcher uses these).
175
175
 
176
176
  ### DOM + render runtime — `@readme plumbing` (compiler-emitted; you don't hand-write these)
177
- `abide/ui/dom/{mount,mountChild,hydrate,text,openChild,openRoot,appendText,appendSnippet,appendStatic,cloneStatic,attr,on,attach,each,eachAsync,when,awaitBlock,tryBlock,switchBlock,applyResolved}` and `abide/ui/runtime/{nextBlockId,enterRenderPass,exitRenderPass}`. These are what `analyzeComponent → generateBuild/generateSSR` lower a `.abide` file into. Read them only to understand compiler output.
177
+ `abide/ui/dom/{mount,mountChild,hydrate,text,openChild,appendText,appendSnippet,appendStatic,cloneStatic,attr,on,attach,each,eachAsync,when,awaitBlock,tryBlock,switchBlock,applyResolved}` and `abide/ui/runtime/{nextBlockId,enterRenderPass,exitRenderPass}`. These are what `analyzeComponent → generateBuild/generateSSR` lower a `.abide` file into. Read them only to understand compiler output.
178
178
  - `abide/ui/remoteProxy`, `abide/ui/socketProxy` — the browser-side implementations the bundler swaps in for `GET(...)` / `socket(...)`.
179
179
 
180
180
  ### `.abide` component format (see `src/lib/ui/README.md`)
181
181
  Valid HTML with `<script>` + native `<template>` control flow + scoped `<style>`.
182
182
  - **Bindings:** `{expr}` text, `name={expr}` attr, `onclick={fn}`, `bind:value={…}` / `bind:checked` / `bind:group`, `attach={fn}` (node-lifetime attachment — the dual of `on`; the `use:`-action / `{@attach}` equivalent, lowered to `ui/dom/attach`).
183
183
  - **Control flow (native `<template>`):** `if`/`else`, `each={list} as="x" key="x.id"`, `await={p}`/`then`/`catch`, `switch`/`case`/`default`.
184
+ - **A branch is a lexical scope:** any control-flow branch may host its own nested `<script>` and `<style>`. The nested `<script>` declares branch-local **plain** signals (`state`/`derived`/`linked`/`prop`) — owned by the branch's render scope, re-seeded each mount (not the serializable top-level `doc`) — with the branch's binding in scope (`then`/`catch`'s `as` value, the `each` `as`/`key` row), so it can derive from the awaited/iterated value; its bindings cover the branch subtree and later siblings auto-deref them. The nested `<style>` is scoped to that branch alone (its own `data-a-<hash>`), not the whole component.
184
185
  - **Components:** capitalised tags (`<Layout title=…>`); children fill `<slot/>`; props are reactive (passed as thunks). A component has no directives — every attribute is a prop under its written name (so `onclick=`/`bind:open=`/`attach=` pass through as props, e.g. callbacks, not the DOM-element directives those are on a lowercase tag) and is type-checked against the child's declared props. `prop('name')` reads a typed component prop (the parent-supplied thunk, reactive + read-only); route params come from the `page` proxy (`page.params.name`), not `prop()`.
185
186
  - **Snippets / named slots:** `<template name="x" args={…}>` declares a reusable named builder (the `snippet()` form), rendered like a function — covers named slots / `{@render}`.
186
187
  - **Reactivity:** write plain assignment (`count += 1`, `items.push(x)`); the compiler lowers it to patches. Deep-field edits wake only that field.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # abide
2
2
 
3
+ ## 0.32.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`660ce08`](https://github.com/briancray/abide/commit/660ce08109160156dc9697fcb223fbb335229243) - fold asset-serve request logging into a timedServe helper ([`17aa14a`](https://github.com/briancray/abide/commit/17aa14aa3f04abe76a2e06b0c2fb0a8d4e725603))
8
+
9
+ - [`660ce08`](https://github.com/briancray/abide/commit/660ce08109160156dc9697fcb223fbb335229243) - swallow the superseded each-async iterator's return rejection ([`5e2898f`](https://github.com/briancray/abide/commit/5e2898ff8a785508aa8fde70f3fc351dcdc8e014))
10
+
11
+ - [`9643066`](https://github.com/briancray/abide/commit/9643066cf19bb944a55509333fd1421a3a8cce7a) - fix(check): narrow `<template else>` against the condition's negation. The shadow type-checker emitted a nested `else` child inside the `if` block, so the else body inherited the if's positive narrowing — a literal-union compare read as "no overlap" and a `typeof`-narrowed branch saw the wrong member. It now pairs the `else` child as a real `if (…) {…} else {…}`, matching the runtime's pairing.
12
+
13
+ - [`9643066`](https://github.com/briancray/abide/commit/9643066cf19bb944a55509333fd1421a3a8cce7a) - fix(check): value-project a nested control-flow `<script>` like the leading one. The shadow type-checker emitted a branch-scoped `<script>` body raw, so its `state`/`derived` declarations kept their `State<T>`/`Derived<T>` types — every read of a nested signal in the branch's markup false-positived (`'Derived<string>' and 'string' have no overlap`, `Property 'length' does not exist on type 'Derived<…>'`). It now rewrites a nested script's reactive declarations to their value types, matching the runtime, which derefs nested-script signals through the rest of the branch.
14
+
15
+ - [`0c54e5a`](https://github.com/briancray/abide/commit/0c54e5a025eef96ebc7eece3f51b8a167241434d) - fix(ui): don't emit a void element as a component's mount wrapper. A component instance mounts into a wrapper tag derived from its name (`<Search>`→`<search>`); when the name lowercases to a void element (`<Input>`→`<input>`, `<Img>`→`<img>`) the wrapper self-closes and the HTML parser reparents the component's own markup as the wrapper's siblings, so on hydration `openChild` finds the wrapper empty, claims `null`, and `attr` throws `null is not an object (setAttribute)` — aborting hydration. Such names now map to a hyphenated custom-element tag (`abide-input`, never void) made layout-transparent with `display:contents`, so the child's real root still lays out as a direct child of the parent. Both the SSR string and the client build go through the shared `componentWrapperTag`, keeping them in agreement.
16
+
17
+ - [`9695bea`](https://github.com/briancray/abide/commit/9695bea26309b693d6b61a4dec1641559421c714) - perf(ui): carry the streamed await-block resume value in a `<script type="application/json">` child instead of a `data-resume` attribute. The attribute form HTML-escaped the JSON (`"` → `&quot;`, plus `&`/`<` passes), inflating the raw payload ~38% and costing two full-string regex passes per render (~2.6 ms/MB; ~10 ms for a 3.7 MB payload). Script content is raw text, so only `<` is neutralized as the JSON escape — ~130–150× cheaper to encode and no quote inflation. `applyResolved` and the inline `SSR_SWAP_SCRIPT` now read the value via `.textContent` and drop the script before swapping the resolved markup into its boundary.
18
+
19
+ ## 0.32.0
20
+
21
+ ### Minor Changes
22
+
23
+ - [`786a496`](https://github.com/briancray/abide/commit/786a4965fa3c94954ea8d66aa41b0f94e0ec8320) - render control-flow branches into marker-bounded ranges ([`23efa06`](https://github.com/briancray/abide/commit/23efa0606b90b69ff262679673d660a71baf443c))
24
+
25
+ ### Patch Changes
26
+
27
+ - [`786a496`](https://github.com/briancray/abide/commit/786a4965fa3c94954ea8d66aa41b0f94e0ec8320) - report checked component count on success ([`9ea83bb`](https://github.com/briancray/abide/commit/9ea83bb3c072d72e67486906b191964505e872f2))
28
+
3
29
  ## 0.31.1
4
30
 
5
31
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.31.1",
3
+ "version": "0.32.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
@@ -82,7 +82,6 @@
82
82
  "./ui/dom/hydrate": "./src/lib/ui/dom/hydrate.ts",
83
83
  "./ui/dom/text": "./src/lib/ui/dom/text.ts",
84
84
  "./ui/dom/openChild": "./src/lib/ui/dom/openChild.ts",
85
- "./ui/dom/openRoot": "./src/lib/ui/dom/openRoot.ts",
86
85
  "./ui/dom/appendText": "./src/lib/ui/dom/appendText.ts",
87
86
  "./ui/dom/appendSnippet": "./src/lib/ui/dom/appendSnippet.ts",
88
87
  "./ui/dom/appendStatic": "./src/lib/ui/dom/appendStatic.ts",
package/src/checkAbide.ts CHANGED
@@ -14,7 +14,7 @@ the same as each package checked on its own — and the same as the LSP. Returns
14
14
  the error count so the CLI can set its exit code.
15
15
  */
16
16
  export async function checkAbide({ cwd }: { cwd: string }): Promise<number> {
17
- const diagnostics = collectByProject(cwd)
17
+ const { diagnostics, checked } = collectByProject(cwd)
18
18
  const byFile = new Map<string, AbideDiagnostic[]>()
19
19
  for (const diagnostic of diagnostics) {
20
20
  const bucket = byFile.get(diagnostic.file) ?? []
@@ -32,11 +32,14 @@ export async function checkAbide({ cwd }: { cwd: string }): Promise<number> {
32
32
  const errors = diagnostics.filter(
33
33
  (diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error,
34
34
  ).length
35
- const relative = byFile.size
35
+ /* Success reports components *checked* (the glob count); failure reports the
36
+ files *with* errors. Reporting `byFile.size` on success printed `0` — it only
37
+ holds files that had diagnostics. */
38
+ const fileCount = byFile.size
36
39
  console.log(
37
40
  errors === 0
38
- ? `\n[abide check] no type errors in ${relative} component${relative === 1 ? '' : 's'}`
39
- : `\n[abide check] ${errors} error${errors === 1 ? '' : 's'} in ${relative} file${relative === 1 ? '' : 's'}`,
41
+ ? `\n[abide check] no type errors in ${checked} component${checked === 1 ? '' : 's'}`
42
+ : `\n[abide check] ${errors} error${errors === 1 ? '' : 's'} in ${fileCount} file${fileCount === 1 ? '' : 's'}`,
40
43
  )
41
44
  return errors
42
45
  }
@@ -45,7 +48,7 @@ export async function checkAbide({ cwd }: { cwd: string }): Promise<number> {
45
48
  project's components against that project's options, then concatenates the
46
49
  diagnostics. Imported components from another project resolve on demand through
47
50
  the host, so the per-project root set stays each project's own files. */
48
- function collectByProject(cwd: string): AbideDiagnostic[] {
51
+ function collectByProject(cwd: string): { diagnostics: AbideDiagnostic[]; checked: number } {
49
52
  const byProject = new Map<string, string[]>()
50
53
  for (const relative of new Bun.Glob('**/*.abide').scanSync({ cwd, onlyFiles: true })) {
51
54
  if (relative.includes('node_modules')) {
@@ -55,9 +58,11 @@ function collectByProject(cwd: string): AbideDiagnostic[] {
55
58
  const root = nearestProjectRoot(path, cwd)
56
59
  byProject.set(root, [...(byProject.get(root) ?? []), path])
57
60
  }
58
- return [...byProject].flatMap(([root, paths]) =>
61
+ const diagnostics = [...byProject].flatMap(([root, paths]) =>
59
62
  collectAbideDiagnostics(createShadowProgram(root, paths)),
60
63
  )
64
+ const checked = [...byProject.values()].reduce((total, paths) => total + paths.length, 0)
65
+ return { diagnostics, checked }
61
66
  }
62
67
 
63
68
  /* Renders one diagnostic as `path:line:col severity message` plus the offending
@@ -1,6 +1,7 @@
1
1
  /*
2
2
  The tiny inline script the abide-ui SSR stream ships in <head>. For each streamed
3
- `<abide-resolve data-id data-resume>` frame it registers the resolved value into
3
+ `<abide-resolve data-id>` frame it reads the leading `<script type=application/json>`,
4
+ registers the resolved value into
4
5
  `window.__abideResume` (the resume manifest hydration reads) and swaps the resolved
5
6
  markup into the matching `<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary —
6
7
  so the pending shell paints instantly and each value lands as it arrives, before
@@ -9,8 +10,8 @@ minified to one line so it inlines cheaply ahead of the document body.
9
10
  */
10
11
  export const SSR_SWAP_SCRIPT =
11
12
  "function __abideSwap(){var f=document.querySelector('abide-resolve');while(f){" +
12
- "var id=f.getAttribute('data-id'),w=document.createTreeWalker(document.body,NodeFilter.SHOW_COMMENT),o=null,c;" +
13
- "try{(window.__abideResume=window.__abideResume||{})[id]=JSON.parse(f.getAttribute('data-resume')||'null');}catch(e){}" +
13
+ "var id=f.getAttribute('data-id'),p=f.firstChild,w=document.createTreeWalker(document.body,NodeFilter.SHOW_COMMENT),o=null,c;" +
14
+ "if(p&&p.nodeName==='SCRIPT'){try{(window.__abideResume=window.__abideResume||{})[id]=JSON.parse(p.textContent||'null');}catch(e){}p.remove();}" +
14
15
  "while((c=w.nextNode())){if(c.data==='abide:await:'+id){o=c;break;}}" +
15
16
  "if(o){var n=o.nextSibling;while(n&&!(n.nodeType===8&&n.data==='/abide:await:'+id)){var x=n.nextSibling;n.remove();n=x;}" +
16
17
  "while(f.firstChild){o.parentNode.insertBefore(f.firstChild,n);}}f.remove();f=document.querySelector('abide-resolve');}}"
@@ -238,6 +238,32 @@ export async function createServer({
238
238
  /* Request closing records are on by default — DEBUG=-abide is the off switch (negation, like the abide channel itself). */
239
239
  const logRequests = !isDebugNegated('abide')
240
240
 
241
+ /*
242
+ Time an asset serve and emit its closing record when logging is on. A miss
243
+ (undefined, from the public server) passes through unlogged so the request
244
+ can fall through to the 404 path.
245
+ */
246
+ const timedServe = async <T extends Response | undefined>(
247
+ serve: () => Promise<T>,
248
+ req: Request,
249
+ url: URL,
250
+ ): Promise<T> => {
251
+ if (!logRequests) {
252
+ return serve()
253
+ }
254
+ const start = Bun.nanoseconds()
255
+ const response = await serve()
256
+ if (response) {
257
+ logClosingRecord(
258
+ req.method,
259
+ `${url.pathname}${url.search}`,
260
+ response.status,
261
+ (Bun.nanoseconds() - start) / 1e6,
262
+ )
263
+ }
264
+ return response
265
+ }
266
+
241
267
  // App-configured headers extend the in-process forward allowlist for the process lifetime.
242
268
  extraForwardHeaders.set(app?.forwardHeaders ?? [])
243
269
 
@@ -546,19 +572,7 @@ export async function createServer({
546
572
  catches anything that goes wrong inside serveAppAsset.
547
573
  */
548
574
  if (url.pathname.startsWith('/_app/')) {
549
- if (!logRequests) {
550
- return serveAppAsset(req, url)
551
- }
552
- const start = Bun.nanoseconds()
553
- const response = await serveAppAsset(req, url)
554
- const ms = (Bun.nanoseconds() - start) / 1e6
555
- logClosingRecord(
556
- req.method,
557
- `${url.pathname}${url.search}`,
558
- response.status,
559
- ms,
560
- )
561
- return response
575
+ return timedServe(() => serveAppAsset(req, url), req, url)
562
576
  }
563
577
  /*
564
578
  Files under public/ are served at the site root, sidestepping
@@ -566,17 +580,8 @@ export async function createServer({
566
580
  undefined so the request falls through to the 404 / middleware
567
581
  path below.
568
582
  */
569
- const publicStart = Bun.nanoseconds()
570
- const publicResponse = await servePublicAsset(req, url)
583
+ const publicResponse = await timedServe(() => servePublicAsset(req, url), req, url)
571
584
  if (publicResponse) {
572
- if (logRequests) {
573
- logClosingRecord(
574
- req.method,
575
- `${url.pathname}${url.search}`,
576
- publicResponse.status,
577
- (Bun.nanoseconds() - publicStart) / 1e6,
578
- )
579
- }
580
585
  return publicResponse
581
586
  }
582
587
  /*
@@ -27,7 +27,7 @@ Response out.
27
27
 
28
28
  A page with no `await` block renders synchronously and ships buffered. A page with
29
29
  await blocks STREAMS: the pending shell flushes first, then each resolved fragment
30
- (`<abide-resolve data-resume>`) as its promise settles, swapped into its boundary
30
+ (`<abide-resolve>` carrying a JSON `<script>`) as its promise settles, swapped into its boundary
31
31
  by the inline SSR_SWAP_SCRIPT — which also registers the value into the resume
32
32
  manifest so client hydration adopts it without re-fetching (see abide/ui/awaitBlock).
33
33
 
@@ -0,0 +1,16 @@
1
+ /*
2
+ A compile-time `.abide` error carrying the absolute source offset of the
3
+ offending node (when the parser tracked one). The loader catches it, resolves the
4
+ offset to `line:col` against the file, and re-throws with the component path and
5
+ position in the message — so a failed build names the exact file and line, not the
6
+ entry page at offset 0 (Bun frames plugin throws at `<file>:0` regardless).
7
+ */
8
+ export class AbideCompileError extends Error {
9
+ readonly offset: number | undefined
10
+
11
+ constructor(message: string, offset?: number) {
12
+ super(message)
13
+ this.name = 'AbideCompileError'
14
+ this.offset = offset
15
+ }
16
+ }
@@ -15,7 +15,6 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string }[] = [
15
15
  { name: 'effect', specifier: 'ui/effect' },
16
16
  { name: 'mount', specifier: 'ui/dom/mount' },
17
17
  { name: 'openChild', specifier: 'ui/dom/openChild' },
18
- { name: 'openRoot', specifier: 'ui/dom/openRoot' },
19
18
  { name: 'appendText', specifier: 'ui/dom/appendText' },
20
19
  { name: 'appendSnippet', specifier: 'ui/dom/appendSnippet' },
21
20
  { name: 'appendStatic', specifier: 'ui/dom/appendStatic' },
@@ -1,8 +1,10 @@
1
1
  import { relative } from 'node:path'
2
2
  import type { BunPlugin } from 'bun'
3
+ import { AbideCompileError } from './AbideCompileError.ts'
3
4
  import { analyzeComponent } from './analyzeComponent.ts'
4
5
  import { compileModule } from './compileModule.ts'
5
6
  import { nearestProjectRoot } from './nearestProjectRoot.ts'
7
+ import { offsetToLineColumn } from './offsetToLineColumn.ts'
6
8
 
7
9
  /*
8
10
  Bun plugin that loads `.abide` single-file components: compiles each to the ES
@@ -40,11 +42,32 @@ export const abideUiPlugin: BunPlugin = {
40
42
  const source = await Bun.file(args.path).text()
41
43
  const moduleId = relative(nearestProjectRoot(args.path, process.cwd()), args.path)
42
44
  const isLayout = (args.path.split('/').pop() ?? '') === 'layout.abide'
43
- const code = compileModule(source, { isLayout, moduleId })
45
+ /* Bun frames a plugin throw at `<file>:0` regardless of the real spot, so
46
+ carry the component path + resolved line:col in the message — otherwise a
47
+ control-flow / compile error reads as `:0` and (in deep imports) can look
48
+ like it came from the entry page rather than this component. */
49
+ const compileAbide = <T>(step: () => T): T => {
50
+ try {
51
+ return step()
52
+ } catch (error) {
53
+ const offset = error instanceof AbideCompileError ? error.offset : undefined
54
+ const at =
55
+ offset === undefined
56
+ ? moduleId
57
+ : (({ line, column }) => `${moduleId}:${line}:${column}`)(
58
+ offsetToLineColumn(source, offset),
59
+ )
60
+ const message = error instanceof Error ? error.message : String(error)
61
+ throw new Error(`${message.replace(/^\[abide\]\s*/, `[abide] ${at} — `)}`)
62
+ }
63
+ }
64
+ const code = compileAbide(() => compileModule(source, { isLayout, moduleId }))
44
65
  /* Browser build with `<style>`(s): concatenate every scoped block's CSS and
45
66
  pull it into the bundle via one virtual import, keyed by `moduleId` so the
46
67
  registry id and the CSS id agree. */
47
- const styles = toBrowser ? analyzeComponent(source, moduleId).styles : []
68
+ const styles = compileAbide(() =>
69
+ toBrowser ? analyzeComponent(source, moduleId).styles : [],
70
+ )
48
71
  if (styles.length === 0) {
49
72
  return { contents: code, loader: 'ts' }
50
73
  }
@@ -0,0 +1,19 @@
1
+ /*
2
+ The DOM event a generic two-way `bind:<property>` listens on to write the bound
3
+ path back. Most form fields report edits via `input`, but a few properties are
4
+ driven by their own event and never fire `input` — `<details open>` fires
5
+ `toggle`, a checkbox/radio `checked` fires `change`, and a `<select>` settles its
6
+ `value` on `change`. Picking the wrong event silently breaks the write-back.
7
+ */
8
+ export function bindListenEvent(property: string, tag: string): string {
9
+ if (property === 'open') {
10
+ return 'toggle'
11
+ }
12
+ if (property === 'checked') {
13
+ return 'change'
14
+ }
15
+ if (property === 'value' && tag === 'select') {
16
+ return 'change'
17
+ }
18
+ return 'input'
19
+ }
@@ -184,6 +184,27 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
184
184
  return { imports, types, scope, props }
185
185
  }
186
186
 
187
+ /* Value-projects a nested control-flow `<script>` body the way `analyzeScript`
188
+ projects the leading script's scope: reactive declarations become their value
189
+ type, every other statement stays verbatim. Returns the projected source text
190
+ (unmapped — nested scripts carry no source offset yet), so a branch's markup
191
+ reads a nested signal as its value type instead of the raw `State`/`Derived`. */
192
+ function projectNestedScript(code: string): string {
193
+ const file = ts.createSourceFile('nested.ts', code, ts.ScriptTarget.Latest, true)
194
+ const verbatim = (node: ts.Node): string => code.slice(node.getStart(file), node.getEnd())
195
+ /* No mapping: a zero-length segment the caller drops. */
196
+ const span = (): ShadowMapping => ({ shadowStart: 0, sourceStart: 0, length: 0 })
197
+ return file.statements
198
+ .flatMap((statement) => {
199
+ const reactive = reactiveDeclarations(statement)
200
+ if (reactive === undefined) {
201
+ return [verbatim(statement)]
202
+ }
203
+ return reactive.map((declaration) => scopeLineFor(declaration, [], verbatim, span).text)
204
+ })
205
+ .join('\n')
206
+ }
207
+
187
208
  /* The `state`/`derived`/`prop` declarations in a variable statement, or undefined
188
209
  if it isn't one declaring them (so the caller emits it verbatim). A statement
189
210
  mixing reactive and plain declarations is rare; treated as all-verbatim. */
@@ -229,7 +250,14 @@ function scopeLineFor(
229
250
  const annotation = typeNode === undefined ? '' : `: ${verbatim(typeNode)}`
230
251
  const init = call.arguments[0]
231
252
  if (init === undefined) {
232
- return { text: `let ${name}${annotation};`, segments: [] }
253
+ /* No initial (`state<T>()`): the value is `T | undefined`. A definite-
254
+ assignment assertion (`!`) gives that union without a use-before-assign
255
+ false-positive AND without control-flow narrowing it to just `undefined`
256
+ (an `= undefined` initializer, never reassigned in the shadow, would make
257
+ a guard like `x !== undefined` collapse to `never`). Unguarded access is
258
+ then correctly flagged possibly-undefined; a guard narrows cleanly. */
259
+ const valueType = annotation === '' ? ': unknown' : `${annotation} | undefined`
260
+ return { text: `let ${name}!${valueType};`, segments: [] }
233
261
  }
234
262
  const prefix = `let ${name}${annotation} = (`
235
263
  return { text: `${prefix}${verbatim(init)});`, segments: [span(init, prefix.length)] }
@@ -257,51 +285,13 @@ function scopeLineFor(
257
285
  return { text: `let ${name} = props[${JSON.stringify(keyText)}];`, segments: [] }
258
286
  }
259
287
 
260
- /* Emits a sibling list. Walks with lookahead so an `if` and its trailing `else`
261
- (the next meaningful sibling — a `case` with no match) fuse into one
262
- `if (…) {…} else {…}`, giving the else branch the condition's negative narrowing
263
- instead of being checked bare against the un-narrowed type. Every other node is
264
- emitted standalone via `emitNode`. */
288
+ /* Emits a sibling list each node standalone via `emitNode`. */
265
289
  function emitNodes(nodes: TemplateNode[], builder: Builder): void {
266
- for (let index = 0; index < nodes.length; index += 1) {
267
- const node = nodes[index]
268
- if (node === undefined) {
269
- continue
270
- }
271
- if (node.kind !== 'if') {
290
+ for (const node of nodes) {
291
+ if (node !== undefined) {
272
292
  emitNode(node, builder)
273
- continue
274
- }
275
- builder.raw('if ')
276
- builder.expr(node.condition, node.loc)
277
- builder.raw(' {\n')
278
- emitNodes(node.children, builder)
279
- builder.raw('}')
280
- const elseIndex = nextMeaningful(nodes, index + 1)
281
- const elseNode = elseIndex === -1 ? undefined : nodes[elseIndex]
282
- if (elseNode?.kind === 'case' && elseNode.match === undefined) {
283
- builder.raw(' else {\n')
284
- emitNodes(elseNode.children, builder)
285
- builder.raw('}')
286
- index = elseIndex
287
- }
288
- builder.raw('\n')
289
- }
290
- }
291
-
292
- /* Index of the next node that isn't whitespace-only text (which separates the `if`
293
- and `else` tags in source but carries no checkable content); -1 if none remain. */
294
- function nextMeaningful(nodes: TemplateNode[], from: number): number {
295
- for (let index = from; index < nodes.length; index += 1) {
296
- const node = nodes[index]
297
- const blank =
298
- node?.kind === 'text' &&
299
- node.parts.every((part) => part.kind === 'static' && part.value.trim() === '')
300
- if (!blank) {
301
- return index
302
293
  }
303
294
  }
304
- return -1
305
295
  }
306
296
 
307
297
  /* Emits a template node's expressions into the shadow's render body. Control flow
@@ -346,15 +336,31 @@ function emitNode(node: TemplateNode, builder: Builder): void {
346
336
  emitNodes(node.children, builder)
347
337
  return
348
338
  }
349
- case 'if':
350
- /* Reached only for an `if` emitted outside a sibling list (none today);
351
- `emitNodes` owns the `if`/`else` fusion. Emit without an else. */
339
+ case 'if': {
340
+ /* The optional `<template else>` is a match-less `case` CHILD (the runtime
341
+ pairs it the same way — see `generateIf`); the rest are the then-content.
342
+ Emitting it as a real `else` gives its body the condition's NEGATIVE
343
+ narrowing — emitting it inside the `if` block (as a plain child) instead
344
+ gave it the positive narrowing, so a literal-union compare read as a
345
+ "no overlap" and a typeof-narrowed branch saw the wrong member. */
346
+ const elseChild = node.children.find(
347
+ (child): child is Extract<TemplateNode, { kind: 'case' }> =>
348
+ child.kind === 'case' && child.match === undefined,
349
+ )
350
+ const thenChildren = node.children.filter((child) => child !== elseChild)
352
351
  builder.raw('if ')
353
352
  builder.expr(node.condition, node.loc)
354
353
  builder.raw(' {\n')
355
- emitNodes(node.children, builder)
356
- builder.raw('}\n')
354
+ emitNodes(thenChildren, builder)
355
+ builder.raw('}')
356
+ if (elseChild !== undefined) {
357
+ builder.raw(' else {\n')
358
+ emitNodes(elseChild.children, builder)
359
+ builder.raw('}')
360
+ }
361
+ builder.raw('\n')
357
362
  return
363
+ }
358
364
  case 'each':
359
365
  /* `for await` over an async each's AsyncIterable, plain `for…of` otherwise —
360
366
  so the item binds to the element type under either iteration protocol. */
@@ -429,8 +435,8 @@ function emitNode(node: TemplateNode, builder: Builder): void {
429
435
  builder.raw('}\n')
430
436
  return
431
437
  case 'case':
432
- /* Reached only for a stray case outside a switch/if-else (none today); a
433
- `switch` emits its own cases and `emitNodes` consumes an `else`. */
438
+ /* Reached only for a stray case outside a switch/if (none today); a `switch`
439
+ emits its own cases and the `if` handler consumes its `else` child. */
434
440
  if (node.match !== undefined) {
435
441
  builder.stmt(node.match, node.loc)
436
442
  }
@@ -457,9 +463,16 @@ function emitNode(node: TemplateNode, builder: Builder): void {
457
463
  builder.raw('};\n')
458
464
  return
459
465
  case 'script':
460
- /* A scoped reactive `<script>` block its body is author TS; emit it so
461
- its references check. Not yet position-mapped (rare). */
462
- builder.raw(`{\n${node.code}\n}\n`)
466
+ /* A scoped reactive `<script>`: value-project its reactive declarations to
467
+ their value types (`derived(…)` the computed value, `state()` → the
468
+ initial) exactly as the leading script's scope lines are, so the branch's
469
+ markup type-checks a nested signal as its value — matching the runtime,
470
+ which derefs nested-script signals through the rest of the branch. Emitted
471
+ INLINE in the current block, not a nested `{…}`, so its bindings reach the
472
+ branch's later siblings (a nested if/each); a wrapping block trapped them,
473
+ surfacing "Cannot find name". Leading `;` guards a preceding semicolon-less
474
+ call from merging in (see the component case). Not yet position-mapped. */
475
+ builder.raw(`;\n${projectNestedScript(node.code)}\n`)
463
476
  return
464
477
  case 'style':
465
478
  /* CSS, not TypeScript — nothing for the shadow to type-check. */
@@ -0,0 +1,20 @@
1
+ import { VOID_TAGS } from './VOID_TAGS.ts'
2
+
3
+ /*
4
+ The element tag a component instance mounts into. Normally the component name
5
+ lowercased — readable in devtools, a real box like any abide wrapper. But a name
6
+ that lowercases to a VOID element (`Input`→`input`, `Img`→`img`) would yield a
7
+ wrapper the HTML parser self-closes, reparenting the component's own markup as the
8
+ wrapper's siblings — so on hydration `openChild` finds the wrapper empty, claims
9
+ `null`, and `attr` throws on it. Those names map to a hyphenated custom-element tag
10
+ (a custom element is never void) made layout-transparent with `display:contents`,
11
+ so the component's real root still lays out as a direct child of the parent the way
12
+ the (parse-broken) void wrapper effectively did. Both back-ends call this so the SSR
13
+ string and the client build agree on the wrapper.
14
+ */
15
+ export function componentWrapperTag(name: string): { tag: string; transparent: boolean } {
16
+ const lower = name.toLowerCase()
17
+ return VOID_TAGS.has(lower)
18
+ ? { tag: `abide-${lower}`, transparent: true }
19
+ : { tag: lower, transparent: false }
20
+ }