@abide/abide 0.30.0 → 0.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -3
- package/CHANGELOG.md +26 -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 +146 -49
- 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 +1 -1
- package/src/lib/ui/state.ts +9 -2
- package/template/src/ui/pages/page.abide +1 -1
|
@@ -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
|
/*
|
|
@@ -66,9 +68,7 @@ export function compileShadow(source: string): CompiledShadow {
|
|
|
66
68
|
for (const line of scope) {
|
|
67
69
|
builder.flush(line)
|
|
68
70
|
}
|
|
69
|
-
|
|
70
|
-
emitNode(node, builder)
|
|
71
|
-
}
|
|
71
|
+
emitNodes(parseTemplate(source.slice(templateStart), templateStart).nodes, builder)
|
|
72
72
|
builder.raw('}\n')
|
|
73
73
|
return builder.result()
|
|
74
74
|
}
|
|
@@ -82,6 +82,9 @@ type Builder = {
|
|
|
82
82
|
expr: (code: string, sourceLoc: number | undefined) => void
|
|
83
83
|
stmt: (code: string, sourceLoc: number | undefined) => void
|
|
84
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
|
|
85
88
|
result: () => CompiledShadow
|
|
86
89
|
}
|
|
87
90
|
|
|
@@ -91,8 +94,12 @@ type ScopeLine = { text: string; segments: ShadowMapping[] }
|
|
|
91
94
|
|
|
92
95
|
function createBuilder(): Builder {
|
|
93
96
|
let code = ''
|
|
97
|
+
let uniqueCounter = 0
|
|
94
98
|
const mappings: ShadowMapping[] = []
|
|
95
99
|
const builder: Builder = {
|
|
100
|
+
unique(base) {
|
|
101
|
+
return `__${base}_${uniqueCounter++}`
|
|
102
|
+
},
|
|
96
103
|
raw(text) {
|
|
97
104
|
code += text
|
|
98
105
|
},
|
|
@@ -189,14 +196,15 @@ function reactiveDeclarations(statement: ts.Statement): ts.VariableDeclaration[]
|
|
|
189
196
|
return reactive.length === declarations.length && reactive.length > 0 ? reactive : undefined
|
|
190
197
|
}
|
|
191
198
|
|
|
192
|
-
/* The callee name of a `NAME = state(...)` / `
|
|
199
|
+
/* The callee name of a `NAME = state(...)` / `linked(...)` / `derived(...)` /
|
|
200
|
+
`prop(...)` decl. */
|
|
193
201
|
function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
|
|
194
202
|
const initializer = declaration.initializer
|
|
195
203
|
if (
|
|
196
204
|
initializer !== undefined &&
|
|
197
205
|
ts.isCallExpression(initializer) &&
|
|
198
206
|
ts.isIdentifier(initializer.expression) &&
|
|
199
|
-
|
|
207
|
+
REACTIVE_CALLEES.has(initializer.expression.text)
|
|
200
208
|
) {
|
|
201
209
|
return initializer.expression.text
|
|
202
210
|
}
|
|
@@ -226,9 +234,10 @@ function scopeLineFor(
|
|
|
226
234
|
const prefix = `let ${name}${annotation} = (`
|
|
227
235
|
return { text: `${prefix}${verbatim(init)});`, segments: [span(init, prefix.length)] }
|
|
228
236
|
}
|
|
229
|
-
if (callee === 'derived') {
|
|
230
|
-
/* derived<T>(compute): T is the value type —
|
|
231
|
-
|
|
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. */
|
|
232
241
|
const typeNode = call.typeArguments?.[0]
|
|
233
242
|
const annotation = typeNode === undefined ? '' : `: ${verbatim(typeNode)}`
|
|
234
243
|
const fn = call.arguments[0]
|
|
@@ -248,9 +257,56 @@ function scopeLineFor(
|
|
|
248
257
|
return { text: `let ${name} = props[${JSON.stringify(keyText)}];`, segments: [] }
|
|
249
258
|
}
|
|
250
259
|
|
|
251
|
-
/* Emits a
|
|
252
|
-
|
|
253
|
-
|
|
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. */
|
|
254
310
|
function emitNode(node: TemplateNode, builder: Builder): void {
|
|
255
311
|
switch (node.kind) {
|
|
256
312
|
case 'text':
|
|
@@ -266,9 +322,7 @@ function emitNode(node: TemplateNode, builder: Builder): void {
|
|
|
266
322
|
builder.stmt(attr.code, attr.loc)
|
|
267
323
|
}
|
|
268
324
|
}
|
|
269
|
-
node.children
|
|
270
|
-
emitNode(child, builder)
|
|
271
|
-
})
|
|
325
|
+
emitNodes(node.children, builder)
|
|
272
326
|
return
|
|
273
327
|
case 'component': {
|
|
274
328
|
/* Check each prop against the child's declared type. The imported tag
|
|
@@ -278,85 +332,128 @@ function emitNode(node: TemplateNode, builder: Builder): void {
|
|
|
278
332
|
offending expression (an annotated target reports the error on the RHS,
|
|
279
333
|
unlike an object literal which reports it on the key). */
|
|
280
334
|
for (const prop of node.props) {
|
|
335
|
+
/* Lead with a defensive `;`: this IIFE is the one shadow emission that
|
|
336
|
+
starts with `(`, so without it a preceding scope statement left
|
|
337
|
+
unterminated (a script ending in a call with no trailing semicolon,
|
|
338
|
+
e.g. `effect(() => …)`) merges across the newline into `effect(…)(…)`
|
|
339
|
+
— a spurious "not callable" on the author's last statement. */
|
|
281
340
|
builder.raw(
|
|
282
|
-
|
|
341
|
+
`;((__prop: Parameters<typeof ${node.name}>[0][${JSON.stringify(prop.name)}]) => {})(`,
|
|
283
342
|
)
|
|
284
343
|
builder.expr(prop.code, prop.loc)
|
|
285
344
|
builder.raw(');\n')
|
|
286
345
|
}
|
|
287
|
-
node.children
|
|
288
|
-
emitNode(child, builder)
|
|
289
|
-
})
|
|
346
|
+
emitNodes(node.children, builder)
|
|
290
347
|
return
|
|
291
348
|
}
|
|
292
349
|
case 'if':
|
|
350
|
+
/* Reached only for an `if` emitted outside a sibling list (none today);
|
|
351
|
+
`emitNodes` owns the `if`/`else` fusion. Emit without an else. */
|
|
293
352
|
builder.raw('if ')
|
|
294
353
|
builder.expr(node.condition, node.loc)
|
|
295
354
|
builder.raw(' {\n')
|
|
296
|
-
node.children
|
|
297
|
-
emitNode(child, builder)
|
|
298
|
-
})
|
|
355
|
+
emitNodes(node.children, builder)
|
|
299
356
|
builder.raw('}\n')
|
|
300
357
|
return
|
|
301
358
|
case 'each':
|
|
302
|
-
|
|
359
|
+
/* `for await` over an async each's AsyncIterable, plain `for…of` otherwise —
|
|
360
|
+
so the item binds to the element type under either iteration protocol. */
|
|
361
|
+
builder.raw(
|
|
362
|
+
node.async ? `for await (const ${node.as} of ` : `for (const ${node.as} of `,
|
|
363
|
+
)
|
|
303
364
|
builder.expr(node.items, node.loc)
|
|
304
365
|
builder.raw(') {\n')
|
|
305
366
|
if (node.key !== undefined) {
|
|
306
367
|
builder.raw(`void (${node.key});\n`)
|
|
307
368
|
}
|
|
308
|
-
node.children
|
|
309
|
-
emitNode(child, builder)
|
|
310
|
-
})
|
|
369
|
+
emitNodes(node.children, builder)
|
|
311
370
|
builder.raw('}\n')
|
|
312
371
|
return
|
|
313
|
-
case 'await':
|
|
372
|
+
case 'await': {
|
|
373
|
+
/* Resolve once into a shadow-local; `then` binds it (carrying the awaited
|
|
374
|
+
type so resolved-content props are checked), `catch` binds the error as
|
|
375
|
+
`any` (statically unknowable), `finally` binds nothing. Blocking: the
|
|
376
|
+
non-branch children are the resolved content, bound to `as`. Streaming:
|
|
377
|
+
they're the pending content, checked without the resolved value. */
|
|
378
|
+
const resolved = builder.unique('awaited')
|
|
314
379
|
builder.raw('{\n')
|
|
315
|
-
builder.raw(
|
|
380
|
+
builder.raw(`const ${resolved} = await `)
|
|
316
381
|
builder.expr(node.promise, node.loc)
|
|
317
|
-
builder.raw(
|
|
318
|
-
node.children.
|
|
319
|
-
|
|
320
|
-
|
|
382
|
+
builder.raw(`;\nvoid ${resolved};\n`)
|
|
383
|
+
const pending = node.children.filter((child) => child.kind !== 'branch')
|
|
384
|
+
const branches = node.children.filter((child) => child.kind === 'branch')
|
|
385
|
+
if (node.blocking && node.as !== undefined) {
|
|
386
|
+
builder.raw(`{\nconst ${node.as} = ${resolved};\n`)
|
|
387
|
+
emitNodes(pending, builder)
|
|
388
|
+
builder.raw('}\n')
|
|
389
|
+
} else {
|
|
390
|
+
emitNodes(pending, builder)
|
|
391
|
+
}
|
|
392
|
+
for (const branch of branches) {
|
|
393
|
+
if (branch.kind !== 'branch') {
|
|
394
|
+
continue
|
|
395
|
+
}
|
|
396
|
+
builder.raw('{\n')
|
|
397
|
+
if (branch.branch === 'then' && branch.as !== undefined) {
|
|
398
|
+
builder.raw(`const ${branch.as} = ${resolved};\n`)
|
|
399
|
+
} else if (branch.branch === 'catch' && branch.as !== undefined) {
|
|
400
|
+
builder.raw(`const ${branch.as} = undefined as any;\n`)
|
|
401
|
+
}
|
|
402
|
+
emitNodes(branch.children, builder)
|
|
403
|
+
builder.raw('}\n')
|
|
404
|
+
}
|
|
321
405
|
builder.raw('}\n')
|
|
322
406
|
return
|
|
407
|
+
}
|
|
323
408
|
case 'switch':
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
409
|
+
/* A real `switch` so a discriminant subject narrows into each case body;
|
|
410
|
+
non-case children (whitespace between cases) carry nothing and are
|
|
411
|
+
skipped. `break` keeps cases independent under `noFallthroughCasesInSwitch`. */
|
|
412
|
+
builder.raw('switch (')
|
|
413
|
+
builder.expr(node.subject, node.loc)
|
|
414
|
+
builder.raw(') {\n')
|
|
415
|
+
for (const child of node.children) {
|
|
416
|
+
if (child.kind !== 'case') {
|
|
417
|
+
continue
|
|
418
|
+
}
|
|
419
|
+
if (child.match !== undefined) {
|
|
420
|
+
builder.raw('case ')
|
|
421
|
+
builder.expr(child.match, child.loc)
|
|
422
|
+
builder.raw(': {\n')
|
|
423
|
+
} else {
|
|
424
|
+
builder.raw('default: {\n')
|
|
425
|
+
}
|
|
426
|
+
emitNodes(child.children, builder)
|
|
427
|
+
builder.raw('break;\n}\n')
|
|
428
|
+
}
|
|
429
|
+
builder.raw('}\n')
|
|
328
430
|
return
|
|
329
431
|
case 'case':
|
|
432
|
+
/* Reached only for a stray case outside a switch/if-else (none today); a
|
|
433
|
+
`switch` emits its own cases and `emitNodes` consumes an `else`. */
|
|
330
434
|
if (node.match !== undefined) {
|
|
331
435
|
builder.stmt(node.match, node.loc)
|
|
332
436
|
}
|
|
333
|
-
node.children
|
|
334
|
-
emitNode(child, builder)
|
|
335
|
-
})
|
|
437
|
+
emitNodes(node.children, builder)
|
|
336
438
|
return
|
|
337
439
|
case 'branch':
|
|
338
|
-
/*
|
|
440
|
+
/* Reached only for a stray branch outside an await (none today); the await
|
|
441
|
+
handler binds resolved/error types for its own branch children. */
|
|
339
442
|
builder.raw('{\n')
|
|
340
443
|
if (node.as !== undefined) {
|
|
341
444
|
builder.raw(`const ${node.as} = undefined as any;\n`)
|
|
342
445
|
}
|
|
343
|
-
node.children
|
|
344
|
-
emitNode(child, builder)
|
|
345
|
-
})
|
|
446
|
+
emitNodes(node.children, builder)
|
|
346
447
|
builder.raw('}\n')
|
|
347
448
|
return
|
|
348
449
|
case 'try':
|
|
349
450
|
builder.raw('{\n')
|
|
350
|
-
node.children
|
|
351
|
-
emitNode(child, builder)
|
|
352
|
-
})
|
|
451
|
+
emitNodes(node.children, builder)
|
|
353
452
|
builder.raw('}\n')
|
|
354
453
|
return
|
|
355
454
|
case 'snippet':
|
|
356
455
|
builder.raw(`const ${node.name} = (${node.params ?? ''}) => {\n`)
|
|
357
|
-
node.children
|
|
358
|
-
emitNode(child, builder)
|
|
359
|
-
})
|
|
456
|
+
emitNodes(node.children, builder)
|
|
360
457
|
builder.raw('};\n')
|
|
361
458
|
return
|
|
362
459
|
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]
|
|
@@ -2,6 +2,7 @@ import { decodeHtmlEntities } from './decodeHtmlEntities.ts'
|
|
|
2
2
|
import type { TemplateAttr } from './types/TemplateAttr.ts'
|
|
3
3
|
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
4
4
|
import type { TextPart } from './types/TextPart.ts'
|
|
5
|
+
import { VOID_TAGS } from './VOID_TAGS.ts'
|
|
5
6
|
|
|
6
7
|
/*
|
|
7
8
|
A minimal compile-time parser for the abide template subset: elements, text with
|
|
@@ -19,23 +20,6 @@ text, never mistaken for a real style. Keeping it in the tree lets the front-end
|
|
|
19
20
|
scope it to its sibling subtree (`analyzeComponent`); the node emits no DOM/markup.
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
|
-
const VOID_TAGS = new Set([
|
|
23
|
-
'area',
|
|
24
|
-
'base',
|
|
25
|
-
'br',
|
|
26
|
-
'col',
|
|
27
|
-
'embed',
|
|
28
|
-
'hr',
|
|
29
|
-
'img',
|
|
30
|
-
'input',
|
|
31
|
-
'link',
|
|
32
|
-
'meta',
|
|
33
|
-
'param',
|
|
34
|
-
'source',
|
|
35
|
-
'track',
|
|
36
|
-
'wbr',
|
|
37
|
-
])
|
|
38
|
-
|
|
39
23
|
/* A braced template expression with the absolute source offset of its first
|
|
40
24
|
(post-trim) character, so the type-checking shadow can map a diagnostic back. */
|
|
41
25
|
type Braced = { code: string; loc: number }
|
|
@@ -245,18 +229,29 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
245
229
|
return { nodes: roots }
|
|
246
230
|
}
|
|
247
231
|
|
|
248
|
-
/* Turns a component's attributes into props
|
|
249
|
-
|
|
232
|
+
/* Turns a component's attributes into props. A component has no directives —
|
|
233
|
+
every attribute is a prop under its written name, so `on*`/`bind:`/`attach`
|
|
234
|
+
round-trip to their original names (the kinds the tag-blind attribute parser
|
|
235
|
+
assigned) instead of being dropped. A static value becomes a string literal;
|
|
236
|
+
every other kind keeps its `code`, letting a prop hold any value, functions
|
|
237
|
+
included (e.g. an `onclick` callback). */
|
|
250
238
|
function toProps(attrs: TemplateAttr[]): { name: string; code: string; loc?: number }[] {
|
|
251
|
-
|
|
252
|
-
for (const attr of attrs) {
|
|
239
|
+
return attrs.map((attr) => {
|
|
253
240
|
if (attr.kind === 'static') {
|
|
254
|
-
|
|
255
|
-
} else if (attr.kind === 'expression') {
|
|
256
|
-
props.push({ name: attr.name, code: attr.code, loc: attr.loc })
|
|
241
|
+
return { name: attr.name, code: JSON.stringify(attr.value) }
|
|
257
242
|
}
|
|
258
|
-
|
|
259
|
-
|
|
243
|
+
/* Every non-static kind keeps its `code`/`loc`; only the prop name differs —
|
|
244
|
+
a directive (`event`/`bind`/`attach`) round-trips to its written name. */
|
|
245
|
+
const name =
|
|
246
|
+
attr.kind === 'event'
|
|
247
|
+
? `on${attr.event}`
|
|
248
|
+
: attr.kind === 'bind'
|
|
249
|
+
? `bind:${attr.property}`
|
|
250
|
+
: attr.kind === 'attach'
|
|
251
|
+
? 'attach'
|
|
252
|
+
: attr.name
|
|
253
|
+
return { name, code: attr.code, loc: attr.loc }
|
|
254
|
+
})
|
|
260
255
|
}
|
|
261
256
|
|
|
262
257
|
/* The literal text of an attribute (a static value or an expression's code);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import ts from 'typescript'
|
|
2
|
+
import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
5
|
The signal binding names a `<script>` nested in a control-flow branch declares
|
|
5
|
-
(`state`/`derived`/`prop`). The back-end adds them to the deref scope so both the
|
|
6
|
+
(`state`/`linked`/`derived`/`prop`). The back-end adds them to the deref scope so both the
|
|
6
7
|
script body and the branch's markup rewrite `{a}` → `a.value` — these stay PLAIN
|
|
7
8
|
signals (local to the branch's render, owned by its scope, re-seeded from the
|
|
8
9
|
in-scope data each mount), unlike the top-level component script which desugars to
|
|
@@ -18,7 +19,8 @@ export function nestedBindingNames(code: string): Set<string> {
|
|
|
18
19
|
for (const declaration of statement.declarationList.declarations) {
|
|
19
20
|
const callee = signalCallee(declaration)
|
|
20
21
|
if (
|
|
21
|
-
|
|
22
|
+
callee !== undefined &&
|
|
23
|
+
REACTIVE_CALLEES.has(callee) &&
|
|
22
24
|
ts.isIdentifier(declaration.name)
|
|
23
25
|
) {
|
|
24
26
|
names.add(declaration.name.text)
|