@abide/abide 0.29.0 → 0.31.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 +6 -4
- package/CHANGELOG.md +32 -0
- package/package.json +2 -1
- package/src/lib/bundle/disconnected.abide +82 -82
- package/src/lib/cli/dispatchCommand.ts +3 -2
- package/src/lib/cli/resolveCliTarget.ts +2 -3
- package/src/lib/cli/runCli.ts +2 -3
- package/src/lib/cli/runSession.ts +2 -3
- package/src/lib/mcp/dispatchMcpRequest.ts +3 -2
- package/src/lib/mcp/mcpSurface.ts +2 -1
- package/src/lib/mcp/toolResultFromResponse.ts +2 -1
- package/src/lib/server/rpc/parseArgs.ts +1 -3
- package/src/lib/server/runtime/streamFromIterator.ts +3 -1
- package/src/lib/server/runtime/warnUnguardedMcp.ts +4 -3
- package/src/lib/server/sockets/createSocketDispatcher.ts +5 -7
- package/src/lib/shared/cacheEntryFromSnapshot.ts +2 -1
- package/src/lib/shared/contentTypeOf.ts +6 -0
- package/src/lib/shared/decodeResponse.ts +2 -1
- package/src/lib/shared/isCompileTarget.ts +7 -1
- package/src/lib/shared/isModuleNotFound.ts +3 -1
- package/src/lib/shared/isStreamingResponse.ts +2 -1
- package/src/lib/shared/messageFromError.ts +6 -0
- package/src/lib/shared/streamResponse.ts +2 -1
- package/src/lib/ui/compile/REACTIVE_CALLEES.ts +7 -0
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/VOID_TAGS.ts +4 -3
- package/src/lib/ui/compile/compileComponent.ts +12 -2
- package/src/lib/ui/compile/compileModule.ts +7 -4
- package/src/lib/ui/compile/compileSSR.ts +11 -2
- package/src/lib/ui/compile/compileShadow.ts +165 -54
- package/src/lib/ui/compile/createShadowLanguageService.ts +2 -1
- package/src/lib/ui/compile/createShadowProgram.ts +2 -1
- package/src/lib/ui/compile/desugarSignals.ts +41 -14
- package/src/lib/ui/compile/parseTemplate.ts +21 -26
- package/src/lib/ui/compile/prepareNestedScript.ts +4 -2
- package/src/lib/ui/derived.ts +25 -4
- package/src/lib/ui/dom/awaitBlock.ts +1 -24
- package/src/lib/ui/dom/discardBoundary.ts +27 -0
- package/src/lib/ui/dom/tryBlock.ts +7 -26
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/linked.ts +34 -0
- package/src/lib/ui/router.ts +23 -6
- package/src/lib/ui/state.ts +9 -2
- package/template/src/ui/pages/page.abide +1 -1
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/* The callee names the `.abide` compiler recognises as reactive declarations
|
|
2
|
+
(`let x = state(...)`, `linked(...)`, `derived(...)`, `prop(...)`): the shared
|
|
3
|
+
"is this a reactive binding" allowlist read by the desugarer, the nested-script
|
|
4
|
+
scoper, and the type-checking shadow. How each lowers — a serializable doc slot
|
|
5
|
+
vs a `.value` cell — is decided per-site; this is only the membership set, so a
|
|
6
|
+
new primitive is a single edit here. */
|
|
7
|
+
export const REACTIVE_CALLEES: ReadonlySet<string> = new Set(['state', 'linked', 'derived', 'prop'])
|
|
@@ -10,6 +10,7 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string }[] = [
|
|
|
10
10
|
{ name: 'snippet', specifier: 'shared/snippet' },
|
|
11
11
|
{ name: 'doc', specifier: 'ui/doc' },
|
|
12
12
|
{ name: 'state', specifier: 'ui/state' },
|
|
13
|
+
{ name: 'linked', specifier: 'ui/linked' },
|
|
13
14
|
{ name: 'derived', specifier: 'ui/derived' },
|
|
14
15
|
{ name: 'effect', specifier: 'ui/effect' },
|
|
15
16
|
{ name: 'mount', specifier: 'ui/dom/mount' },
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/*
|
|
2
|
-
HTML void elements — they have no closing tag and no children. Shared by the
|
|
3
|
-
generator and the static-clone skeleton generator so
|
|
4
|
-
|
|
2
|
+
HTML void elements — they have no closing tag and no children. Shared by the
|
|
3
|
+
template parser, the SSR generator, and the static-clone skeleton generator so
|
|
4
|
+
all self-close consistently and the back-ends emit `<img>` not `<img></img>`,
|
|
5
|
+
keeping server markup and the client clone template identical.
|
|
5
6
|
*/
|
|
6
7
|
export const VOID_TAGS: ReadonlySet<string> = new Set([
|
|
7
8
|
'area',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { analyzeComponent } from './analyzeComponent.ts'
|
|
2
2
|
import { generateBuild } from './generateBuild.ts'
|
|
3
3
|
import { hoistCells } from './hoistCells.ts'
|
|
4
|
+
import type { AnalyzedComponent } from './types/AnalyzedComponent.ts'
|
|
4
5
|
|
|
5
6
|
/*
|
|
6
7
|
Compiles a single-file abide component into the body of a client build function.
|
|
@@ -9,9 +10,18 @@ template, and hoists static paths to cells. The returned body runs against a
|
|
|
9
10
|
`host` element with `doc`/`state`/`derived`/`effect` and the dom bindings in
|
|
10
11
|
scope and defines `model` itself. `compileModule` wraps it (and the SSR body) into
|
|
11
12
|
a real module; tests wrap it with `new Function`.
|
|
13
|
+
|
|
14
|
+
`analyzed` is a lazy default: a direct caller (tests) omits it and the front-end
|
|
15
|
+
runs here, but `compileModule` analyzes once and passes the result to both
|
|
16
|
+
back-ends, so the shared front-end runs once per build instead of three times.
|
|
12
17
|
*/
|
|
13
|
-
export function compileComponent(
|
|
14
|
-
|
|
18
|
+
export function compileComponent(
|
|
19
|
+
source: string,
|
|
20
|
+
isLayout = false,
|
|
21
|
+
scopeSeed?: string,
|
|
22
|
+
analyzed: AnalyzedComponent = analyzeComponent(source, scopeSeed),
|
|
23
|
+
): string {
|
|
24
|
+
const { script, stateNames, derivedNames, nodes } = analyzed
|
|
15
25
|
const build = generateBuild(nodes, 'host', stateNames, derivedNames, isLayout)
|
|
16
26
|
/* The scoped CSS is bundled into the entry stylesheet (see `abideUiPlugin`), not
|
|
17
27
|
injected at runtime; the build only needs the `data-a-…` scope attributes on
|
|
@@ -22,10 +22,13 @@ export function compileModule(
|
|
|
22
22
|
options: { isLayout?: boolean; moduleId?: string; hot?: boolean } = {},
|
|
23
23
|
): string {
|
|
24
24
|
const isLayout = options.isLayout ?? false
|
|
25
|
-
/*
|
|
26
|
-
|
|
25
|
+
/* Run the shared front-end once and feed it to both back-ends — the analysis is
|
|
26
|
+
pure over (source, moduleId), so the client and SSR builds reuse one parse
|
|
27
|
+
instead of re-running it. `imports` (hoisted child-component imports) and the
|
|
28
|
+
per-element scopes both come from this single pass. */
|
|
29
|
+
const analyzed = analyzeComponent(source, options.moduleId)
|
|
27
30
|
const userImports = analyzed.imports
|
|
28
|
-
const body = indent(compileComponent(source, isLayout, options.moduleId))
|
|
31
|
+
const body = indent(compileComponent(source, isLayout, options.moduleId, analyzed))
|
|
29
32
|
|
|
30
33
|
/* Hot module (dev component HMR): the same client build, but its runtime comes
|
|
31
34
|
from the live bundle via `window.__abide` — so it shares the one reactive graph
|
|
@@ -49,7 +52,7 @@ if (!hotReplace(${id}, component)) location.reload()
|
|
|
49
52
|
`
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
const ssrBody = indent(compileSSR(source, isLayout, options.moduleId))
|
|
55
|
+
const ssrBody = indent(compileSSR(source, isLayout, options.moduleId, analyzed))
|
|
53
56
|
/* Per-component dead-import elimination: emit only the runtime names this module
|
|
54
57
|
actually references. A component that uses no `each`/`await`/`html` shouldn't
|
|
55
58
|
drag those modules into its chunk. The package isn't globally side-effect-free
|
|
@@ -2,6 +2,7 @@ import { analyzeComponent } from './analyzeComponent.ts'
|
|
|
2
2
|
import { generateSSR } from './generateSSR.ts'
|
|
3
3
|
import { SSR_ESCAPE } from './SSR_ESCAPE.ts'
|
|
4
4
|
import { stripEffects } from './stripEffects.ts'
|
|
5
|
+
import type { AnalyzedComponent } from './types/AnalyzedComponent.ts'
|
|
5
6
|
|
|
6
7
|
/*
|
|
7
8
|
Compiles a component into the body of a server render function. Runs the shared
|
|
@@ -19,9 +20,17 @@ Runs with `doc`/`state`/`derived`/`effect`/`nextBlockId`/`enterRenderPass`/
|
|
|
19
20
|
`exitRenderPass` in scope and defines `model`. The body is bracketed by a render
|
|
20
21
|
pass so the outermost render resets the block-id counter and an inlined child
|
|
21
22
|
render continues it — keeping await/try ids unique and aligned with the client.
|
|
23
|
+
|
|
24
|
+
`analyzed` is a lazy default: a direct caller (tests) omits it and the front-end
|
|
25
|
+
runs here, but `compileModule` shares one analysis across both back-ends.
|
|
22
26
|
*/
|
|
23
|
-
export function compileSSR(
|
|
24
|
-
|
|
27
|
+
export function compileSSR(
|
|
28
|
+
source: string,
|
|
29
|
+
isLayout = false,
|
|
30
|
+
scopeSeed?: string,
|
|
31
|
+
analyzed: AnalyzedComponent = analyzeComponent(source, scopeSeed),
|
|
32
|
+
): string {
|
|
33
|
+
const { script, stateNames, derivedNames, nodes } = analyzed
|
|
25
34
|
const ssr = generateSSR(nodes, stateNames, derivedNames, isLayout)
|
|
26
35
|
/* No `<style>` in the markup — the scoped CSS is bundled into the entry stylesheet
|
|
27
36
|
the shell links (see `abideUiPlugin`), so SSR output is styled by that sheet. The
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import ts from 'typescript'
|
|
2
2
|
import { ABIDE_PACKAGE_NAME } from '../../shared/ABIDE_PACKAGE_NAME.ts'
|
|
3
3
|
import { parseTemplate } from './parseTemplate.ts'
|
|
4
|
+
import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
|
|
4
5
|
import type { CompiledShadow, ShadowMapping } from './types/CompiledShadow.ts'
|
|
5
6
|
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
6
7
|
|
|
@@ -14,13 +15,14 @@ never appears here — every `prop()` declaration is rewritten away. `$props` is
|
|
|
14
15
|
legacy untyped prop bag (pre-`prop()` sugar) made available raw.
|
|
15
16
|
*/
|
|
16
17
|
const SHADOW_PREAMBLE = `import { state } from '${ABIDE_PACKAGE_NAME}/ui/state'
|
|
18
|
+
import { linked } from '${ABIDE_PACKAGE_NAME}/ui/linked'
|
|
17
19
|
import { derived } from '${ABIDE_PACKAGE_NAME}/ui/derived'
|
|
18
20
|
import { effect } from '${ABIDE_PACKAGE_NAME}/ui/effect'
|
|
19
21
|
import { doc } from '${ABIDE_PACKAGE_NAME}/ui/doc'
|
|
20
22
|
import { html } from '${ABIDE_PACKAGE_NAME}/shared/html'
|
|
21
23
|
import { snippet } from '${ABIDE_PACKAGE_NAME}/shared/snippet'
|
|
22
24
|
declare const $props: Record<string, (() => unknown) | undefined>
|
|
23
|
-
void [state, derived, effect, doc, html, snippet]
|
|
25
|
+
void [state, linked, derived, effect, doc, html, snippet]
|
|
24
26
|
`
|
|
25
27
|
|
|
26
28
|
/*
|
|
@@ -46,11 +48,18 @@ export function compileShadow(source: string): CompiledShadow {
|
|
|
46
48
|
const scriptStart = leadingScript ? source.indexOf('>', leadingScript.index) + 1 : 0
|
|
47
49
|
const templateStart = leadingScript ? (leadingScript.index ?? 0) + leadingScript[0].length : 0
|
|
48
50
|
|
|
49
|
-
const { imports, scope, props } = analyzeScript(scriptBody, scriptStart)
|
|
51
|
+
const { imports, types, scope, props } = analyzeScript(scriptBody, scriptStart)
|
|
50
52
|
builder.raw(SHADOW_PREAMBLE)
|
|
51
53
|
for (const line of imports) {
|
|
52
54
|
builder.flush(line)
|
|
53
55
|
}
|
|
56
|
+
/* Component-local `type`/`interface` declarations are hoisted to module scope —
|
|
57
|
+
above `__Props` so prop annotations referencing them resolve, and still visible
|
|
58
|
+
inside the function body where the rest of the scope and template expressions use
|
|
59
|
+
them. (Emitting them as in-function scope lines would hide them from `__Props`.) */
|
|
60
|
+
for (const line of types) {
|
|
61
|
+
builder.flush(line)
|
|
62
|
+
}
|
|
54
63
|
builder.raw(`interface __Props {\n${props.join('\n')}\n}\n`)
|
|
55
64
|
/* async so `await` blocks are legal; never executed, so the return is void. */
|
|
56
65
|
builder.raw('export default async function (props: __Props): Promise<void> {\n')
|
|
@@ -59,9 +68,7 @@ export function compileShadow(source: string): CompiledShadow {
|
|
|
59
68
|
for (const line of scope) {
|
|
60
69
|
builder.flush(line)
|
|
61
70
|
}
|
|
62
|
-
|
|
63
|
-
emitNode(node, builder)
|
|
64
|
-
}
|
|
71
|
+
emitNodes(parseTemplate(source.slice(templateStart), templateStart).nodes, builder)
|
|
65
72
|
builder.raw('}\n')
|
|
66
73
|
return builder.result()
|
|
67
74
|
}
|
|
@@ -75,6 +82,9 @@ type Builder = {
|
|
|
75
82
|
expr: (code: string, sourceLoc: number | undefined) => void
|
|
76
83
|
stmt: (code: string, sourceLoc: number | undefined) => void
|
|
77
84
|
flush: (line: ScopeLine) => void
|
|
85
|
+
/* A fresh shadow-local binding name (`__<base>_<n>`) — for synthesised bindings
|
|
86
|
+
like an await's resolved value, kept distinct so nested blocks never collide. */
|
|
87
|
+
unique: (base: string) => string
|
|
78
88
|
result: () => CompiledShadow
|
|
79
89
|
}
|
|
80
90
|
|
|
@@ -84,8 +94,12 @@ type ScopeLine = { text: string; segments: ShadowMapping[] }
|
|
|
84
94
|
|
|
85
95
|
function createBuilder(): Builder {
|
|
86
96
|
let code = ''
|
|
97
|
+
let uniqueCounter = 0
|
|
87
98
|
const mappings: ShadowMapping[] = []
|
|
88
99
|
const builder: Builder = {
|
|
100
|
+
unique(base) {
|
|
101
|
+
return `__${base}_${uniqueCounter++}`
|
|
102
|
+
},
|
|
89
103
|
raw(text) {
|
|
90
104
|
code += text
|
|
91
105
|
},
|
|
@@ -117,17 +131,24 @@ function createBuilder(): Builder {
|
|
|
117
131
|
return builder
|
|
118
132
|
}
|
|
119
133
|
|
|
120
|
-
type ScriptAnalysis = {
|
|
134
|
+
type ScriptAnalysis = {
|
|
135
|
+
imports: ScopeLine[]
|
|
136
|
+
types: ScopeLine[]
|
|
137
|
+
scope: ScopeLine[]
|
|
138
|
+
props: string[]
|
|
139
|
+
}
|
|
121
140
|
|
|
122
141
|
/* Walks the leading `<script>` and produces the shadow's module imports, the
|
|
123
|
-
value-typed scope lines, and the Props
|
|
124
|
-
body's absolute offset in the source, so
|
|
142
|
+
module-scope type declarations, the value-typed scope lines, and the Props
|
|
143
|
+
interface fields. `scriptStart` is the body's absolute offset in the source, so
|
|
144
|
+
verbatim spans map back exactly. */
|
|
125
145
|
function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis {
|
|
126
146
|
const imports: ScopeLine[] = []
|
|
147
|
+
const types: ScopeLine[] = []
|
|
127
148
|
const scope: ScopeLine[] = []
|
|
128
149
|
const props: string[] = []
|
|
129
150
|
if (scriptBody.trim() === '') {
|
|
130
|
-
return { imports, scope, props }
|
|
151
|
+
return { imports, types, scope, props }
|
|
131
152
|
}
|
|
132
153
|
const file = ts.createSourceFile('script.ts', scriptBody, ts.ScriptTarget.Latest, true)
|
|
133
154
|
/* A verbatim span: original text + the segment mapping it back, relative to the
|
|
@@ -145,6 +166,11 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
|
|
|
145
166
|
imports.push({ text: verbatim(statement), segments: [span(statement, 0)] })
|
|
146
167
|
continue
|
|
147
168
|
}
|
|
169
|
+
if (ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement)) {
|
|
170
|
+
/* Hoist to module scope (verbatim, mapped) so prop annotations resolve them. */
|
|
171
|
+
types.push({ text: verbatim(statement), segments: [span(statement, 0)] })
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
148
174
|
const reactive = reactiveDeclarations(statement)
|
|
149
175
|
if (reactive === undefined) {
|
|
150
176
|
/* Plain statement (function, const, expression) — emit verbatim, mapped. */
|
|
@@ -155,7 +181,7 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
|
|
|
155
181
|
scope.push(scopeLineFor(declaration, props, verbatim, span))
|
|
156
182
|
}
|
|
157
183
|
}
|
|
158
|
-
return { imports, scope, props }
|
|
184
|
+
return { imports, types, scope, props }
|
|
159
185
|
}
|
|
160
186
|
|
|
161
187
|
/* The `state`/`derived`/`prop` declarations in a variable statement, or undefined
|
|
@@ -170,14 +196,15 @@ function reactiveDeclarations(statement: ts.Statement): ts.VariableDeclaration[]
|
|
|
170
196
|
return reactive.length === declarations.length && reactive.length > 0 ? reactive : undefined
|
|
171
197
|
}
|
|
172
198
|
|
|
173
|
-
/* The callee name of a `NAME = state(...)` / `
|
|
199
|
+
/* The callee name of a `NAME = state(...)` / `linked(...)` / `derived(...)` /
|
|
200
|
+
`prop(...)` decl. */
|
|
174
201
|
function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
|
|
175
202
|
const initializer = declaration.initializer
|
|
176
203
|
if (
|
|
177
204
|
initializer !== undefined &&
|
|
178
205
|
ts.isCallExpression(initializer) &&
|
|
179
206
|
ts.isIdentifier(initializer.expression) &&
|
|
180
|
-
|
|
207
|
+
REACTIVE_CALLEES.has(initializer.expression.text)
|
|
181
208
|
) {
|
|
182
209
|
return initializer.expression.text
|
|
183
210
|
}
|
|
@@ -207,9 +234,10 @@ function scopeLineFor(
|
|
|
207
234
|
const prefix = `let ${name}${annotation} = (`
|
|
208
235
|
return { text: `${prefix}${verbatim(init)});`, segments: [span(init, prefix.length)] }
|
|
209
236
|
}
|
|
210
|
-
if (callee === 'derived') {
|
|
211
|
-
/* derived<T>(compute): T is the value type —
|
|
212
|
-
|
|
237
|
+
if (callee === 'derived' || callee === 'linked') {
|
|
238
|
+
/* derived<T>(compute) / linked<T>(seed): T is the value type — the call's
|
|
239
|
+
first arg is a thunk, so invoking it yields the value. Annotate so an
|
|
240
|
+
explicit type argument isn't lost to inference of the thunk's return. */
|
|
213
241
|
const typeNode = call.typeArguments?.[0]
|
|
214
242
|
const annotation = typeNode === undefined ? '' : `: ${verbatim(typeNode)}`
|
|
215
243
|
const fn = call.arguments[0]
|
|
@@ -229,9 +257,56 @@ function scopeLineFor(
|
|
|
229
257
|
return { text: `let ${name} = props[${JSON.stringify(keyText)}];`, segments: [] }
|
|
230
258
|
}
|
|
231
259
|
|
|
232
|
-
/* Emits a
|
|
233
|
-
|
|
234
|
-
|
|
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`. */
|
|
265
|
+
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') {
|
|
272
|
+
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
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return -1
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/* Emits a template node's expressions into the shadow's render body. Control flow
|
|
308
|
+
introduces its binding so children type-check against it; every expression is
|
|
309
|
+
referenced in a statement so a type error surfaces and maps. */
|
|
235
310
|
function emitNode(node: TemplateNode, builder: Builder): void {
|
|
236
311
|
switch (node.kind) {
|
|
237
312
|
case 'text':
|
|
@@ -247,9 +322,7 @@ function emitNode(node: TemplateNode, builder: Builder): void {
|
|
|
247
322
|
builder.stmt(attr.code, attr.loc)
|
|
248
323
|
}
|
|
249
324
|
}
|
|
250
|
-
node.children
|
|
251
|
-
emitNode(child, builder)
|
|
252
|
-
})
|
|
325
|
+
emitNodes(node.children, builder)
|
|
253
326
|
return
|
|
254
327
|
case 'component': {
|
|
255
328
|
/* Check each prop against the child's declared type. The imported tag
|
|
@@ -265,79 +338,117 @@ function emitNode(node: TemplateNode, builder: Builder): void {
|
|
|
265
338
|
builder.expr(prop.code, prop.loc)
|
|
266
339
|
builder.raw(');\n')
|
|
267
340
|
}
|
|
268
|
-
node.children
|
|
269
|
-
emitNode(child, builder)
|
|
270
|
-
})
|
|
341
|
+
emitNodes(node.children, builder)
|
|
271
342
|
return
|
|
272
343
|
}
|
|
273
344
|
case 'if':
|
|
345
|
+
/* Reached only for an `if` emitted outside a sibling list (none today);
|
|
346
|
+
`emitNodes` owns the `if`/`else` fusion. Emit without an else. */
|
|
274
347
|
builder.raw('if ')
|
|
275
348
|
builder.expr(node.condition, node.loc)
|
|
276
349
|
builder.raw(' {\n')
|
|
277
|
-
node.children
|
|
278
|
-
emitNode(child, builder)
|
|
279
|
-
})
|
|
350
|
+
emitNodes(node.children, builder)
|
|
280
351
|
builder.raw('}\n')
|
|
281
352
|
return
|
|
282
353
|
case 'each':
|
|
283
|
-
|
|
354
|
+
/* `for await` over an async each's AsyncIterable, plain `for…of` otherwise —
|
|
355
|
+
so the item binds to the element type under either iteration protocol. */
|
|
356
|
+
builder.raw(
|
|
357
|
+
node.async ? `for await (const ${node.as} of ` : `for (const ${node.as} of `,
|
|
358
|
+
)
|
|
284
359
|
builder.expr(node.items, node.loc)
|
|
285
360
|
builder.raw(') {\n')
|
|
286
361
|
if (node.key !== undefined) {
|
|
287
362
|
builder.raw(`void (${node.key});\n`)
|
|
288
363
|
}
|
|
289
|
-
node.children
|
|
290
|
-
emitNode(child, builder)
|
|
291
|
-
})
|
|
364
|
+
emitNodes(node.children, builder)
|
|
292
365
|
builder.raw('}\n')
|
|
293
366
|
return
|
|
294
|
-
case 'await':
|
|
367
|
+
case 'await': {
|
|
368
|
+
/* Resolve once into a shadow-local; `then` binds it (carrying the awaited
|
|
369
|
+
type so resolved-content props are checked), `catch` binds the error as
|
|
370
|
+
`any` (statically unknowable), `finally` binds nothing. Blocking: the
|
|
371
|
+
non-branch children are the resolved content, bound to `as`. Streaming:
|
|
372
|
+
they're the pending content, checked without the resolved value. */
|
|
373
|
+
const resolved = builder.unique('awaited')
|
|
295
374
|
builder.raw('{\n')
|
|
296
|
-
builder.raw(
|
|
375
|
+
builder.raw(`const ${resolved} = await `)
|
|
297
376
|
builder.expr(node.promise, node.loc)
|
|
298
|
-
builder.raw(
|
|
299
|
-
node.children.
|
|
300
|
-
|
|
301
|
-
|
|
377
|
+
builder.raw(`;\nvoid ${resolved};\n`)
|
|
378
|
+
const pending = node.children.filter((child) => child.kind !== 'branch')
|
|
379
|
+
const branches = node.children.filter((child) => child.kind === 'branch')
|
|
380
|
+
if (node.blocking && node.as !== undefined) {
|
|
381
|
+
builder.raw(`{\nconst ${node.as} = ${resolved};\n`)
|
|
382
|
+
emitNodes(pending, builder)
|
|
383
|
+
builder.raw('}\n')
|
|
384
|
+
} else {
|
|
385
|
+
emitNodes(pending, builder)
|
|
386
|
+
}
|
|
387
|
+
for (const branch of branches) {
|
|
388
|
+
if (branch.kind !== 'branch') {
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
builder.raw('{\n')
|
|
392
|
+
if (branch.branch === 'then' && branch.as !== undefined) {
|
|
393
|
+
builder.raw(`const ${branch.as} = ${resolved};\n`)
|
|
394
|
+
} else if (branch.branch === 'catch' && branch.as !== undefined) {
|
|
395
|
+
builder.raw(`const ${branch.as} = undefined as any;\n`)
|
|
396
|
+
}
|
|
397
|
+
emitNodes(branch.children, builder)
|
|
398
|
+
builder.raw('}\n')
|
|
399
|
+
}
|
|
302
400
|
builder.raw('}\n')
|
|
303
401
|
return
|
|
402
|
+
}
|
|
304
403
|
case 'switch':
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
404
|
+
/* A real `switch` so a discriminant subject narrows into each case body;
|
|
405
|
+
non-case children (whitespace between cases) carry nothing and are
|
|
406
|
+
skipped. `break` keeps cases independent under `noFallthroughCasesInSwitch`. */
|
|
407
|
+
builder.raw('switch (')
|
|
408
|
+
builder.expr(node.subject, node.loc)
|
|
409
|
+
builder.raw(') {\n')
|
|
410
|
+
for (const child of node.children) {
|
|
411
|
+
if (child.kind !== 'case') {
|
|
412
|
+
continue
|
|
413
|
+
}
|
|
414
|
+
if (child.match !== undefined) {
|
|
415
|
+
builder.raw('case ')
|
|
416
|
+
builder.expr(child.match, child.loc)
|
|
417
|
+
builder.raw(': {\n')
|
|
418
|
+
} else {
|
|
419
|
+
builder.raw('default: {\n')
|
|
420
|
+
}
|
|
421
|
+
emitNodes(child.children, builder)
|
|
422
|
+
builder.raw('break;\n}\n')
|
|
423
|
+
}
|
|
424
|
+
builder.raw('}\n')
|
|
309
425
|
return
|
|
310
426
|
case 'case':
|
|
427
|
+
/* Reached only for a stray case outside a switch/if-else (none today); a
|
|
428
|
+
`switch` emits its own cases and `emitNodes` consumes an `else`. */
|
|
311
429
|
if (node.match !== undefined) {
|
|
312
430
|
builder.stmt(node.match, node.loc)
|
|
313
431
|
}
|
|
314
|
-
node.children
|
|
315
|
-
emitNode(child, builder)
|
|
316
|
-
})
|
|
432
|
+
emitNodes(node.children, builder)
|
|
317
433
|
return
|
|
318
434
|
case 'branch':
|
|
319
|
-
/*
|
|
435
|
+
/* Reached only for a stray branch outside an await (none today); the await
|
|
436
|
+
handler binds resolved/error types for its own branch children. */
|
|
320
437
|
builder.raw('{\n')
|
|
321
438
|
if (node.as !== undefined) {
|
|
322
439
|
builder.raw(`const ${node.as} = undefined as any;\n`)
|
|
323
440
|
}
|
|
324
|
-
node.children
|
|
325
|
-
emitNode(child, builder)
|
|
326
|
-
})
|
|
441
|
+
emitNodes(node.children, builder)
|
|
327
442
|
builder.raw('}\n')
|
|
328
443
|
return
|
|
329
444
|
case 'try':
|
|
330
445
|
builder.raw('{\n')
|
|
331
|
-
node.children
|
|
332
|
-
emitNode(child, builder)
|
|
333
|
-
})
|
|
446
|
+
emitNodes(node.children, builder)
|
|
334
447
|
builder.raw('}\n')
|
|
335
448
|
return
|
|
336
449
|
case 'snippet':
|
|
337
450
|
builder.raw(`const ${node.name} = (${node.params ?? ''}) => {\n`)
|
|
338
|
-
node.children
|
|
339
|
-
emitNode(child, builder)
|
|
340
|
-
})
|
|
451
|
+
emitNodes(node.children, builder)
|
|
341
452
|
builder.raw('};\n')
|
|
342
453
|
return
|
|
343
454
|
case 'script':
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolve } from 'node:path'
|
|
2
2
|
import ts from 'typescript'
|
|
3
|
+
import { messageFromError } from '../../shared/messageFromError.ts'
|
|
3
4
|
import { assetModulesFile } from './assetModulesFile.ts'
|
|
4
5
|
import { compileShadow } from './compileShadow.ts'
|
|
5
6
|
import { loadShadowTsConfig } from './loadShadowTsConfig.ts'
|
|
@@ -59,7 +60,7 @@ export function createShadowLanguageService(cwd: string): ShadowLanguageService
|
|
|
59
60
|
return compiled.code
|
|
60
61
|
} catch (error) {
|
|
61
62
|
shadows.set(abidePath, { code: '', mappings: [] })
|
|
62
|
-
parseErrors.set(abidePath,
|
|
63
|
+
parseErrors.set(abidePath, messageFromError(error))
|
|
63
64
|
return 'export default function (): void {}\n'
|
|
64
65
|
}
|
|
65
66
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolve } from 'node:path'
|
|
2
2
|
import ts from 'typescript'
|
|
3
|
+
import { messageFromError } from '../../shared/messageFromError.ts'
|
|
3
4
|
import { assetModulesFile } from './assetModulesFile.ts'
|
|
4
5
|
import { compileShadow } from './compileShadow.ts'
|
|
5
6
|
import { loadShadowTsConfig } from './loadShadowTsConfig.ts'
|
|
@@ -51,7 +52,7 @@ export function createShadowProgram(cwd: string, abidePaths?: string[]): ShadowP
|
|
|
51
52
|
return compiled.code
|
|
52
53
|
} catch (error) {
|
|
53
54
|
shadows.set(abidePath, { code: '', mappings: [] })
|
|
54
|
-
parseErrors.set(abidePath,
|
|
55
|
+
parseErrors.set(abidePath, messageFromError(error))
|
|
55
56
|
return 'export default function (): void {}\n'
|
|
56
57
|
}
|
|
57
58
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ts from 'typescript'
|
|
2
|
+
import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
|
|
2
3
|
import { renameSignalRefs } from './renameSignalRefs.ts'
|
|
3
4
|
|
|
4
5
|
/*
|
|
@@ -9,14 +10,16 @@ declares reactive state as signals:
|
|
|
9
10
|
let items = state([])
|
|
10
11
|
const total = derived(() => count + items.length)
|
|
11
12
|
|
|
12
|
-
This collects the
|
|
13
|
+
This collects the binding names, turns each plain `state(initial)` declaration
|
|
13
14
|
into an initialising assignment on a shared `model` document (in source order, so
|
|
14
|
-
a later state can read an earlier one), keeps
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
`
|
|
15
|
+
a later state can read an earlier one), keeps the rest, then renames every
|
|
16
|
+
reference through `renameSignalRefs`. Plain `state` becomes `model.x` access that
|
|
17
|
+
`lowerDocAccess` lowers to patches/reads — the document substrate's deep,
|
|
18
|
+
fine-grained, serializable reactivity for free. `linked`, `derived`, and
|
|
19
|
+
`state(initial, transform)` stay verbatim as runtime `.value` cells (referenced
|
|
20
|
+
as `name.value`): they own no doc slot, so they reseed/recompute on resume rather
|
|
21
|
+
than serialize. No reactive declarations → the script is returned untouched (the
|
|
22
|
+
explicit `const model = doc(...)` form still works).
|
|
20
23
|
*/
|
|
21
24
|
export function desugarSignals(scriptBody: string): {
|
|
22
25
|
code: string
|
|
@@ -32,13 +35,16 @@ export function desugarSignals(scriptBody: string): {
|
|
|
32
35
|
}
|
|
33
36
|
for (const declaration of statement.declarationList.declarations) {
|
|
34
37
|
const callee = signalCallee(declaration)
|
|
35
|
-
if (
|
|
38
|
+
if (!ts.isIdentifier(declaration.name)) {
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (isPlainStateSlot(declaration)) {
|
|
42
|
+
/* Plain `state(initial)` → a serializable `model` doc slot. */
|
|
36
43
|
stateNames.add(declaration.name.text)
|
|
37
|
-
} else if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
/* A prop reads like a derived (read-only); both are referenced as `.value`. */
|
|
44
|
+
} else if (callee !== undefined && REACTIVE_CALLEES.has(callee)) {
|
|
45
|
+
/* `.value` cells, referenced as `name.value`: `linked`, `derived`, a
|
|
46
|
+
`prop` (reads like a read-only derived), and `state(initial, transform)`
|
|
47
|
+
(the transform must run on writes, so it can't be a bare doc slot). */
|
|
42
48
|
derivedNames.add(declaration.name.text)
|
|
43
49
|
}
|
|
44
50
|
}
|
|
@@ -70,6 +76,24 @@ export function desugarSignals(scriptBody: string): {
|
|
|
70
76
|
}
|
|
71
77
|
}
|
|
72
78
|
|
|
79
|
+
/* True for `state(initial, transform)` — the write-coercion transform forces a
|
|
80
|
+
`.value` cell (writes run it) rather than a bare, serializable doc slot. */
|
|
81
|
+
function hasTransform(declaration: ts.VariableDeclaration): boolean {
|
|
82
|
+
const initializer = declaration.initializer
|
|
83
|
+
return (
|
|
84
|
+
initializer !== undefined &&
|
|
85
|
+
ts.isCallExpression(initializer) &&
|
|
86
|
+
initializer.arguments.length >= 2
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* A plain `state(initial)` with no transform → a serializable `model` doc slot;
|
|
91
|
+
every other reactive declaration is a `.value` cell. The one rule shared by the
|
|
92
|
+
name-collection pass and the slot lowering. */
|
|
93
|
+
function isPlainStateSlot(declaration: ts.VariableDeclaration): boolean {
|
|
94
|
+
return signalCallee(declaration) === 'state' && !hasTransform(declaration)
|
|
95
|
+
}
|
|
96
|
+
|
|
73
97
|
/* The callee name of a `NAME = state(...)` / `derived(...)` declaration, else undefined. */
|
|
74
98
|
function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
|
|
75
99
|
const initializer = declaration.initializer
|
|
@@ -119,7 +143,10 @@ function stateDeclarationAssignments(
|
|
|
119
143
|
}
|
|
120
144
|
const assignments: string[] = []
|
|
121
145
|
for (const declaration of statement.declarationList.declarations) {
|
|
122
|
-
if (
|
|
146
|
+
if (!ts.isIdentifier(declaration.name) || !isPlainStateSlot(declaration)) {
|
|
147
|
+
/* Only a plain `state(initial)` becomes a slot; `state(initial, transform)`
|
|
148
|
+
(and everything else) is a `.value` cell — print it verbatim so the
|
|
149
|
+
runtime call (and its transform) survives. */
|
|
123
150
|
return undefined
|
|
124
151
|
}
|
|
125
152
|
const initial = (declaration.initializer as ts.CallExpression).arguments[0]
|