@abide/abide 0.39.0 → 0.40.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 +2 -2
- package/CHANGELOG.md +47 -0
- package/package.json +1 -1
- package/src/lib/server/rpc/parseArgs.ts +10 -1
- package/src/lib/server/rpc/runWithVerbTimeout.ts +7 -9
- package/src/lib/server/rpc/types/VerbHelper.ts +18 -21
- package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +8 -7
- package/src/lib/server/sockets/createSocketDispatcher.ts +12 -3
- package/src/lib/server/sockets/defineSocket.ts +3 -1
- package/src/lib/shared/REF_JSON_HEADER.ts +9 -0
- package/src/lib/shared/REF_JSON_TAGS.ts +31 -0
- package/src/lib/shared/buildRpcRequest.ts +8 -1
- package/src/lib/shared/decodeRefJson.ts +110 -0
- package/src/lib/shared/encodeRefJson.ts +106 -0
- package/src/lib/test/createTestSocketChannel.ts +6 -2
- package/src/lib/ui/compile/compileShadow.ts +14 -7
- package/src/lib/ui/compile/generateBuild.ts +41 -58
- package/src/lib/ui/compile/generateSSR.ts +23 -21
- package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
- package/src/lib/ui/compile/parseTemplate.ts +74 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +12 -0
- package/src/lib/ui/compile/resolveBranches.ts +21 -0
- package/src/lib/ui/compile/types/TemplateNode.ts +12 -2
- package/src/lib/ui/createScope.ts +14 -0
- package/src/lib/ui/dom/appendText.ts +2 -3
- package/src/lib/ui/dom/appendTextAt.ts +2 -3
- package/src/lib/ui/dom/applyResolved.ts +5 -8
- package/src/lib/ui/dom/awaitBlock.ts +14 -1
- package/src/lib/ui/dom/on.ts +7 -0
- package/src/lib/ui/dom/parseRawNodes.ts +17 -0
- package/src/lib/ui/navigate.ts +28 -3
- package/src/lib/ui/renderToStream.ts +17 -9
- package/src/lib/ui/resumeSeedScript.ts +16 -6
- package/src/lib/ui/router.ts +1 -1
- package/src/lib/ui/runtime/RESUME.ts +13 -6
- package/src/lib/ui/runtime/localStoragePersistence.ts +14 -8
- package/src/lib/ui/socketChannel.ts +5 -2
- package/src/lib/ui/tryEncodeResume.ts +20 -0
- package/src/lib/ui/types/Scope.ts +9 -3
|
@@ -5,7 +5,9 @@ import { bindListenEvent } from './bindListenEvent.ts'
|
|
|
5
5
|
import { composeProps } from './composeProps.ts'
|
|
6
6
|
import { groupBindParts } from './groupBindParts.ts'
|
|
7
7
|
import { isControlFlow } from './isControlFlow.ts'
|
|
8
|
+
import { isWhitespaceText } from './isWhitespaceText.ts'
|
|
8
9
|
import { lowerContext } from './lowerContext.ts'
|
|
10
|
+
import { resolveBranches } from './resolveBranches.ts'
|
|
9
11
|
import { scopeAttr } from './scopeAttr.ts'
|
|
10
12
|
import { skeletonContext } from './skeletonContext.ts'
|
|
11
13
|
import { spreadExcludedNames } from './spreadExcludedNames.ts'
|
|
@@ -477,27 +479,25 @@ export function generateBuild(
|
|
|
477
479
|
parentVar: string,
|
|
478
480
|
before: string,
|
|
479
481
|
): string {
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
482
|
+
const [thenBranch, catchBranch, finallyBranch] = resolveBranches(
|
|
483
|
+
node,
|
|
484
|
+
'then',
|
|
485
|
+
'catch',
|
|
486
|
+
'finally',
|
|
487
|
+
)
|
|
488
|
+
const finallyChildren = finallyBranch?.children ?? []
|
|
484
489
|
/* Blocking: no pending, the children are the resolved branch bound to `node.as`.
|
|
485
490
|
Streaming: pending is the non-branch children, resolved is the `then` child. */
|
|
486
491
|
const pending = node.blocking
|
|
487
492
|
? []
|
|
488
493
|
: node.children.filter((child) => child.kind !== 'branch')
|
|
489
|
-
const thenBranch = node.children.find(isBranch('then'))
|
|
490
494
|
const thenThunk = node.blocking
|
|
491
495
|
? branchThunk(
|
|
492
496
|
node.children.filter((child) => child.kind !== 'branch'),
|
|
493
497
|
node.as ?? '_value',
|
|
494
498
|
finallyChildren,
|
|
495
499
|
)
|
|
496
|
-
: branchThunk(
|
|
497
|
-
branchChildren(thenBranch),
|
|
498
|
-
branchVar(thenBranch) ?? '_value',
|
|
499
|
-
finallyChildren,
|
|
500
|
-
)
|
|
500
|
+
: branchThunk(thenBranch?.children ?? [], thenBranch?.as ?? '_value', finallyChildren)
|
|
501
501
|
/* Neither catch nor finally → pass `undefined` so awaitBlock re-throws the
|
|
502
502
|
rejection (surfacing it) instead of rendering an empty branch. A finally-only
|
|
503
503
|
block keeps a catch thunk that renders just finally. */
|
|
@@ -505,8 +505,8 @@ export function generateBuild(
|
|
|
505
505
|
catchBranch === undefined && finallyChildren.length === 0
|
|
506
506
|
? 'undefined'
|
|
507
507
|
: branchThunk(
|
|
508
|
-
|
|
509
|
-
|
|
508
|
+
catchBranch?.children ?? [],
|
|
509
|
+
catchBranch?.as ?? '_error',
|
|
510
510
|
finallyChildren,
|
|
511
511
|
)
|
|
512
512
|
const pendingThunk = hasRenderableContent(pending) ? branchThunk(pending) : 'undefined'
|
|
@@ -518,16 +518,6 @@ export function generateBuild(
|
|
|
518
518
|
)
|
|
519
519
|
}
|
|
520
520
|
|
|
521
|
-
/* Children of a branch node (then/catch), or [] when the branch is absent. */
|
|
522
|
-
function branchChildren(branch: TemplateNode | undefined): TemplateNode[] {
|
|
523
|
-
return branch !== undefined && branch.kind === 'branch' ? branch.children : []
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/* The value/error variable name a branch binds, if any. */
|
|
527
|
-
function branchVar(branch: TemplateNode | undefined): string | undefined {
|
|
528
|
-
return branch !== undefined && branch.kind === 'branch' ? branch.as : undefined
|
|
529
|
-
}
|
|
530
|
-
|
|
531
521
|
/* A branch's content as a void render thunk `(parent[, value]) => void` that
|
|
532
522
|
builds its children — and an optional trailing `finally` branch — into
|
|
533
523
|
`parent`. The full-range model tracks the built content between markers, so a
|
|
@@ -566,17 +556,6 @@ export function generateBuild(
|
|
|
566
556
|
)
|
|
567
557
|
}
|
|
568
558
|
|
|
569
|
-
/* The branch child of a control block matching `which` (then/catch/finally). */
|
|
570
|
-
function findBranch(
|
|
571
|
-
children: TemplateNode[],
|
|
572
|
-
which: 'then' | 'catch' | 'finally',
|
|
573
|
-
): Extract<TemplateNode, { kind: 'branch' }> | undefined {
|
|
574
|
-
return children.find(
|
|
575
|
-
(child): child is Extract<TemplateNode, { kind: 'branch' }> =>
|
|
576
|
-
child.kind === 'branch' && child.branch === which,
|
|
577
|
-
)
|
|
578
|
-
}
|
|
579
|
-
|
|
580
559
|
/* A sync error boundary: build the guarded subtree (++ finally); a throw while
|
|
581
560
|
building swaps to the catch branch (++ finally). No catch → `undefined`, which
|
|
582
561
|
makes the runtime re-throw to the nearest enclosing boundary. */
|
|
@@ -585,18 +564,14 @@ export function generateBuild(
|
|
|
585
564
|
parentVar: string,
|
|
586
565
|
before: string,
|
|
587
566
|
): string {
|
|
588
|
-
const catchBranch =
|
|
589
|
-
const finallyChildren =
|
|
567
|
+
const [catchBranch, finallyBranch] = resolveBranches(node, 'catch', 'finally')
|
|
568
|
+
const finallyChildren = finallyBranch?.children ?? []
|
|
590
569
|
const guarded = node.children.filter((child) => child.kind !== 'branch')
|
|
591
570
|
const tryThunk = branchThunk(guarded, undefined, finallyChildren)
|
|
592
571
|
const catchThunk =
|
|
593
572
|
catchBranch === undefined
|
|
594
573
|
? 'undefined'
|
|
595
|
-
: branchThunk(
|
|
596
|
-
branchChildren(catchBranch),
|
|
597
|
-
branchVar(catchBranch) ?? '_error',
|
|
598
|
-
finallyChildren,
|
|
599
|
-
)
|
|
574
|
+
: branchThunk(catchBranch.children, catchBranch.as ?? '_error', finallyChildren)
|
|
600
575
|
return `tryBlock(${parentVar}, nextBlockId(), ${tryThunk}, ${catchThunk}, ${before});\n`
|
|
601
576
|
}
|
|
602
577
|
|
|
@@ -607,13 +582,33 @@ export function generateBuild(
|
|
|
607
582
|
parentVar: string,
|
|
608
583
|
before: string,
|
|
609
584
|
): string {
|
|
610
|
-
|
|
585
|
+
/* The `case` children are the chain's `elseif`/`else` branches in source order;
|
|
586
|
+
the rest are the `then` content. */
|
|
587
|
+
const branches = node.children.filter(
|
|
611
588
|
(child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
|
|
612
589
|
)
|
|
613
590
|
const thenChildren = node.children.filter((child) => child.kind !== 'case')
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
591
|
+
const hasElseif = branches.some((branch) => branch.condition !== undefined)
|
|
592
|
+
/* Fast path: a plain `if` (with optional `else`) is the binary `when` runtime. */
|
|
593
|
+
if (!hasElseif) {
|
|
594
|
+
const elseBranch = branches.find((branch) => branch.condition === undefined)
|
|
595
|
+
const thenThunk = branchThunk(thenChildren)
|
|
596
|
+
const elseThunk =
|
|
597
|
+
elseBranch === undefined ? 'undefined' : branchThunk(elseBranch.children)
|
|
598
|
+
return `when(${parentVar}, () => (${lowerExpression(node.condition)}), ${thenThunk}, ${elseThunk}, ${before});\n`
|
|
599
|
+
}
|
|
600
|
+
/* if/elseif/else is a cond-chain — reuse `switchBlock` over a constant `true`
|
|
601
|
+
subject with `Boolean`-coerced match thunks, so the first truthy branch wins
|
|
602
|
+
(`else` is the match-less default). */
|
|
603
|
+
const entries = [
|
|
604
|
+
`{ match: () => Boolean(${lowerExpression(node.condition)}), render: ${branchThunk(thenChildren)} }`,
|
|
605
|
+
...branches.map((branch) =>
|
|
606
|
+
branch.condition !== undefined
|
|
607
|
+
? `{ match: () => Boolean(${lowerExpression(branch.condition)}), render: ${branchThunk(branch.children)} }`
|
|
608
|
+
: `{ match: undefined, render: ${branchThunk(branch.children)} }`,
|
|
609
|
+
),
|
|
610
|
+
]
|
|
611
|
+
return `switchBlock(${parentVar}, () => true, [${entries.join(', ')}], ${before});\n`
|
|
617
612
|
}
|
|
618
613
|
|
|
619
614
|
/* A keyed each. Each row is a content RANGE (any content, tracked between the
|
|
@@ -635,11 +630,9 @@ export function generateBuild(
|
|
|
635
630
|
optional `<template catch>` branch rendered (after the streamed rows) when the
|
|
636
631
|
iterator rejects. Absent → `undefined`, so the rejection surfaces instead. */
|
|
637
632
|
const fn = node.async ? 'eachAsync' : 'each'
|
|
638
|
-
const catchBranch = node
|
|
639
|
-
(child) => child.kind === 'branch' && child.branch === 'catch',
|
|
640
|
-
)
|
|
633
|
+
const [catchBranch] = resolveBranches(node, 'catch')
|
|
641
634
|
const catchArg = node.async
|
|
642
|
-
? `, ${catchBranch === undefined ? 'undefined' : branchThunk(
|
|
635
|
+
? `, ${catchBranch === undefined ? 'undefined' : branchThunk(catchBranch.children, catchBranch.as ?? '_error')}`
|
|
643
636
|
: ''
|
|
644
637
|
return (
|
|
645
638
|
`${fn}(${parentVar}, () => (${lowerExpression(node.items)}), ` +
|
|
@@ -650,16 +643,6 @@ export function generateBuild(
|
|
|
650
643
|
return generateChildren(rootNodes, hostVar)
|
|
651
644
|
}
|
|
652
645
|
|
|
653
|
-
/* A text node that is purely whitespace (no interpolation, only blank static
|
|
654
|
-
parts). Both back-ends drop it, so it neither contributes markup nor breaks a
|
|
655
|
-
static clone run — it stays transparent so `<a/>\n<b/>` still coalesces. */
|
|
656
|
-
function isWhitespaceText(node: TemplateNode): boolean {
|
|
657
|
-
return (
|
|
658
|
-
node.kind === 'text' &&
|
|
659
|
-
node.parts.every((part) => part.kind === 'static' && part.value.trim() === '')
|
|
660
|
-
)
|
|
661
|
-
}
|
|
662
|
-
|
|
663
646
|
/*
|
|
664
647
|
Whether an element subtree is fully static — no reactive/event/bind attributes,
|
|
665
648
|
no nested `<script>`, and every descendant likewise static (static text, static
|
|
@@ -5,6 +5,7 @@ import { composeProps } from './composeProps.ts'
|
|
|
5
5
|
import { groupBindParts } from './groupBindParts.ts'
|
|
6
6
|
import { isAnchorPositioned } from './isAnchorPositioned.ts'
|
|
7
7
|
import { lowerContext } from './lowerContext.ts'
|
|
8
|
+
import { resolveBranches } from './resolveBranches.ts'
|
|
8
9
|
import { scopeAttr } from './scopeAttr.ts'
|
|
9
10
|
import { skeletonContext } from './skeletonContext.ts'
|
|
10
11
|
import { spreadExcludedNames } from './spreadExcludedNames.ts'
|
|
@@ -21,17 +22,6 @@ import { VOID_TAGS } from './VOID_TAGS.ts'
|
|
|
21
22
|
const RANGE_OPEN = '<!--[-->'
|
|
22
23
|
const RANGE_CLOSE = '<!--]-->'
|
|
23
24
|
|
|
24
|
-
/* The `then`/`catch`/`finally` branch child of an await/try block, or undefined. */
|
|
25
|
-
function branchNamed(
|
|
26
|
-
children: TemplateNode[],
|
|
27
|
-
which: 'then' | 'catch' | 'finally',
|
|
28
|
-
): Extract<TemplateNode, { kind: 'branch' }> | undefined {
|
|
29
|
-
return children.find(
|
|
30
|
-
(child): child is Extract<TemplateNode, { kind: 'branch' }> =>
|
|
31
|
-
child.kind === 'branch' && child.branch === which,
|
|
32
|
-
)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
25
|
/*
|
|
36
26
|
Server code generator: turns the parsed template into statements that push HTML
|
|
37
27
|
fragments onto an output array, reading the document synchronously (no DOM, no
|
|
@@ -212,11 +202,19 @@ export function generateSSR(
|
|
|
212
202
|
.join('')
|
|
213
203
|
}
|
|
214
204
|
if (node.kind === 'if') {
|
|
215
|
-
|
|
205
|
+
/* `case` children are the `elseif`/`else` branches in source order; the rest are
|
|
206
|
+
the `then` content. Each `elseif` becomes an `else if`, the match-less `else`
|
|
207
|
+
the trailing `else`. */
|
|
208
|
+
const branches = node.children.filter(
|
|
209
|
+
(child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
|
|
210
|
+
)
|
|
216
211
|
const thenChildren = node.children.filter((child) => child.kind !== 'case')
|
|
217
212
|
let code = `if (${lowerExpression(node.condition)}) {\n${branchContent(thenChildren, target)}}`
|
|
218
|
-
|
|
219
|
-
code +=
|
|
213
|
+
for (const branch of branches) {
|
|
214
|
+
code +=
|
|
215
|
+
branch.condition !== undefined
|
|
216
|
+
? ` else if (${lowerExpression(branch.condition)}) {\n${branchContent(branch.children, target)}}`
|
|
217
|
+
: ` else {\n${branchContent(branch.children, target)}}`
|
|
220
218
|
}
|
|
221
219
|
return `${anchor}${openRange(target)}${code}\n${closeRange(target)}`
|
|
222
220
|
}
|
|
@@ -442,8 +440,8 @@ export function generateSSR(
|
|
|
442
440
|
node: Extract<TemplateNode, { kind: 'await' }>,
|
|
443
441
|
target: string,
|
|
444
442
|
): string {
|
|
445
|
-
const catchBranch =
|
|
446
|
-
const finallyChildren =
|
|
443
|
+
const [catchBranch, finallyBranch] = resolveBranches(node, 'catch', 'finally')
|
|
444
|
+
const finallyChildren = finallyBranch?.children ?? []
|
|
447
445
|
const resolvedChildren = node.children.filter((child) => child.kind !== 'branch')
|
|
448
446
|
const resolvedAs = node.as ?? '_value'
|
|
449
447
|
const errorAs = catchBranch?.as ?? '_error'
|
|
@@ -480,9 +478,13 @@ export function generateSSR(
|
|
|
480
478
|
node: Extract<TemplateNode, { kind: 'await' }>,
|
|
481
479
|
target: string,
|
|
482
480
|
): string {
|
|
483
|
-
const catchBranch =
|
|
484
|
-
|
|
485
|
-
|
|
481
|
+
const [thenBranch, catchBranch, finallyBranch] = resolveBranches(
|
|
482
|
+
node,
|
|
483
|
+
'then',
|
|
484
|
+
'catch',
|
|
485
|
+
'finally',
|
|
486
|
+
)
|
|
487
|
+
const finallyChildren = finallyBranch?.children ?? []
|
|
486
488
|
const pending = node.children.filter((child) => child.kind !== 'branch')
|
|
487
489
|
const id = nextVar('$aid')
|
|
488
490
|
let code = `const ${id} = $ctx.next++;\n`
|
|
@@ -510,8 +512,8 @@ export function generateSSR(
|
|
|
510
512
|
an enclosing boundary / the 500 / the stream). Boundary comments let hydration
|
|
511
513
|
discard the server content if the client adoption fails. */
|
|
512
514
|
function generateTry(node: Extract<TemplateNode, { kind: 'try' }>, target: string): string {
|
|
513
|
-
const catchBranch =
|
|
514
|
-
const finallyChildren =
|
|
515
|
+
const [catchBranch, finallyBranch] = resolveBranches(node, 'catch', 'finally')
|
|
516
|
+
const finallyChildren = finallyBranch?.children ?? []
|
|
515
517
|
const guarded = node.children.filter((child) => child.kind !== 'branch')
|
|
516
518
|
const errName = catchBranch?.as ?? '_error'
|
|
517
519
|
const id = nextVar('$tid')
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
2
|
+
|
|
3
|
+
/* A text node that is purely whitespace (no interpolation, only blank static
|
|
4
|
+
parts). Both back-ends drop it, so it neither contributes markup nor breaks a
|
|
5
|
+
static clone run — it stays transparent so `<a/>\n<b/>` still coalesces. */
|
|
6
|
+
export function isWhitespaceText(node: TemplateNode): boolean {
|
|
7
|
+
return (
|
|
8
|
+
node.kind === 'text' &&
|
|
9
|
+
node.parts.every((part) => part.kind === 'static' && part.value.trim() === '')
|
|
10
|
+
)
|
|
11
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { decodeHtmlEntities } from './decodeHtmlEntities.ts'
|
|
2
|
+
import { isWhitespaceText } from './isWhitespaceText.ts'
|
|
2
3
|
import type { TemplateAttr } from './types/TemplateAttr.ts'
|
|
3
4
|
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
4
5
|
import type { TextPart } from './types/TextPart.ts'
|
|
@@ -303,6 +304,13 @@ function rejectStrayBranches(
|
|
|
303
304
|
'[abide] <template else>/<template case> must be nested inside its <template if>/<template switch> — a sibling branch is not supported',
|
|
304
305
|
)
|
|
305
306
|
}
|
|
307
|
+
/* `elseif` is an `if`-chain branch; inside a `switch` it would silently read as the
|
|
308
|
+
default (match-less), so reject the cross-construct mix. */
|
|
309
|
+
if (node.kind === 'case' && node.condition !== undefined && parentKind === 'switch') {
|
|
310
|
+
throw new Error(
|
|
311
|
+
'[abide] <template elseif> belongs to a <template if> chain, not a <template switch> — use <template case>',
|
|
312
|
+
)
|
|
313
|
+
}
|
|
306
314
|
if (
|
|
307
315
|
node.kind === 'branch' &&
|
|
308
316
|
parentKind !== 'await' &&
|
|
@@ -313,12 +321,62 @@ function rejectStrayBranches(
|
|
|
313
321
|
'[abide] <template then>/<template catch>/<template finally> must be nested inside its <template await>/<template try>/<template each await>',
|
|
314
322
|
)
|
|
315
323
|
}
|
|
324
|
+
if (node.kind === 'if' || node.kind === 'switch') {
|
|
325
|
+
rejectMisplacedBranchContent(node)
|
|
326
|
+
}
|
|
316
327
|
if ('children' in node) {
|
|
317
328
|
rejectStrayBranches(node.children, node.kind)
|
|
318
329
|
}
|
|
319
330
|
}
|
|
320
331
|
}
|
|
321
332
|
|
|
333
|
+
/* A `<template if>` chain's `then` content precedes its first branch tag
|
|
334
|
+
(`elseif`/`else`); a `<template switch>` renders only its branch tags. So rendered
|
|
335
|
+
content sitting AFTER the first branch in an `if` — or ANYWHERE in a `switch` —
|
|
336
|
+
belongs to no branch: today it silently folds into `then` / is dropped. Reject it so
|
|
337
|
+
the misplacement surfaces. Whitespace stays transparent, and `<script>`/`<style>` are
|
|
338
|
+
directives (scoping, not rendered output), so both remain legal anywhere. */
|
|
339
|
+
function rejectMisplacedBranchContent(
|
|
340
|
+
node: Extract<TemplateNode, { kind: 'if' | 'switch' }>,
|
|
341
|
+
): void {
|
|
342
|
+
const firstBranch = node.children.findIndex((child) => child.kind === 'case')
|
|
343
|
+
node.children.forEach((child, index) => {
|
|
344
|
+
const isRenderedContent =
|
|
345
|
+
child.kind !== 'case' &&
|
|
346
|
+
child.kind !== 'script' &&
|
|
347
|
+
child.kind !== 'style' &&
|
|
348
|
+
!isWhitespaceText(child)
|
|
349
|
+
/* In a switch nothing but branches renders, so every position is illegal; in an if
|
|
350
|
+
only content past the first branch is (the leading then-content is legal). */
|
|
351
|
+
const illegalPosition =
|
|
352
|
+
node.kind === 'switch' || (firstBranch !== -1 && index > firstBranch)
|
|
353
|
+
if (isRenderedContent && illegalPosition) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
node.kind === 'switch'
|
|
356
|
+
? '[abide] a <template switch> renders only its <template case>/<template default> branches — move stray content into a branch'
|
|
357
|
+
: '[abide] content after a <template elseif>/<template else> belongs to no branch — the then-content must precede the first branch tag',
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
/* In an `if` chain the match-less `<template else>` is the trailing block, so any
|
|
362
|
+
branch after it (a second `else`, or an `elseif`) compiles to invalid `} else {…}
|
|
363
|
+
else if {…}` (SSR/type-shadow) or a silently-shadowed branch (the `switchBlock`
|
|
364
|
+
default wins). Reject so the misordering surfaces here, not as opaque codegen. */
|
|
365
|
+
if (node.kind === 'if') {
|
|
366
|
+
const branches = node.children.filter(
|
|
367
|
+
(child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
|
|
368
|
+
)
|
|
369
|
+
const elseIndex = branches.findIndex(
|
|
370
|
+
(branch) => branch.match === undefined && branch.condition === undefined,
|
|
371
|
+
)
|
|
372
|
+
if (elseIndex !== -1 && elseIndex < branches.length - 1) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
'[abide] <template else> must be the last branch of its <template if> chain — no <template elseif>/<template else> may follow it',
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
322
380
|
/* Turns a component's attributes into props. A component has no directives —
|
|
323
381
|
every attribute is a prop under its written name, so `on*`/`bind:`/`attach`
|
|
324
382
|
round-trip to their original names (the kinds the tag-blind attribute parser
|
|
@@ -468,6 +526,22 @@ function toControlFlow(attrs: TemplateAttr[], children: TemplateNode[]): Templat
|
|
|
468
526
|
}
|
|
469
527
|
return { kind: 'case', match: matchCode, children, loc: attrLoc(caseAttr) }
|
|
470
528
|
}
|
|
529
|
+
/* `<template elseif={c}>` is a match-less case carrying a condition — a branch of the
|
|
530
|
+
enclosing `<template if>` chain, truthy-tested in source order. */
|
|
531
|
+
const elseif = find('elseif')
|
|
532
|
+
if (elseif !== undefined) {
|
|
533
|
+
const conditionCode = attrText(elseif)
|
|
534
|
+
if (conditionCode === undefined) {
|
|
535
|
+
throw new Error('[abide] <template elseif> requires a condition expression')
|
|
536
|
+
}
|
|
537
|
+
return {
|
|
538
|
+
kind: 'case',
|
|
539
|
+
match: undefined,
|
|
540
|
+
condition: conditionCode,
|
|
541
|
+
children,
|
|
542
|
+
loc: attrLoc(elseif),
|
|
543
|
+
}
|
|
544
|
+
}
|
|
471
545
|
if (find('default') !== undefined || find('else') !== undefined) {
|
|
472
546
|
return { kind: 'case', match: undefined, children } // default (switch) / else (if)
|
|
473
547
|
}
|
|
@@ -68,6 +68,18 @@ export function renameSignalRefs(
|
|
|
68
68
|
with that name added, so shadowing is per-branch (a sibling scope is unaffected). */
|
|
69
69
|
const makeVisitor = (shadowed: ReadonlySet<string>): ts.Visitor => {
|
|
70
70
|
const visit = (node: ts.Node): ts.Node => {
|
|
71
|
+
/* Type space never holds a value read. A type alias, an interface, or any
|
|
72
|
+
type annotation can name a signal — a prop-type member `option?: …`, a
|
|
73
|
+
`typeof x` — without it being a runtime reference. Leave the whole type
|
|
74
|
+
subtree untouched so it isn't rewritten into a call/access (`option()`)
|
|
75
|
+
and emitted as broken code (types erase at build anyway). */
|
|
76
|
+
if (
|
|
77
|
+
ts.isTypeAliasDeclaration(node) ||
|
|
78
|
+
ts.isInterfaceDeclaration(node) ||
|
|
79
|
+
ts.isTypeNode(node)
|
|
80
|
+
) {
|
|
81
|
+
return node
|
|
82
|
+
}
|
|
71
83
|
/* Shorthand `{ count }` → `{ count: model.count }` / `{ total: total.value }`,
|
|
72
84
|
unless a nearer scope shadows the name. */
|
|
73
85
|
if (ts.isShorthandPropertyAssignment(node) && !shadowed.has(node.name.text)) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
2
|
+
|
|
3
|
+
type Branch = Extract<TemplateNode, { kind: 'branch' }>
|
|
4
|
+
type BranchName = Branch['branch']
|
|
5
|
+
|
|
6
|
+
/* Extracts the `then`/`catch`/`finally` branch children of a control-flow block
|
|
7
|
+
(await/try), one slot per requested name in the order asked, so callers
|
|
8
|
+
destructure them directly. A requested branch that is absent yields `undefined`
|
|
9
|
+
— the caller reads `?.children`/`?.as` for its content and bound var. This is
|
|
10
|
+
the one branch-lookup site both back-ends share, replacing the per-generator
|
|
11
|
+
`findBranch`/`branchNamed`/`branchChildren`/`branchVar` copies. */
|
|
12
|
+
export function resolveBranches(
|
|
13
|
+
node: Extract<TemplateNode, { children: TemplateNode[] }>,
|
|
14
|
+
...which: BranchName[]
|
|
15
|
+
): (Branch | undefined)[] {
|
|
16
|
+
return which.map((name) =>
|
|
17
|
+
node.children.find(
|
|
18
|
+
(child): child is Branch => child.kind === 'branch' && child.branch === name,
|
|
19
|
+
),
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -4,7 +4,7 @@ import type { TextPart } from './TextPart.ts'
|
|
|
4
4
|
/*
|
|
5
5
|
A parsed template node. `text` carries interpolation parts; `element` carries
|
|
6
6
|
attributes and children; `each` is the `<template each as key>` control flow over
|
|
7
|
-
a list
|
|
7
|
+
a list; `if`/`await`/`switch`/`try` are its control-flow siblings.
|
|
8
8
|
|
|
9
9
|
`loc` (where present) is the absolute offset of the node's primary expression in
|
|
10
10
|
the original `.abide` source — additive, set only when the parser tracks
|
|
@@ -67,7 +67,17 @@ export type TemplateNode =
|
|
|
67
67
|
children: TemplateNode[]
|
|
68
68
|
}
|
|
69
69
|
| { kind: 'switch'; subject: string; children: TemplateNode[]; loc?: number }
|
|
70
|
-
|
|
70
|
+
/* A branch of a `<template switch>` (`match` set) or `<template if>` chain. Inside an
|
|
71
|
+
`if`, `<template elseif={c}>` sets `condition` (match-less, truthy-tested), and
|
|
72
|
+
`<template else>` leaves both unset (the default). `loc` points at whichever
|
|
73
|
+
expression the node carries (`match` or `condition`). */
|
|
74
|
+
| {
|
|
75
|
+
kind: 'case'
|
|
76
|
+
match: string | undefined
|
|
77
|
+
condition?: string
|
|
78
|
+
children: TemplateNode[]
|
|
79
|
+
loc?: number
|
|
80
|
+
}
|
|
71
81
|
/* A `<template name="row" args={item}>` snippet: a named, scope-capturing
|
|
72
82
|
builder declared once and called like a function (`{row(item)}`). `params`
|
|
73
83
|
is the raw `args` source spliced into the builder's parameter list. */
|
|
@@ -37,6 +37,9 @@ export function createScope(
|
|
|
37
37
|
const data = (): Doc => (document ??= createDoc({}))
|
|
38
38
|
const id = parent === undefined ? `scope-${nextId++}` : `${parent.id}.${nextId++}`
|
|
39
39
|
const children: Scope[] = []
|
|
40
|
+
/* Context values shared down the tree, held apart from the reactive doc (which
|
|
41
|
+
a child does not inherit): keyed by name, read by the closest ancestor walk. */
|
|
42
|
+
const shared = new Map<string, unknown>()
|
|
40
43
|
let past: History | undefined
|
|
41
44
|
let persistence: PersistHandle | undefined
|
|
42
45
|
let unsync: (() => void) | undefined
|
|
@@ -64,6 +67,16 @@ export function createScope(
|
|
|
64
67
|
return created
|
|
65
68
|
},
|
|
66
69
|
root: () => (parent === undefined ? self : parent.root()),
|
|
70
|
+
/* Reference store — no tracking, so a lookup never subscribes; reactivity comes
|
|
71
|
+
from what is shared (a cell/scope), not from the share. */
|
|
72
|
+
share: (key, value) => {
|
|
73
|
+
shared.set(key, value)
|
|
74
|
+
},
|
|
75
|
+
/* Closest-ancestor resolve: own map first (self can read what it shared), then
|
|
76
|
+
defer up via each scope's own `shared`. `has` distinguishes a shared
|
|
77
|
+
`undefined` from "not provided"; undefined at the root means no provider. */
|
|
78
|
+
shared: <T>(key: string): T | undefined =>
|
|
79
|
+
shared.has(key) ? (shared.get(key) as T) : parent?.shared<T>(key),
|
|
67
80
|
record: (options) => {
|
|
68
81
|
past ??= history(data(), options)
|
|
69
82
|
},
|
|
@@ -82,6 +95,7 @@ export function createScope(
|
|
|
82
95
|
created.dispose()
|
|
83
96
|
}
|
|
84
97
|
children.length = 0
|
|
98
|
+
shared.clear()
|
|
85
99
|
past?.dispose()
|
|
86
100
|
past = undefined
|
|
87
101
|
persistence?.dispose()
|
|
@@ -4,6 +4,7 @@ import { effect } from '../effect.ts'
|
|
|
4
4
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
5
5
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
6
6
|
import { appendSnippet } from './appendSnippet.ts'
|
|
7
|
+
import { parseRawNodes } from './parseRawNodes.ts'
|
|
7
8
|
|
|
8
9
|
const CLOSE = '/abide:html'
|
|
9
10
|
|
|
@@ -73,9 +74,7 @@ function appendRawHtml(parent: Node, read: () => unknown): void {
|
|
|
73
74
|
for (const node of nodes) {
|
|
74
75
|
parent.removeChild(node)
|
|
75
76
|
}
|
|
76
|
-
|
|
77
|
-
holder.innerHTML = value
|
|
78
|
-
nodes = [...holder.childNodes]
|
|
77
|
+
nodes = parseRawNodes(parent, value)
|
|
79
78
|
for (const node of nodes) {
|
|
80
79
|
parent.insertBefore(node, anchor)
|
|
81
80
|
}
|
|
@@ -4,6 +4,7 @@ import { effect } from '../effect.ts'
|
|
|
4
4
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
5
5
|
import { appendSnippet } from './appendSnippet.ts'
|
|
6
6
|
import { appendText } from './appendText.ts'
|
|
7
|
+
import { parseRawNodes } from './parseRawNodes.ts'
|
|
7
8
|
|
|
8
9
|
/*
|
|
9
10
|
A reactive `{expr}` interpolation mounted at a skeleton anchor comment (`<!--a-->`), used
|
|
@@ -51,9 +52,7 @@ export function appendTextAt(anchor: Node, read: () => unknown): void {
|
|
|
51
52
|
for (const node of nodes) {
|
|
52
53
|
parent.removeChild(node)
|
|
53
54
|
}
|
|
54
|
-
|
|
55
|
-
holder.innerHTML = rawHtmlString(read()) ?? ''
|
|
56
|
-
nodes = [...holder.childNodes]
|
|
55
|
+
nodes = parseRawNodes(parent, rawHtmlString(read()) ?? '')
|
|
57
56
|
/* Insert the fresh markup just after the anchor (its live re-insertion point). */
|
|
58
57
|
const after = anchor.nextSibling
|
|
59
58
|
for (const node of nodes) {
|
|
@@ -41,16 +41,13 @@ export function applyResolved(root: Element, frame: string): void {
|
|
|
41
41
|
if (id === null) {
|
|
42
42
|
return
|
|
43
43
|
}
|
|
44
|
-
/* The resolved value rides in a leading <script type=application/json
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
/* The resolved value rides in a leading <script type=application/json> as ref-json
|
|
45
|
+
text; store it raw and remove the node so only the resolved markup moves into the
|
|
46
|
+
boundary. Decoding is deferred to the read in `awaitBlock` (which has the codec);
|
|
47
|
+
recording it lets a later hydrate adopt this branch (no re-fetch). */
|
|
47
48
|
const payload = resolved.firstChild as Element | null
|
|
48
49
|
if (payload !== null && payload.nodeName === 'SCRIPT') {
|
|
49
|
-
|
|
50
|
-
RESUME[Number(id)] = JSON.parse(payload.textContent ?? 'null')
|
|
51
|
-
} catch {
|
|
52
|
-
/* malformed payload — leave unregistered, hydration re-runs the promise */
|
|
53
|
-
}
|
|
50
|
+
RESUME[Number(id)] = payload.textContent ?? ''
|
|
54
51
|
payload.remove()
|
|
55
52
|
}
|
|
56
53
|
const open = `abide:await:${id}`
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { decodeRefJson } from '../../shared/decodeRefJson.ts'
|
|
1
2
|
import { effect } from '../effect.ts'
|
|
2
3
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
3
4
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
5
|
+
import type { ResumeEntry } from '../runtime/RESUME.ts'
|
|
4
6
|
import { RESUME } from '../runtime/RESUME.ts'
|
|
5
7
|
import { scope } from '../runtime/scope.ts'
|
|
6
8
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
@@ -161,7 +163,18 @@ export function awaitBlock(
|
|
|
161
163
|
const firstHydrate = (result: unknown): void => {
|
|
162
164
|
const cursor = hydration as NonNullable<typeof hydration>
|
|
163
165
|
const open = claimChild(cursor, parent)
|
|
164
|
-
|
|
166
|
+
/* RESUME holds the ref-json-encoded entry STRING; decode here, where the codec
|
|
167
|
+
lives. A decode failure (malformed/absent payload) reads as "no resume" — fall
|
|
168
|
+
through to the live promise rather than crash hydration. */
|
|
169
|
+
const raw = RESUME[id]
|
|
170
|
+
let entry: ResumeEntry | undefined
|
|
171
|
+
if (raw !== undefined) {
|
|
172
|
+
try {
|
|
173
|
+
entry = decodeRefJson(raw) as ResumeEntry
|
|
174
|
+
} catch {
|
|
175
|
+
entry = undefined
|
|
176
|
+
}
|
|
177
|
+
}
|
|
165
178
|
if (entry !== undefined) {
|
|
166
179
|
let build: (host: Node) => void
|
|
167
180
|
if (entry.ok) {
|
package/src/lib/ui/dom/on.ts
CHANGED
|
@@ -12,6 +12,13 @@ resolves the component, not whatever is current when the event fires.
|
|
|
12
12
|
*/
|
|
13
13
|
// @documentation plumbing
|
|
14
14
|
export function on(element: Element, type: string, handler: EventListener): void {
|
|
15
|
+
/* A caller that omits an optional `on*` prop forwards it as undefined, yet the
|
|
16
|
+
compiler still emits this on() call — so skip attaching a non-function rather
|
|
17
|
+
than invoke undefined when the event fires (e.g. input/keydown on a search box,
|
|
18
|
+
which would throw "handler is not a function" on every keystroke). */
|
|
19
|
+
if (typeof handler !== 'function') {
|
|
20
|
+
return
|
|
21
|
+
}
|
|
15
22
|
const captured = CURRENT_SCOPE.current
|
|
16
23
|
const wrapped: EventListener = (event) => inScope(captured, () => handler(event))
|
|
17
24
|
element.addEventListener(type, wrapped)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { foreignWrapperTag } from './foreignWrapperTag.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Parses a trusted raw-markup string into detached nodes in the SAME foreign namespace
|
|
5
|
+
as `parent`. The HTML fragment parser namespaces by its context element, so markup
|
|
6
|
+
bound inside an <svg>/<math> must parse inside a matching wrapper — otherwise a bare
|
|
7
|
+
`<path>` lands in the HTML namespace and never renders. Mirrors `cloneStatic`'s
|
|
8
|
+
foreign handling, but parses per-call into a throwaway `<template>` rather than the
|
|
9
|
+
skeleton cache: raw values are dynamic, so caching every distinct string would leak.
|
|
10
|
+
*/
|
|
11
|
+
export function parseRawNodes(parent: Node, markup: string): Node[] {
|
|
12
|
+
const wrapper = foreignWrapperTag(parent)
|
|
13
|
+
const template = document.createElement('template')
|
|
14
|
+
template.innerHTML = wrapper === undefined ? markup : `<${wrapper}>${markup}</${wrapper}>`
|
|
15
|
+
const source = wrapper === undefined ? template.content : (template.content.firstChild as Node)
|
|
16
|
+
return [...source.childNodes]
|
|
17
|
+
}
|