@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.
- package/AGENTS.md +15 -7
- package/CHANGELOG.md +39 -0
- package/README.md +9 -3
- package/package.json +3 -1
- package/src/buildCli.ts +3 -5
- package/src/buildDisconnected.ts +2 -3
- package/src/bundleApp.ts +2 -3
- package/src/compile.ts +2 -4
- package/src/lib/bundle/installDownloads.ts +13 -3
- package/src/lib/bundle/installMacMenu.ts +13 -3
- package/src/lib/cli/parseArgvForRpc.ts +36 -9
- package/src/lib/cli/tokenizeArgvFlags.ts +4 -2
- package/src/lib/mcp/createMcpResourceServer.ts +13 -2
- package/src/lib/mcp/toolResultFromResponse.ts +40 -12
- package/src/lib/server/rpc/parseArgs.ts +15 -1
- package/src/lib/server/rpc/runWithRpcTimeout.ts +12 -1
- package/src/lib/server/runtime/finalizeResponse.ts +11 -1
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +10 -2
- package/src/lib/shared/buildArtifact.ts +17 -0
- package/src/lib/shared/buildRpcRequest.ts +6 -2
- package/src/lib/shared/canonicalJson.ts +24 -1
- package/src/lib/shared/createChannelLog.ts +24 -0
- package/src/lib/shared/exitOnBuildFailure.ts +4 -3
- package/src/lib/shared/parseEnv.ts +17 -6
- package/src/lib/shared/serializeEnv.ts +18 -6
- package/src/lib/shared/snippet.ts +11 -6
- package/src/lib/shared/url.ts +5 -0
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/bindListenEvent.ts +5 -6
- package/src/lib/ui/compile/compileSSR.ts +1 -1
- package/src/lib/ui/compile/compileShadow.ts +121 -25
- package/src/lib/ui/compile/composeProps.ts +3 -2
- package/src/lib/ui/compile/desugarSignals.ts +1 -1
- package/src/lib/ui/compile/generateBuild.ts +23 -9
- package/src/lib/ui/compile/generateSSR.ts +49 -37
- package/src/lib/ui/compile/isWhitespaceText.ts +10 -2
- package/src/lib/ui/compile/lowerDocAccess.ts +39 -1
- package/src/lib/ui/compile/parseTemplate.ts +3 -1
- package/src/lib/ui/compile/renameSignalRefs.ts +5 -18
- package/src/lib/ui/compile/resolveReactiveExport.ts +4 -7
- package/src/lib/ui/compile/scopeCss.ts +27 -4
- package/src/lib/ui/dom/awaitBlock.ts +67 -29
- package/src/lib/ui/dom/discardBoundary.ts +24 -6
- package/src/lib/ui/dom/each.ts +15 -8
- package/src/lib/ui/dom/fillBefore.ts +6 -7
- package/src/lib/ui/dom/mergeProps.ts +1 -1
- package/src/lib/ui/dom/mountSlot.ts +1 -1
- package/src/lib/ui/dom/mutateDocArray.ts +38 -0
- package/src/lib/ui/dom/restProps.ts +2 -2
- package/src/lib/ui/dom/spreadProps.ts +3 -4
- package/src/lib/ui/dom/tryBlock.ts +10 -11
- package/src/lib/ui/dom/withScope.ts +7 -0
- package/src/lib/ui/history.ts +14 -7
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/props.ts +17 -0
- package/src/lib/ui/renderChain.ts +7 -3
- package/src/lib/ui/router.ts +23 -17
- package/src/lib/ui/runtime/CHILD_PRESENT.ts +2 -2
- package/src/lib/ui/runtime/applyPatchToTree.ts +16 -3
- package/src/lib/ui/runtime/captureModelDoc.ts +22 -7
- package/src/lib/ui/runtime/createDoc.ts +28 -12
- package/src/lib/ui/runtime/flushEffects.ts +23 -1
- package/src/lib/ui/runtime/scope.ts +11 -0
- package/src/lib/ui/runtime/toTeardown.ts +10 -3
- package/src/lib/ui/runtime/types/UiProps.ts +6 -5
- package/src/lib/ui/runtime/withoutHydration.ts +22 -0
- 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
|
-
|
|
72
|
-
|
|
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,
|
|
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.
|
|
6
|
-
build
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
(
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
2
|
-
//
|
|
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
|
|
11
|
-
|
|
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
|
-
/*
|
|
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
|
|
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):
|
|
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
|
|
28
|
+
? (value as SnippetValue)[SNIPPET]
|
|
24
29
|
: undefined
|
|
25
30
|
}
|
package/src/lib/shared/url.ts
CHANGED
|
@@ -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
|
|
6
|
-
|
|
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
|
|
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
|
|
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>()`);
|
|
26
|
-
|
|
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 } =
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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.
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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) =>
|
|
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
|
|
15
|
-
layer
|
|
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
|
|
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
|
|
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()}`
|
|
551
|
-
|
|
552
|
-
|
|
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
|
|
561
|
+
return `$$appendText(${parentVar}, () => (${lowerExpression('children()')}));\n`
|
|
558
562
|
}
|
|
559
563
|
|
|
560
|
-
/*
|
|
561
|
-
|
|
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() === ''
|
|
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
|
-
|
|
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
|