@abide/abide 0.48.0 → 0.49.0

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 (67) hide show
  1. package/AGENTS.md +15 -7
  2. package/CHANGELOG.md +39 -0
  3. package/README.md +9 -3
  4. package/package.json +3 -1
  5. package/src/buildCli.ts +3 -5
  6. package/src/buildDisconnected.ts +2 -3
  7. package/src/bundleApp.ts +2 -3
  8. package/src/compile.ts +2 -4
  9. package/src/lib/bundle/installDownloads.ts +13 -3
  10. package/src/lib/bundle/installMacMenu.ts +13 -3
  11. package/src/lib/cli/parseArgvForRpc.ts +36 -9
  12. package/src/lib/cli/tokenizeArgvFlags.ts +4 -2
  13. package/src/lib/mcp/createMcpResourceServer.ts +13 -2
  14. package/src/lib/mcp/toolResultFromResponse.ts +40 -12
  15. package/src/lib/server/rpc/parseArgs.ts +15 -1
  16. package/src/lib/server/rpc/runWithRpcTimeout.ts +12 -1
  17. package/src/lib/server/runtime/finalizeResponse.ts +11 -1
  18. package/src/lib/server/runtime/snapshotEntryFromCache.ts +10 -2
  19. package/src/lib/shared/buildArtifact.ts +17 -0
  20. package/src/lib/shared/buildRpcRequest.ts +6 -2
  21. package/src/lib/shared/canonicalJson.ts +24 -1
  22. package/src/lib/shared/createChannelLog.ts +24 -0
  23. package/src/lib/shared/exitOnBuildFailure.ts +4 -3
  24. package/src/lib/shared/parseEnv.ts +17 -6
  25. package/src/lib/shared/serializeEnv.ts +18 -6
  26. package/src/lib/shared/snippet.ts +11 -6
  27. package/src/lib/shared/url.ts +5 -0
  28. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  29. package/src/lib/ui/compile/bindListenEvent.ts +5 -6
  30. package/src/lib/ui/compile/compileSSR.ts +1 -1
  31. package/src/lib/ui/compile/compileShadow.ts +121 -25
  32. package/src/lib/ui/compile/composeProps.ts +3 -2
  33. package/src/lib/ui/compile/desugarSignals.ts +1 -1
  34. package/src/lib/ui/compile/generateBuild.ts +23 -9
  35. package/src/lib/ui/compile/generateSSR.ts +49 -37
  36. package/src/lib/ui/compile/isWhitespaceText.ts +10 -2
  37. package/src/lib/ui/compile/lowerDocAccess.ts +39 -1
  38. package/src/lib/ui/compile/parseTemplate.ts +3 -1
  39. package/src/lib/ui/compile/renameSignalRefs.ts +5 -18
  40. package/src/lib/ui/compile/resolveReactiveExport.ts +4 -7
  41. package/src/lib/ui/compile/scopeCss.ts +27 -4
  42. package/src/lib/ui/dom/awaitBlock.ts +67 -29
  43. package/src/lib/ui/dom/discardBoundary.ts +24 -6
  44. package/src/lib/ui/dom/each.ts +15 -8
  45. package/src/lib/ui/dom/fillBefore.ts +6 -7
  46. package/src/lib/ui/dom/mergeProps.ts +1 -1
  47. package/src/lib/ui/dom/mountSlot.ts +1 -1
  48. package/src/lib/ui/dom/mutateDocArray.ts +38 -0
  49. package/src/lib/ui/dom/restProps.ts +2 -2
  50. package/src/lib/ui/dom/spreadProps.ts +3 -4
  51. package/src/lib/ui/dom/tryBlock.ts +10 -11
  52. package/src/lib/ui/dom/withScope.ts +7 -0
  53. package/src/lib/ui/history.ts +14 -7
  54. package/src/lib/ui/installHotBridge.ts +2 -0
  55. package/src/lib/ui/props.ts +17 -0
  56. package/src/lib/ui/renderChain.ts +7 -3
  57. package/src/lib/ui/router.ts +23 -17
  58. package/src/lib/ui/runtime/CHILD_PRESENT.ts +2 -2
  59. package/src/lib/ui/runtime/applyPatchToTree.ts +16 -3
  60. package/src/lib/ui/runtime/captureModelDoc.ts +22 -7
  61. package/src/lib/ui/runtime/createDoc.ts +28 -12
  62. package/src/lib/ui/runtime/flushEffects.ts +23 -1
  63. package/src/lib/ui/runtime/scope.ts +11 -0
  64. package/src/lib/ui/runtime/toTeardown.ts +10 -3
  65. package/src/lib/ui/runtime/types/UiProps.ts +6 -5
  66. package/src/lib/ui/runtime/withoutHydration.ts +22 -0
  67. package/src/lib/ui/state.ts +9 -3
@@ -0,0 +1,17 @@
1
+ import type { BuildConfig, BuildOutput } from 'bun'
2
+ import { exitOnBuildFailure } from './exitOnBuildFailure.ts'
3
+
4
+ /*
5
+ Runs one Bun.build and fails the process on any diagnostic, returning the
6
+ successful output — the build-or-die pairing every one-shot build site shares
7
+ (compile, buildCli's discovery + cli, bundleApp, buildDisconnected). Keeps the
8
+ `Bun.build` + `exitOnBuildFailure` step atomic so a new build site can't ship
9
+ without the failure check. The incremental client build (build.ts) does NOT use
10
+ this: it must clean its staging dir and return `false` rather than exit, so it
11
+ keeps its own epilogue.
12
+ */
13
+ export async function buildArtifact(config: BuildConfig): Promise<BuildOutput> {
14
+ const result = await Bun.build(config)
15
+ exitOnBuildFailure(result)
16
+ return result
17
+ }
@@ -68,8 +68,12 @@ function appendQuery(method: HttpMethod, url: string, args: unknown): string {
68
68
  if (args === undefined) {
69
69
  return url
70
70
  }
71
- if (typeof args !== 'object' || args === null || Array.isArray(args)) {
72
- const got = Array.isArray(args) ? 'array' : typeof args
71
+ const isFormData = typeof FormData !== 'undefined' && args instanceof FormData
72
+ if (typeof args !== 'object' || args === null || Array.isArray(args) || isFormData) {
73
+ /* FormData has no own enumerable keys, so `queryStringFromArgs` would silently drop
74
+ every field; a query-carrying method (GET/DELETE/HEAD) can't take a body anyway.
75
+ Fail loudly like any other non-plain-object arg. */
76
+ const got = Array.isArray(args) ? 'array' : isFormData ? 'FormData' : typeof args
73
77
  throw new Error(`[abide] ${method} ${url} args must be a plain object — got ${got}`)
74
78
  }
75
79
  const suffix = queryStringFromArgs(args as Record<string, unknown>, false)
@@ -8,7 +8,8 @@ POST/PUT/PATCH bodies; the output is a key, not a request body, so it is free to
8
8
  encode types JSON.stringify would silently flatten (Map/Set → {}), coerce
9
9
  (Date → its ISO string, via toJSON before any replacer sees it) or drop
10
10
  (undefined). Covers the value types commonly passed as rpc args: primitives,
11
- arrays, plain objects, Date, Map, Set, and bigint. Functions and symbols can't
11
+ arrays, plain objects, Date, Map, Set, bigint, and FormData/File/Blob (the
12
+ multipart upload path). Functions and symbols can't
12
13
  key anything meaningful but are tagged rather than dropped so a stray one can't
13
14
  silently collapse two distinct argument sets onto the same key.
14
15
  */
@@ -55,6 +56,28 @@ export function canonicalJson(value: unknown): string {
55
56
  const members = Array.from(value, canonicalJson).sort()
56
57
  return `Set{${members.join(',')}}`
57
58
  }
59
+ /* FormData is the documented multipart escape hatch for body rpcs. Its fields are
60
+ NOT own enumerable keys, so the generic object branch below would return `{}` for
61
+ every distinct upload — collapsing them onto one cache key and coalescing unrelated
62
+ uploads onto each other. Encode its entries (sorted, so field order doesn't change
63
+ the key) with File/Blob distinguished by identity attributes below. */
64
+ if (typeof FormData !== 'undefined' && value instanceof FormData) {
65
+ const entries: string[] = []
66
+ value.forEach((entryValue, entryKey) => {
67
+ entries.push(`${JSON.stringify(entryKey)}=>${canonicalJson(entryValue)}`)
68
+ })
69
+ entries.sort()
70
+ return `FormData{${entries.join(',')}}`
71
+ }
72
+ /* File extends Blob — check it first so a named upload keys on its name too. Contents
73
+ can't be read synchronously, so identity attributes are the best available key;
74
+ distinct files (name/size/type/mtime) get distinct keys, which is the fix. */
75
+ if (typeof File !== 'undefined' && value instanceof File) {
76
+ return `File(${JSON.stringify(value.name)},${value.size},${JSON.stringify(value.type)},${value.lastModified})`
77
+ }
78
+ if (typeof Blob !== 'undefined' && value instanceof Blob) {
79
+ return `Blob(${value.size},${JSON.stringify(value.type)})`
80
+ }
58
81
  const record = value as Record<string, unknown>
59
82
  const entries = Object.keys(record)
60
83
  .sort()
@@ -24,8 +24,32 @@ function channelPatterns(): string | undefined {
24
24
  return undefined
25
25
  }
26
26
 
27
+ /* One line for an AggregateError sub-error: its message, prefixed with the source
28
+ position when it carries one. A thrown `Bun.build` failure holds a `BuildMessage`
29
+ per diagnostic, each with a `position.file` — naming that file is the point, since
30
+ without it a codegen/parse failure reads as a detail-free "Bundle failed". */
31
+ function formatAggregateEntry(entry: unknown): string {
32
+ if (typeof entry === 'object' && entry !== null && 'message' in entry) {
33
+ const message = String((entry as { message: unknown }).message)
34
+ const position = (entry as { position?: { file?: string; line?: number; column?: number } })
35
+ .position
36
+ if (position?.file) {
37
+ return ` ${position.file}:${position.line ?? 0}:${position.column ?? 0} — ${message}`
38
+ }
39
+ return ` ${message}`
40
+ }
41
+ return ` ${String(entry)}`
42
+ }
43
+
27
44
  // Prefers a full stack trace when the value is an Error so logs include the call site.
45
+ // An AggregateError (a thrown Bun.build failure among them) carries the real diagnostics
46
+ // in `.errors` — its own message is only a summary ("Bundle failed") — so expand each one,
47
+ // or every per-file position is dropped and the failure reads with zero detail.
28
48
  function errorParts(value: unknown): { msg: string; stack?: string } {
49
+ if (value instanceof AggregateError && value.errors.length > 0) {
50
+ const details = value.errors.map(formatAggregateEntry).join('\n')
51
+ return { msg: `${value.message}\n${details}` }
52
+ }
29
53
  if (value instanceof Error) {
30
54
  return { msg: value.message, stack: value.stack }
31
55
  }
@@ -2,9 +2,10 @@ import type { BuildOutput } from 'bun'
2
2
  import { abideLog } from './abideLog.ts'
3
3
 
4
4
  /*
5
- On a failed Bun.build(), logs each diagnostic and exits non-zero. Every abide
6
- build entrypoint (build / compile / buildCli / bundleApp) funnels its result
7
- through here so failure reporting can't drift between them.
5
+ On a failed Bun.build(), logs each diagnostic and exits non-zero. The one-shot
6
+ build entrypoints reach it through `buildArtifact` (build-or-die); the
7
+ incremental client build (build.ts) calls it directly on its conditional-exit
8
+ path. One reporter so build failure can't drift between them.
8
9
  */
9
10
  export function exitOnBuildFailure(result: BuildOutput): void {
10
11
  if (result.success) {
@@ -9,7 +9,9 @@ counterpart to loadEnvFile (which merges into process.env) and serializeEnv
9
9
  */
10
10
  export function parseEnv(text: string): Record<string, string> {
11
11
  const result: Record<string, string> = {}
12
- for (const line of text.split('\n')) {
12
+ // Split on CRLF or LF — a Windows-saved (or git autocrlf) .env otherwise leaves a
13
+ // trailing \r that breaks ENV_LINE's `$` anchor, silently dropping every line.
14
+ for (const line of text.split(/\r?\n/)) {
13
15
  if (!line || line.startsWith('#')) {
14
16
  continue
15
17
  }
@@ -19,11 +21,20 @@ export function parseEnv(text: string): Record<string, string> {
19
21
  }
20
22
  const [, key, rawValue] = match
21
23
  const trimmed = rawValue?.trim() ?? ''
22
- const unquoted =
23
- (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
24
- (trimmed.startsWith("'") && trimmed.endsWith("'"))
25
- ? trimmed.slice(1, -1)
26
- : trimmed
24
+ let unquoted: string
25
+ if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
26
+ // Double-quoted: unescape the sequences serializeEnv writes (`\\`, `\"`, `\n`, `\r`).
27
+ unquoted = trimmed
28
+ .slice(1, -1)
29
+ .replace(/\\([nr"\\])/g, (_, c: string) =>
30
+ c === 'n' ? '\n' : c === 'r' ? '\r' : c,
31
+ )
32
+ } else if (trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'")) {
33
+ // Single-quoted: taken verbatim (no escaping applied on the way out).
34
+ unquoted = trimmed.slice(1, -1)
35
+ } else {
36
+ unquoted = trimmed
37
+ }
27
38
  result[key as string] = unquoted
28
39
  }
29
40
  return result
@@ -1,18 +1,30 @@
1
- // Quote only values that wouldn't round-trip bare through parseEnv empties and
2
- // anything carrying whitespace or a `#` (which would otherwise read as a comment).
1
+ // Quote any value that wouldn't round-trip bare through parseEnv: empties, anything
2
+ // carrying whitespace/`#` (a comment)/a quote/a backslash, or one that starts or ends with
3
+ // a quote char (parseEnv strips a surrounding quote pair). Everything else stays bare.
3
4
  function needsQuoting(value: string): boolean {
4
- return value === '' || /[\s#]/.test(value)
5
+ return value === '' || /[\s#"\\]/.test(value) || /^['"]|['"]$/.test(value)
6
+ }
7
+
8
+ // Escape a value for inside double quotes: backslash and quote so the closing quote can't
9
+ // be faked, and newlines so an embedded `\n` can't inject a whole new `KEY=value` line
10
+ // (the security fix — an unescaped token newline used to forge extra env entries).
11
+ function escapeQuoted(value: string): string {
12
+ return value
13
+ .replace(/\\/g, '\\\\')
14
+ .replace(/"/g, '\\"')
15
+ .replace(/\n/g, '\\n')
16
+ .replace(/\r/g, '\\r')
5
17
  }
6
18
 
7
19
  /*
8
20
  Serializes a key→value record to `.env` text — the inverse of parseEnv, used by
9
21
  the connect-screen config form to persist the user's answers to the data-dir
10
- `.env`. One `KEY=value` per line; values that need it are wrapped in double
11
- quotes so parseEnv reads them back unchanged.
22
+ `.env`. One `KEY=value` per line; values that need it are double-quoted and escaped
23
+ so parseEnv reads them back unchanged and a value can never inject extra lines.
12
24
  */
13
25
  export function serializeEnv(values: Record<string, string>): string {
14
26
  const lines = Object.entries(values).map(([key, value]) =>
15
- needsQuoting(value) ? `${key}="${value}"` : `${key}=${value}`,
27
+ needsQuoting(value) ? `${key}="${escapeQuoted(value)}"` : `${key}=${value}`,
16
28
  )
17
29
  return `${lines.join('\n')}\n`
18
30
  }
@@ -1,18 +1,23 @@
1
1
  const SNIPPET = Symbol.for('abide.snippet')
2
2
 
3
- /* A `{#snippet name(args)}` snippet, called like a function: it carries a payload a
4
- `{expr}` interpolation mounts in place — a DOM builder `(host) => void` on the
3
+ /* The internal payload a snippet carries a DOM builder `(host) => void` on the
5
4
  client, the pre-rendered HTML string on the server. The brand is a registered
6
5
  Symbol so it survives across module/bundle copies (same idiom as `html\`\``). */
7
- export type Snippet<Payload> = { readonly [SNIPPET]: Payload }
6
+ export type SnippetValue = { readonly [SNIPPET]: unknown }
7
+
8
+ /* The author-facing snippet type: a builder invoked with its arguments, yielding a
9
+ mountable value. `children` is `Snippet` (no args, invoked `children()`); a row
10
+ renderer is `Snippet<[Item]>` (invoked `row(item)`). The side-specific payload is
11
+ hidden behind `SnippetValue`. */
12
+ export type Snippet<Args extends unknown[] = []> = (...args: Args) => SnippetValue
8
13
 
9
14
  /* Brands a snippet payload so a `{expr}` interpolation mounts it instead of
10
15
  inserting escaped text. The compiler wraps a snippet's body in this — the client
11
16
  builder closes over the defining component's scope, the server string is its SSR
12
17
  render — so a snippet value passes through props like any other value. */
13
18
  // @documentation templating
14
- export function snippet<Payload>(payload: Payload): Snippet<Payload> {
15
- return { [SNIPPET]: payload }
19
+ export function snippet<Payload>(payload: Payload): SnippetValue {
20
+ return { [SNIPPET]: payload } as SnippetValue
16
21
  }
17
22
 
18
23
  /* The payload of a snippet-branded value, or undefined for anything else — so a
@@ -20,6 +25,6 @@ export function snippet<Payload>(payload: Payload): Snippet<Payload> {
20
25
  reads a builder function; the server reads the rendered string. */
21
26
  export function snippetPayload(value: unknown): unknown {
22
27
  return value !== null && typeof value === 'object' && SNIPPET in value
23
- ? (value as Snippet<unknown>)[SNIPPET]
28
+ ? (value as SnippetValue)[SNIPPET]
24
29
  : undefined
25
30
  }
@@ -116,6 +116,11 @@ function interpolate(segments: RouteSegment[], params: Query): string {
116
116
  if (segment.optional && (value === undefined || value === '')) {
117
117
  return undefined
118
118
  }
119
+ /* A required `[name]`/`[...rest]` with no value would otherwise stringify to the
120
+ literal "undefined" and silently mis-route; fail loudly instead. */
121
+ if (value === undefined || value === null) {
122
+ throw new Error(`abide url(): missing required param "${segment.name}"`)
123
+ }
119
124
  const text = String(value)
120
125
  return segment.catchAll
121
126
  ? text.split('/').map(encodeURIComponent).join('/')
@@ -49,6 +49,7 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string; alias?: stri
49
49
  { name: 'restProps', specifier: 'ui/dom/restProps', alias: '$$restProps' },
50
50
  { name: 'spreadAttrs', specifier: 'ui/dom/spreadAttrs', alias: '$$spreadAttrs' },
51
51
  { name: 'readCall', specifier: 'ui/dom/readCall', alias: '$$readCall' },
52
+ { name: 'mutateDocArray', specifier: 'ui/dom/mutateDocArray', alias: '$$mutateDocArray' },
52
53
  { name: 'hydrate', specifier: 'ui/dom/hydrate', alias: '$$hydrate' },
53
54
  { name: 'escapeKey', specifier: 'ui/runtime/escapeKey', alias: '$$escapeKey' },
54
55
  { name: 'nextBlockId', specifier: 'ui/runtime/nextBlockId', alias: '$$nextBlockId' },
@@ -2,18 +2,17 @@
2
2
  The DOM event a generic two-way `bind:<property>` listens on to write the bound
3
3
  path back. Most form fields report edits via `input`, but a few properties are
4
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.
5
+ `toggle` and a checkbox/radio `checked` fires `change`. `<select value>` is NOT
6
+ handled here: generateBuild intercepts it before this function and routes it to
7
+ `bindSelectValue` (a MutationObserver-based helper that re-applies on late
8
+ options), so this function never sees that case.
7
9
  */
8
- export function bindListenEvent(property: string, tag: string): string {
10
+ export function bindListenEvent(property: string): string {
9
11
  if (property === 'open') {
10
12
  return 'toggle'
11
13
  }
12
14
  if (property === 'checked') {
13
15
  return 'change'
14
16
  }
15
- if (property === 'value' && tag === 'select') {
16
- return 'change'
17
- }
18
17
  return 'input'
19
18
  }
@@ -27,7 +27,7 @@ when a caller (a test, a top-level render) omits it.
27
27
 
28
28
  The body is wrapped in an ASYNC IIFE (returns `Promise<SsrRender>`) ONLY when it contains
29
29
  an inline `await` — a blocking `{#await … then}` block, a child-component render, a `<slot>`
30
- read (its `$children` builder is async), or a top-level `await` in the author script. A
30
+ read (its `children` builder is async), or a top-level `await` in the author script. A
31
31
  purely synchronous / streaming-await / try-only component returns `SsrRender` directly. The
32
32
  framework always `await`s `render()`, so production is uniform either way; the sync return
33
33
  keeps static pages off the microtask queue and leaves the bulk of the SSR tests synchronous.
@@ -22,8 +22,10 @@ OMITTED (see `shadowPreamble`) to avoid a duplicate identifier. Otherwise
22
22
  `state`/`linked`/`computed` are declared ambiently and `effect` is imported as a fallback
23
23
  for a bare/nested use the top-level rewrite doesn't project (unused when projected — fine,
24
24
  the shadow disables noUnusedLocals). `props` is the prop reader — destructured
25
- (`const { a = 1 } = props<Shape>()`); declared returning its type argument (default
26
- `Record<string, any>`) so each binding inherits its prop type and its `= default` narrows.
25
+ (`const { a = 1 } = props<Shape>()`); a required import (`abide/ui/props`), so the shadow
26
+ declares it only when imported, returning the file's contextual shape (route params or
27
+ `Record<string, any>`) intersected with its own type argument — additive, so declared props
28
+ like `children: Snippet` layer on top instead of re-spelling the route shape.
27
29
  */
28
30
  function shadowPreamble(importedReactives: ReadonlySet<string>): string {
29
31
  /* Omit the ambient fallback for a primitive the author imports — its own binding is
@@ -46,7 +48,6 @@ function shadowPreamble(importedReactives: ReadonlySet<string>): string {
46
48
  importedReactives.has('computed')
47
49
  ? undefined
48
50
  : 'declare function computed<T>(compute: () => T): { readonly value: T }',
49
- 'declare const children: (() => void) | undefined',
50
51
  /* `effect` is defined either way — the preamble import or the author's own import. */
51
52
  'void [effect, snippet, scope]',
52
53
  ]
@@ -76,15 +77,32 @@ export function compileShadow(source: string, propsType = 'Record<string, any>')
76
77
  const scriptStart = leadingScript ? source.indexOf('>', leadingScript.index) + 1 : 0
77
78
  const templateStart = leadingScript ? (leadingScript.index ?? 0) + leadingScript[0].length : 0
78
79
 
79
- const { imports, types, scope, propsShapes, diagnostics, importedReactives } = analyzeScript(
80
- scriptBody,
81
- scriptStart,
82
- )
80
+ const { imports, types, scope, propsShapes, diagnostics, importedReactives, propsLocalName } =
81
+ analyzeScript(scriptBody, scriptStart)
83
82
  builder.raw(shadowPreamble(importedReactives))
84
- /* `props()` defaults to the file's type a page/layout's route param shape, else
85
- `Record<string, any>`so `const { id } = props()` infers from the route. */
86
- builder.raw(`declare function props<T = ${propsType}>(): T\n`)
83
+ /* `props` is a required import (`abide/ui/props`). The shadow owns its type so the
84
+ return is file-contextual the route param shape (page/layout) or `Record<string,
85
+ any>` (component), intersected with the author's annotation `T` so declared props
86
+ (notably `children: Snippet`) are ADDITIVE and route params never need re-spelling.
87
+ Emitted only when imported: a missing import surfaces as "Cannot find name 'props'"
88
+ (or the author's local alias), so `abide check` flags it. Declared under the LOCAL
89
+ binding name (alias-safe, like `state`/`effect`) — a `props as p` import must get a
90
+ `p` declare, or `p()` reads as undefined once the import below is stripped. */
91
+ if (propsLocalName !== undefined) {
92
+ builder.raw(`declare function ${propsLocalName}<T = {}>(): (${propsType}) & T\n`)
93
+ }
94
+ const propsSpecifier = `${ABIDE_PACKAGE_NAME}/ui/props`
87
95
  for (const line of imports) {
96
+ /* The `props` import is replaced by the contextual `declare function` above;
97
+ emitting it too would be a duplicate-identifier error. Matched by EXACT specifier
98
+ (either quote style, not a loose suffix match) so an unrelated user module merely
99
+ named `.../ui/props` survives verbatim. */
100
+ if (
101
+ line.text.includes(`from '${propsSpecifier}'`) ||
102
+ line.text.includes(`from "${propsSpecifier}"`)
103
+ ) {
104
+ continue
105
+ }
88
106
  builder.flush(line)
89
107
  }
90
108
  /* Component-local `type`/`interface` declarations are hoisted to module scope —
@@ -123,6 +141,19 @@ export function compileShadow(source: string, propsType = 'Record<string, any>')
123
141
  /* Nested `<script>` blocks inline into the synchronous `build()` too, so a top-level
124
142
  await in one is the same build-breaker — flag it, mapped via the node's body offset. */
125
143
  collectNestedScriptAwaitDiagnostics(templateNodes, diagnostics)
144
+ /* Emit the DOM-typed attachment aliases only when an element `attach` uses them: they
145
+ reference `HTMLElementTagNameMap`/`Element`, so a template with no element attach never
146
+ forces DOM lib into its shadow. `__ElementFor` maps a tag to its element interface
147
+ (HTML-first; unknown/custom tags fall back to `Element`); `__Attachment` sources its
148
+ return type from the real `attach` runtime signature so it never drifts. */
149
+ if (hasElementAttach(templateNodes)) {
150
+ builder.raw(
151
+ 'type __ElementFor<T extends string> = T extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[T] : T extends keyof SVGElementTagNameMap ? SVGElementTagNameMap[T] : T extends keyof MathMLElementTagNameMap ? MathMLElementTagNameMap[T] : Element;\n',
152
+ )
153
+ builder.raw(
154
+ `type __Attachment<E extends Element> = (node: E) => ReturnType<Parameters<typeof import('${ABIDE_PACKAGE_NAME}/ui/dom/attach').attach>[1]>;\n`,
155
+ )
156
+ }
126
157
  emitNodes(templateNodes, builder)
127
158
  builder.raw('}\n')
128
159
  return { ...builder.result(), diagnostics }
@@ -209,6 +240,10 @@ type ScriptAnalysis = {
209
240
  /* The reactive primitives the author imports (`state`/`effect`), so the preamble
210
241
  omits the ambient fallback for each and avoids a duplicate-identifier error. */
211
242
  importedReactives: Set<string>
243
+ /* The LOCAL binding name the author's `props` import is bound to (alias-safe — `props`
244
+ for the canonical import, `p` for `props as p`), or undefined when not imported. The
245
+ `declare function` for `props` must target this name, not the canonical `'props'`. */
246
+ propsLocalName: string | undefined
212
247
  }
213
248
 
214
249
  /* Pushes a diagnostic for every author binding whose name starts with the reserved `$$`
@@ -321,7 +356,15 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
321
356
  const propsShapes: string[] = []
322
357
  const diagnostics: ShadowDiagnostic[] = []
323
358
  if (scriptBody.trim() === '') {
324
- return { imports, types, scope, propsShapes, diagnostics, importedReactives: new Set() }
359
+ return {
360
+ imports,
361
+ types,
362
+ scope,
363
+ propsShapes,
364
+ diagnostics,
365
+ importedReactives: new Set(),
366
+ propsLocalName: undefined,
367
+ }
325
368
  }
326
369
  const file = ts.createSourceFile('script.ts', scriptBody, ts.ScriptTarget.Latest, true)
327
370
  /* The author's reactive import bindings (alias-safe) — recognition source for
@@ -329,6 +372,15 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
329
372
  the preamble omits its ambient fallback for. */
330
373
  const bindings = reactiveImportBindings(file)
331
374
  const importedReactives = new Set(bindings.direct.values())
375
+ /* The local name bound to `props` (alias-safe): the key whose value is the canonical
376
+ `'props'`. At most one — a file imports `props` from one specifier. */
377
+ let propsLocalName: string | undefined
378
+ for (const [local, canonical] of bindings.direct) {
379
+ if (canonical === 'props') {
380
+ propsLocalName = local
381
+ break
382
+ }
383
+ }
332
384
  /* The `$$` prefix is reserved for the compiler's injected runtime (`$$each`, `$$model`,
333
385
  `$$scope`, …), so an author binding may not start with it — that's the contract that
334
386
  lets a user freely name a variable after any helper. Flag every such declaration. */
@@ -368,7 +420,7 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
368
420
  scope.push(scopeLineFor(declaration, propsShapes, verbatim, span, bindings))
369
421
  }
370
422
  }
371
- return { imports, types, scope, propsShapes, diagnostics, importedReactives }
423
+ return { imports, types, scope, propsShapes, diagnostics, importedReactives, propsLocalName }
372
424
  }
373
425
 
374
426
  /* Value-projects a nested control-flow `<script>` body the way `analyzeScript`
@@ -446,14 +498,22 @@ function scopeLineFor(
446
498
  })
447
499
  if (callee === 'state') {
448
500
  /* state<T>(initial): T is the value type — carry it onto the `let` so an
449
- explicit annotation isn't lost to `any`/`any[]` inference of the initial. */
450
- const typeNode = call.typeArguments?.[0]
501
+ explicit annotation isn't lost to `any`/`any[]` inference of the initial. The
502
+ type comes from the generic (`state<T>(v)`), else the binding annotation
503
+ (`let x: T = state(v)`) — either form pins the cell type, so a narrow/`any`
504
+ inference of the initial (`state([])` → `any[]`) can't leak. */
505
+ const typeNode = call.typeArguments?.[0] ?? declaration.type
451
506
  const annotation = typeNode === undefined ? '' : `: ${verbatim(typeNode)}`
452
507
  const init = call.arguments[0]
453
- if (init === undefined) {
454
- /* No initial (`state<T>()`): the value is `T | undefined`. A definite-
455
- assignment assertion (`!`) gives that union without a use-before-assign
456
- false-positive AND without control-flow narrowing it to just `undefined`
508
+ /* A literal `undefined` initial is the no-arg form spelled out (`state<T>(undefined)`):
509
+ the value is `T | undefined`, never `= (undefined)` checked against a non-optional
510
+ `T` which would spuriously flag "undefined not assignable to T". */
511
+ const initIsUndefined =
512
+ init !== undefined && ts.isIdentifier(init) && init.text === 'undefined'
513
+ if (init === undefined || initIsUndefined) {
514
+ /* No initial (`state<T>()` / `state<T>(undefined)`): the value is `T | undefined`.
515
+ A definite-assignment assertion (`!`) gives that union without a use-before-
516
+ assign false-positive AND without control-flow narrowing it to just `undefined`
457
517
  (an `= undefined` initializer, never reassigned in the shadow, would make
458
518
  a guard like `x !== undefined` collapse to `never`). Unguarded access is
459
519
  then correctly flagged possibly-undefined; a guard narrows cleanly. */
@@ -471,9 +531,12 @@ function scopeLineFor(
471
531
  })
472
532
  }
473
533
  /* computed<T>(compute) / linked<T>(seed) — the only callees left: T is the value
474
- type — the call's first arg is a thunk, so invoking it yields the value. Annotate
475
- so an explicit type argument isn't lost to inference of the thunk's return. */
476
- const typeNode = call.typeArguments?.[0]
534
+ type — the call's first arg is a thunk, so invoking it yields the value. The type
535
+ comes from the generic (`computed<T>(fn)`), else the binding annotation
536
+ (`let x: T = state.computed(fn)`) — same fallback as `state` above, so a binding
537
+ annotation isn't dropped and `abide check` doesn't emit `never`-inference false
538
+ positives on the thunk's return. */
539
+ const typeNode = call.typeArguments?.[0] ?? declaration.type
477
540
  const annotation = typeNode === undefined ? '' : `: ${verbatim(typeNode)}`
478
541
  const fn = call.arguments[0]
479
542
  /* `linked` is a writable `State<T>` at runtime (it reseeds AND accepts `.value =`
@@ -494,6 +557,28 @@ function scopeLineFor(
494
557
  })
495
558
  }
496
559
 
560
+ /* Whether any ELEMENT in the tree carries an `attach` — gates emitting the DOM-typed
561
+ attachment aliases. All nested content (block bodies, await/switch branches, snippet
562
+ bodies) routes through `children`, so a recursive `children` walk is complete. */
563
+ function hasElementAttach(nodes: TemplateNode[]): boolean {
564
+ for (const node of nodes) {
565
+ if (node === undefined) {
566
+ continue
567
+ }
568
+ if (node.kind === 'element') {
569
+ for (const attr of node.attrs) {
570
+ if (attr.kind === 'attach') {
571
+ return true
572
+ }
573
+ }
574
+ }
575
+ if ('children' in node && node.children !== undefined && hasElementAttach(node.children)) {
576
+ return true
577
+ }
578
+ }
579
+ return false
580
+ }
581
+
497
582
  /* Emits a sibling list — each node standalone via `emitNode`. */
498
583
  function emitNodes(nodes: TemplateNode[], builder: Builder): void {
499
584
  for (const node of nodes) {
@@ -517,15 +602,26 @@ function emitNode(node: TemplateNode, builder: Builder): void {
517
602
  return
518
603
  case 'element':
519
604
  for (const attr of node.attrs) {
520
- /* An interpolated value checks each `{expr}` part on its own offset; every
521
- other dynamic attribute checks its single `code`. */
522
- if (attr.kind === 'interpolated') {
605
+ if (attr.kind === 'attach') {
606
+ /* `attach={code}` types its callback's `node` param from the element's tag
607
+ (via `__ElementFor`) and checks the whole value is an attachment: an inline
608
+ arrow's `node` reads the specific DOM interface, and a non-function value is
609
+ rejected. Same IIFE-parameter contextual-typing trick the component-prop
610
+ path uses; the leading `;` guards an unterminated preceding statement. */
611
+ builder.raw(
612
+ `;((__attach: __Attachment<__ElementFor<${JSON.stringify(node.tag)}>>) => {})(`,
613
+ )
614
+ builder.expr(attr.code, attr.loc)
615
+ builder.raw(');\n')
616
+ } else if (attr.kind === 'interpolated') {
617
+ /* An interpolated value checks each `{expr}` part on its own offset. */
523
618
  for (const part of attr.parts) {
524
619
  if (part.kind === 'expression') {
525
620
  builder.stmt(part.code, part.loc)
526
621
  }
527
622
  }
528
623
  } else if (attr.kind !== 'static') {
624
+ /* Every other dynamic attribute checks its single `code`. */
529
625
  builder.stmt(attr.code, attr.loc)
530
626
  }
531
627
  }
@@ -773,7 +869,7 @@ function emitNode(node: TemplateNode, builder: Builder): void {
773
869
  builder.mapped(node.params, node.loc)
774
870
  }
775
871
  /* Wrap the body in `snippet(() => …)` so the shadow types the snippet as
776
- `(args) => Snippet<…>` — mirroring the runtime lowering (which returns
872
+ `(args) => SnippetValue` — mirroring the runtime lowering (which returns
777
873
  `$$snippet(($host) => …)`) so a snippet passed to a `Snippet`-typed prop
778
874
  type-checks instead of reading as `() => void`. */
779
875
  builder.raw(') => snippet(() => {\n')
@@ -11,8 +11,9 @@ SSR/client prop congruence rests on. No spread → the plain object literal of n
11
11
  value thunks (+ the trailing slot). With a `{...expr}` spread → a `mergeProps` of
12
12
  ordered layers — explicit-prop runs, `$$spreadProps(expr)` spreads, the slot —
13
13
  resolved last-wins per key, so source order decides overrides (like JSX).
14
- `lowerExpression` is the caller's expression lowering; `slotPart` is its `$children`
15
- layer (a host-taking builder for the client, a string-returning thunk for SSR) or
14
+ `lowerExpression` is the caller's expression lowering; `slotPart` is its `children`
15
+ layer slotted content as a `Snippet` under the `children` prop key (a builder-
16
+ returning callable for the client, a `$snip`-string-returning callable for SSR) — or
16
17
  undefined when the component has no slotted children.
17
18
  */
18
19
  export function composeProps(
@@ -430,7 +430,7 @@ function propsStatements(
430
430
  ),
431
431
  )
432
432
  }
433
- /* The rest bag gathers every prop not named above (and not `$children`). */
433
+ /* The rest bag gathers every prop not named above (and not `children`). */
434
434
  if (rest !== undefined) {
435
435
  const consumed = propBindings.map((binding) => factory.createStringLiteral(binding.key))
436
436
  statements.push(
@@ -200,7 +200,7 @@ export function generateBuild(
200
200
  (`type="number"`/`"range"`) reports its edit as a string on `el.value`, which
201
201
  would corrupt number-typed state; source the write-back from `valueAsNumber`
202
202
  instead (empty field → `undefined`), gated on a statically-known type. */
203
- const event = bindListenEvent(attr.property, node.tag)
203
+ const event = bindListenEvent(attr.property)
204
204
  const staticType = staticAttrValue(node, 'type')
205
205
  const isNumericInput =
206
206
  attr.property === 'value' &&
@@ -547,21 +547,29 @@ export function generateBuild(
547
547
  return `$$switchBlock(${parentVar}, () => (${lowerExpression(node.subject)}), [${cases}], ${before});\n`
548
548
  }
549
549
 
550
- /* A `{children()}` slot fill point: render the parent-provided content (`$children`).
551
- A fallback is now an authored `{#if children}…{:else}…{/if}`, so the slot node never
552
- carries children the invoke is unconditional but guarded against a propless mount. */
550
+ /* A component `{children()}` fill point: mount the `children` prop (a `Snippet`) by
551
+ calling it and mounting the resulting value through the snippet interpolation path,
552
+ exactly as any `{snippet(args)}`. `lowerExpression('children()')` reads the destructured
553
+ `children` computed and calls it → a `SnippetValue`, which `appendText` routes to
554
+ `appendSnippet`. A fallback is now an authored `{#if children}…{:else}…{/if}`, so the slot
555
+ node carries no children. Layouts never reach here — their slots are rewritten to `outlet`
556
+ elements by `asOutlet`. */
553
557
  function generateSlot(
554
558
  _node: Extract<TemplateNode, { kind: 'element' }>,
555
559
  parentVar: string,
556
560
  ): string {
557
- return `if ($props && $props.$children) { $props.$children(${parentVar}); }\n`
561
+ return `$$appendText(${parentVar}, () => (${lowerExpression('children()')}));\n`
558
562
  }
559
563
 
560
- /* The child's slot content as a host-taking builder (`$children`), or undefined when
561
- the component has no slotted children. */
564
+ /* Slot content as a zero-arg `Snippet` under the `children` key a callable returning a
565
+ `$$snippet`-branded builder, so it unifies with a passed `children={snippet}` and mounts
566
+ through the standard snippet interpolation path. `composeProps` wraps it in the prop-bag
567
+ thunk, so the final bag value is `() => (Snippet callable)`. */
562
568
  function slotPart(node: Extract<TemplateNode, { kind: 'component' }>): string | undefined {
563
569
  const slotCode = generateChildren(node.children, '$slot')
564
- return slotCode.trim() === '' ? undefined : `"$children": ($slot) => {\n${slotCode}}`
570
+ return slotCode.trim() === ''
571
+ ? undefined
572
+ : `"children": () => (() => $$snippet(($slot) => {\n${slotCode}}))`
565
573
  }
566
574
 
567
575
  /* The props bag a child mount receives — composed by the shared `composeProps` so the
@@ -581,7 +589,13 @@ export function generateBuild(
581
589
  parentVar: string,
582
590
  before: string,
583
591
  ): string {
584
- return `$$mountChild(${parentVar}, ${node.name}, ${propsArg(node)}, ${before}, ${JSON.stringify(node.name)});\n`
592
+ /* The tag lowers through `lowerExpression` like any other reference: a static
593
+ module import (`Button`) is left bare, but a reactive binding — a `{#for}`
594
+ item, an `await` `then` value, a component signal — derefs to its `.value`
595
+ cell, so `<Icon>` from `{#for {icon: Icon} of …}` mounts the component the cell
596
+ holds, not the cell object (whose `.build` is undefined → `build is not a
597
+ function`). SSR emits the same lowering for congruence. */
598
+ return `$$mountChild(${parentVar}, ${lowerExpression(node.name)}, ${propsArg(node)}, ${before}, ${JSON.stringify(node.name)});\n`
585
599
  }
586
600
 
587
601
  /* An await block: pending → resolved(value) / error branches. Each branch is a