@abide/abide 0.38.1 → 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 +5 -5
- package/CHANGELOG.md +63 -0
- package/package.json +9 -1
- package/src/build.ts +14 -0
- package/src/lib/bundle/exitWithParent.ts +4 -2
- package/src/lib/server/rpc/parseArgs.ts +10 -1
- package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
- 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/runtime/createAppAssetServer.ts +37 -11
- package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
- package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
- package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
- package/src/lib/server/sockets/createSocketDispatcher.ts +19 -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/createRemoteFunction.ts +20 -11
- package/src/lib/shared/decodeRefJson.ts +110 -0
- package/src/lib/shared/encodeRefJson.ts +106 -0
- package/src/lib/shared/escapeHtml.ts +15 -0
- package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
- package/src/lib/shared/streamResponse.ts +8 -1
- package/src/lib/shared/types/RemoteCallable.ts +12 -3
- package/src/lib/shared/types/RpcOptions.ts +22 -0
- package/src/lib/shared/types/SourceMap.ts +14 -0
- package/src/lib/test/createTestSocketChannel.ts +6 -2
- package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +5 -0
- package/src/lib/ui/compile/compileModule.ts +5 -2
- package/src/lib/ui/compile/compileSSR.ts +32 -9
- package/src/lib/ui/compile/compileShadow.ts +25 -10
- package/src/lib/ui/compile/composeProps.ts +53 -0
- package/src/lib/ui/compile/desugarSignals.ts +45 -17
- package/src/lib/ui/compile/generateBuild.ts +87 -76
- package/src/lib/ui/compile/generateSSR.ts +217 -71
- package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
- package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
- package/src/lib/ui/compile/parseTemplate.ts +118 -1
- package/src/lib/ui/compile/renameSignalRefs.ts +12 -0
- package/src/lib/ui/compile/resolveBranches.ts +21 -0
- package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
- package/src/lib/ui/compile/staticAttr.ts +1 -1
- package/src/lib/ui/compile/staticTextPart.ts +1 -1
- package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
- package/src/lib/ui/compile/types/TemplateNode.ts +15 -3
- 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/mergeProps.ts +32 -0
- package/src/lib/ui/dom/on.ts +7 -0
- package/src/lib/ui/dom/parseRawNodes.ts +17 -0
- package/src/lib/ui/dom/readCall.ts +27 -0
- package/src/lib/ui/dom/restProps.ts +32 -0
- package/src/lib/ui/dom/spreadAttrs.ts +34 -0
- package/src/lib/ui/dom/spreadProps.ts +32 -0
- package/src/lib/ui/installHotBridge.ts +10 -0
- package/src/lib/ui/navigate.ts +28 -3
- package/src/lib/ui/remoteProxy.ts +68 -36
- package/src/lib/ui/renderChain.ts +39 -37
- package/src/lib/ui/renderToStream.ts +84 -68
- package/src/lib/ui/resumeSeedScript.ts +27 -0
- package/src/lib/ui/router.ts +81 -51
- package/src/lib/ui/runtime/RESUME.ts +13 -6
- package/src/lib/ui/runtime/createEffectNode.ts +5 -0
- package/src/lib/ui/runtime/localStoragePersistence.ts +21 -8
- package/src/lib/ui/runtime/toTeardown.ts +10 -5
- package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
- package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
- package/src/lib/ui/runtime/types/UiComponent.ts +7 -1
- 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
- package/src/lib/ui/compile/escapeHtml.ts +0 -15
- /package/src/lib/{server/runtime → shared}/safeJsonForScript.ts +0 -0
|
@@ -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'
|
|
@@ -121,6 +122,24 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
121
122
|
if (char === '>' || char === '/' || char === undefined) {
|
|
122
123
|
break
|
|
123
124
|
}
|
|
125
|
+
/* `{...expr}` standing where an attribute name would — a spread of an object's
|
|
126
|
+
keys onto the tag: props on a component, attributes on a native element. Only
|
|
127
|
+
a `<template>` directive rejects it (see `readElement`). */
|
|
128
|
+
if (char === '{') {
|
|
129
|
+
const { code, loc } = readBracedExpression()
|
|
130
|
+
if (!code.startsWith('...')) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`[abide] a bare {expr} is not a valid attribute — write {...expr} to spread an object's keys as props`,
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
/* Advance `loc` past `...` and any whitespace so it points at the spread
|
|
136
|
+
EXPRESSION, not the dots — the shadow source-map invariant (source text at
|
|
137
|
+
`loc` equals the emitted code) requires `loc` and `code` to align. */
|
|
138
|
+
const inner = code.slice(3)
|
|
139
|
+
const leading = inner.length - inner.trimStart().length
|
|
140
|
+
attrs.push({ kind: 'spread', code: inner.trim(), loc: loc + 3 + leading })
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
124
143
|
let name = ''
|
|
125
144
|
while (cursor < source.length && !/[\s=>/]/.test(source.charAt(cursor))) {
|
|
126
145
|
name += source.charAt(cursor)
|
|
@@ -163,6 +182,12 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
163
182
|
`>`, per the HTML unquoted-attribute rule. No delimiter to consume. */
|
|
164
183
|
let value = ''
|
|
165
184
|
while (cursor < source.length && !/[\s>]/.test(source.charAt(cursor))) {
|
|
185
|
+
/* Stop before a `/` that closes the tag (`<Comp x=y/>`) so the value
|
|
186
|
+
doesn't swallow the self-closing slash and defeat detection; a `/`
|
|
187
|
+
elsewhere (e.g. a URL `href=/a/b`) stays part of the value. */
|
|
188
|
+
if (source.charAt(cursor) === '/' && source.charAt(cursor + 1) === '>') {
|
|
189
|
+
break
|
|
190
|
+
}
|
|
166
191
|
value += source.charAt(cursor)
|
|
167
192
|
cursor += 1
|
|
168
193
|
}
|
|
@@ -214,6 +239,11 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
214
239
|
const slotted = selfClosing ? [] : readChildren(tag)
|
|
215
240
|
return { kind: 'component', name: tag, props: toProps(attrs), children: slotted }
|
|
216
241
|
}
|
|
242
|
+
/* `{...expr}` spreads onto a component (its props) or a native element (its
|
|
243
|
+
attributes), but a `<template>` directive has no such bag — reject it there. */
|
|
244
|
+
if (tag === 'template' && attrs.some((attr) => attr.kind === 'spread')) {
|
|
245
|
+
throw new Error('[abide] {...expr} spread is not supported on a <template> directive')
|
|
246
|
+
}
|
|
217
247
|
const children = selfClosing || VOID_TAGS.has(tag) ? [] : readChildren(tag)
|
|
218
248
|
if (tag === 'template') {
|
|
219
249
|
return toControlFlow(attrs, children)
|
|
@@ -274,6 +304,13 @@ function rejectStrayBranches(
|
|
|
274
304
|
'[abide] <template else>/<template case> must be nested inside its <template if>/<template switch> — a sibling branch is not supported',
|
|
275
305
|
)
|
|
276
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
|
+
}
|
|
277
314
|
if (
|
|
278
315
|
node.kind === 'branch' &&
|
|
279
316
|
parentKind !== 'await' &&
|
|
@@ -284,20 +321,77 @@ function rejectStrayBranches(
|
|
|
284
321
|
'[abide] <template then>/<template catch>/<template finally> must be nested inside its <template await>/<template try>/<template each await>',
|
|
285
322
|
)
|
|
286
323
|
}
|
|
324
|
+
if (node.kind === 'if' || node.kind === 'switch') {
|
|
325
|
+
rejectMisplacedBranchContent(node)
|
|
326
|
+
}
|
|
287
327
|
if ('children' in node) {
|
|
288
328
|
rejectStrayBranches(node.children, node.kind)
|
|
289
329
|
}
|
|
290
330
|
}
|
|
291
331
|
}
|
|
292
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
|
+
|
|
293
380
|
/* Turns a component's attributes into props. A component has no directives —
|
|
294
381
|
every attribute is a prop under its written name, so `on*`/`bind:`/`attach`
|
|
295
382
|
round-trip to their original names (the kinds the tag-blind attribute parser
|
|
296
383
|
assigned) instead of being dropped. A static value becomes a string literal —
|
|
297
384
|
a bare attribute coerces to `true` instead; every other kind keeps its `code`,
|
|
298
385
|
letting a prop hold any value, functions included (e.g. an `onclick` callback). */
|
|
299
|
-
function toProps(
|
|
386
|
+
function toProps(
|
|
387
|
+
attrs: TemplateAttr[],
|
|
388
|
+
): { name: string; code: string; loc?: number; spread?: boolean }[] {
|
|
300
389
|
return attrs.map((attr) => {
|
|
390
|
+
/* A `{...expr}` spread carries no name — its keys merge in at runtime
|
|
391
|
+
(`mergeProps`/`spreadProps`); `spread: true` marks it for the back-ends. */
|
|
392
|
+
if (attr.kind === 'spread') {
|
|
393
|
+
return { name: '', code: attr.code, loc: attr.loc, spread: true }
|
|
394
|
+
}
|
|
301
395
|
if (attr.kind === 'static') {
|
|
302
396
|
/* A bare attribute (`<Toggle on />`) is a boolean flag: coerce it to
|
|
303
397
|
`true` so the prop reads as a boolean, not the empty string a native
|
|
@@ -347,6 +441,13 @@ function attrName(attr: TemplateAttr): string {
|
|
|
347
441
|
if (attr.kind === 'attach') {
|
|
348
442
|
return 'attach'
|
|
349
443
|
}
|
|
444
|
+
/* A spread has no name. `attrName` only feeds `<template>` directive lookups, and a
|
|
445
|
+
spread on a `<template>` is rejected at parse, so this branch is unreachable in
|
|
446
|
+
practice. (Spread itself IS supported — on components as props and on native
|
|
447
|
+
elements as attributes; only `<template>` directives reject it.) */
|
|
448
|
+
if (attr.kind === 'spread') {
|
|
449
|
+
return ''
|
|
450
|
+
}
|
|
350
451
|
return attr.name
|
|
351
452
|
}
|
|
352
453
|
|
|
@@ -425,6 +526,22 @@ function toControlFlow(attrs: TemplateAttr[], children: TemplateNode[]): Templat
|
|
|
425
526
|
}
|
|
426
527
|
return { kind: 'case', match: matchCode, children, loc: attrLoc(caseAttr) }
|
|
427
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
|
+
}
|
|
428
545
|
if (find('default') !== undefined || find('else') !== undefined) {
|
|
429
546
|
return { kind: 'case', match: undefined, children } // default (switch) / else (if)
|
|
430
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
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { TemplateAttr } from './types/TemplateAttr.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The explicitly-authored attribute names on a native element that also carries a
|
|
5
|
+
`{...spread}`. An explicit attribute wins over a spread key of the same name, so
|
|
6
|
+
both back-ends pass this set to skip those keys from the spread — keeping the
|
|
7
|
+
server string and the client DOM congruent (no duplicate attribute, and no
|
|
8
|
+
SSR-first-wins vs client-spread-wins divergence). Excluding by NAME (not by a
|
|
9
|
+
runtime present/absent check) means the two sides agree regardless of a dynamic
|
|
10
|
+
attribute's runtime value. Static and dynamic (`{expr}`) attrs contribute their
|
|
11
|
+
name, a `bind:` its property, an `on<event>` its `on…` form; `attach`/`spread`
|
|
12
|
+
carry no attribute name.
|
|
13
|
+
*/
|
|
14
|
+
export function spreadExcludedNames(attrs: TemplateAttr[]): string[] {
|
|
15
|
+
return attrs.flatMap((attr) => {
|
|
16
|
+
if (attr.kind === 'static' || attr.kind === 'expression') {
|
|
17
|
+
return [attr.name]
|
|
18
|
+
}
|
|
19
|
+
if (attr.kind === 'bind') {
|
|
20
|
+
return [attr.property]
|
|
21
|
+
}
|
|
22
|
+
if (attr.kind === 'event') {
|
|
23
|
+
return [`on${attr.event}`]
|
|
24
|
+
}
|
|
25
|
+
return []
|
|
26
|
+
})
|
|
27
|
+
}
|
|
@@ -6,7 +6,9 @@ reactively; `event` is `on<event>={code}` where `code` evaluates to the handler;
|
|
|
6
6
|
`bind` is `bind:<property>={lvalue}`, a two-way binding whose `code` is the
|
|
7
7
|
writable doc path read into the property and written back on input; `attach` is
|
|
8
8
|
`attach={code}` where `code` evaluates to an attachment `(node) => teardown` run
|
|
9
|
-
at build with node-lifetime teardown
|
|
9
|
+
at build with node-lifetime teardown; `spread` is `{...code}` where `code`
|
|
10
|
+
evaluates to an object whose own keys each become a prop on a component or an
|
|
11
|
+
attribute on a native element. `loc` is the
|
|
10
12
|
absolute offset of `code` in the original `.abide` source (see TextPart) —
|
|
11
13
|
optional, set only when the parser tracks positions for the type-checking shadow.
|
|
12
14
|
*/
|
|
@@ -16,3 +18,4 @@ export type TemplateAttr =
|
|
|
16
18
|
| { kind: 'event'; event: string; code: string; loc?: number }
|
|
17
19
|
| { kind: 'bind'; property: string; code: string; loc?: number }
|
|
18
20
|
| { kind: 'attach'; code: string; loc?: number }
|
|
21
|
+
| { kind: 'spread'; code: string; loc?: number }
|
|
@@ -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
|
|
@@ -61,11 +61,23 @@ export type TemplateNode =
|
|
|
61
61
|
| {
|
|
62
62
|
kind: 'component'
|
|
63
63
|
name: string
|
|
64
|
-
|
|
64
|
+
/* Each authored attribute as a prop. A `spread` entry (`{...code}`) carries no
|
|
65
|
+
`name`; its keys merge in at runtime (`mergeProps`/`spreadProps`). */
|
|
66
|
+
props: { name: string; code: string; loc?: number; spread?: boolean }[]
|
|
65
67
|
children: TemplateNode[]
|
|
66
68
|
}
|
|
67
69
|
| { kind: 'switch'; subject: string; children: TemplateNode[]; loc?: number }
|
|
68
|
-
|
|
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
|
+
}
|
|
69
81
|
/* A `<template name="row" args={item}>` snippet: a named, scope-capturing
|
|
70
82
|
builder declared once and called like a function (`{row(item)}`). `params`
|
|
71
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) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { UiProps } from '../runtime/types/UiProps.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Composes a child's props from ordered layers — explicit prop runs (`{ name: () =>
|
|
5
|
+
value }` thunk maps), `{...spread}` layers (`spreadProps`), and a trailing slot
|
|
6
|
+
layer (`$children`) — into one prop bag, last layer wins per key (source order, like
|
|
7
|
+
JSX). A key resolves by scanning layers in reverse for the first that carries it, so
|
|
8
|
+
an explicit prop after a spread overrides it and vice-versa. Emitted only when a
|
|
9
|
+
component carries a spread; the plain object literal stays the path otherwise.
|
|
10
|
+
*/
|
|
11
|
+
// @documentation plumbing
|
|
12
|
+
export function mergeProps(layers: Record<string | symbol, unknown>[]): UiProps {
|
|
13
|
+
return new Proxy(Object.create(null), {
|
|
14
|
+
get(_target, key) {
|
|
15
|
+
for (let index = layers.length - 1; index >= 0; index -= 1) {
|
|
16
|
+
const layer = layers[index]
|
|
17
|
+
if (layer !== undefined && key in layer) {
|
|
18
|
+
return layer[key]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return undefined
|
|
22
|
+
},
|
|
23
|
+
has: (_target, key) => layers.some((layer) => key in layer),
|
|
24
|
+
/* Enumerable across every layer (deduped), so `restProps` can collect a
|
|
25
|
+
parent-spread bag's keys. */
|
|
26
|
+
ownKeys: () => [...new Set(layers.flatMap((layer) => Reflect.ownKeys(layer)))],
|
|
27
|
+
getOwnPropertyDescriptor: (_target, key) =>
|
|
28
|
+
layers.some((layer) => key in layer)
|
|
29
|
+
? { enumerable: true, configurable: true }
|
|
30
|
+
: undefined,
|
|
31
|
+
}) as UiProps
|
|
32
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Guarded method call on a reactive-document read. The doc-access lowering rewrites
|
|
3
|
+
`model.draft.trim()` into `model.read("draft").trim()`; when that read is nullish
|
|
4
|
+
the bare form throws the engine's opaque `undefined is not an object (evaluating
|
|
5
|
+
'…read("draft").trim()')`, naming only the desugared call. This wraps the
|
|
6
|
+
non-optional call so the throw names the authored scope path and member instead —
|
|
7
|
+
the source-mapped stack frame still resolves to the `.abide` line, so message and
|
|
8
|
+
location together read in authored terms. Both opaque engine errors are covered: a
|
|
9
|
+
nullish receiver (`undefined is not an object`) AND a present receiver whose member
|
|
10
|
+
is not callable (`… is not a function`, the misspelled/missing-method case).
|
|
11
|
+
Optional-chained calls are left bare: `?.` means skip-if-absent, not throw, so
|
|
12
|
+
guarding them would change semantics. `.apply(target, …)` preserves the receiver, so
|
|
13
|
+
the method sees the same `this` the bare `target.member(…)` would.
|
|
14
|
+
*/
|
|
15
|
+
// @documentation plumbing
|
|
16
|
+
export function readCall(target: unknown, path: string, member: string, args: unknown[]): unknown {
|
|
17
|
+
if (target === undefined || target === null) {
|
|
18
|
+
throw new TypeError(`abide: cannot call .${member}() — scope value "${path}" is ${target}`)
|
|
19
|
+
}
|
|
20
|
+
const method = (target as Record<string, unknown>)[member]
|
|
21
|
+
if (typeof method !== 'function') {
|
|
22
|
+
throw new TypeError(
|
|
23
|
+
`abide: cannot call .${member}() — "${path}".${member} is not a function (got ${typeof method})`,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
return (method as (...args: unknown[]) => unknown).apply(target, args)
|
|
27
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { UiProps } from '../runtime/types/UiProps.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The rest of a component's props — `const { foo, ...rest } = props()` — as a live
|
|
5
|
+
object of the UNCONSUMED prop values. A child receives prop THUNKS, so `rest.key`
|
|
6
|
+
unwraps them (`$props[key]?.()`), tracking as a reactive dependency; a top-level
|
|
7
|
+
page/layout instead receives its route params as PLAIN values, so a non-function
|
|
8
|
+
value is returned as-is rather than called (which would throw `value is not a
|
|
9
|
+
function`). The explicitly-destructured keys and the `$children` slot are excluded.
|
|
10
|
+
Enumerable (`ownKeys`/`for…in`/`Object.keys`), so `{...rest}` can forward the
|
|
11
|
+
remaining props onto a child or a native element. Key membership is live, but a
|
|
12
|
+
consumer that captures the key SET (a `{...rest}` spread) snapshots it at that point.
|
|
13
|
+
*/
|
|
14
|
+
// @documentation plumbing
|
|
15
|
+
export function restProps(props: UiProps, consumed: string[]): Record<string, unknown> {
|
|
16
|
+
const skip = new Set([...consumed, '$children'])
|
|
17
|
+
const bag = props as Record<string, unknown>
|
|
18
|
+
const visible = (key: string | symbol): key is string =>
|
|
19
|
+
typeof key === 'string' && !skip.has(key) && key in bag
|
|
20
|
+
const read = (key: string): unknown =>
|
|
21
|
+
typeof bag[key] === 'function' ? (bag[key] as () => unknown)() : bag[key]
|
|
22
|
+
return new Proxy(
|
|
23
|
+
{},
|
|
24
|
+
{
|
|
25
|
+
get: (_target, key) => (visible(key) ? read(key) : undefined),
|
|
26
|
+
has: (_target, key) => visible(key),
|
|
27
|
+
ownKeys: () => Reflect.ownKeys(bag).filter(visible),
|
|
28
|
+
getOwnPropertyDescriptor: (_target, key) =>
|
|
29
|
+
visible(key) ? { enumerable: true, configurable: true } : undefined,
|
|
30
|
+
},
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { attr } from './attr.ts'
|
|
2
|
+
import { on } from './on.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Spreads an object's keys onto a native element — the `<div {...rest}>` runtime. `source`
|
|
6
|
+
is a thunk over the spread expression; its keys are enumerated ONCE (so the attribute set
|
|
7
|
+
is fixed at build, not reactively added/removed), but each value stays live: an `on<event>`
|
|
8
|
+
key holding a function attaches as that event listener (mirroring `onclick={…}` on an
|
|
9
|
+
element), and every other key binds as a reactive attribute (`attr`, re-reading the source
|
|
10
|
+
per change, with the same present/absent semantics — `false`/`null`/`undefined` removes it,
|
|
11
|
+
`true` sets it bare). A function under a non-`on` key is skipped (no attribute for it). A key
|
|
12
|
+
in `exclude` is skipped — the element names it explicitly, and an explicit attribute wins
|
|
13
|
+
over a spread key (the same set SSR's `$spread` skips, keeping the two sides congruent).
|
|
14
|
+
*/
|
|
15
|
+
// @documentation plumbing
|
|
16
|
+
export function spreadAttrs(
|
|
17
|
+
element: Element,
|
|
18
|
+
source: () => Record<string, unknown>,
|
|
19
|
+
exclude: string[] = [],
|
|
20
|
+
): void {
|
|
21
|
+
const skip = new Set(exclude)
|
|
22
|
+
const object = source()
|
|
23
|
+
for (const key in object) {
|
|
24
|
+
if (skip.has(key)) {
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
const value = object[key]
|
|
28
|
+
if (key.startsWith('on') && typeof value === 'function') {
|
|
29
|
+
on(element, key.slice(2), value as EventListener)
|
|
30
|
+
} else if (typeof value !== 'function') {
|
|
31
|
+
attr(element, key, () => source()[key])
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Wraps a `{...source}` spread layer so every key resolves to a live value thunk —
|
|
3
|
+
the same `() => value` shape an explicit `name={expr}` prop compiles to, so a child
|
|
4
|
+
reads a spread key exactly like an authored one (`$props[key]?.()`). `source` is a
|
|
5
|
+
THUNK over the spread expression (not its value), re-evaluated on each key read and
|
|
6
|
+
membership test, so a reactive source stays live both when its keys mutate and when
|
|
7
|
+
the whole object is replaced — and the read registers as the reader's dependency. A
|
|
8
|
+
nullish source spreads nothing. The reserved `$children` slot key is never surfaced —
|
|
9
|
+
a source happening to carry one must not masquerade as slot content.
|
|
10
|
+
*/
|
|
11
|
+
// @documentation plumbing
|
|
12
|
+
export function spreadProps(
|
|
13
|
+
source: () => Record<string, unknown> | null | undefined,
|
|
14
|
+
): Record<string, () => unknown> {
|
|
15
|
+
/* A spread key the merged bag exposes — present on the current source and not the
|
|
16
|
+
reserved `$children` slot key (a source carrying one must not become slot content). */
|
|
17
|
+
const carries = (key: string | symbol): boolean =>
|
|
18
|
+
key !== '$children' && key in (source() ?? {})
|
|
19
|
+
return new Proxy(
|
|
20
|
+
{},
|
|
21
|
+
{
|
|
22
|
+
/* Each read returns a fresh thunk that re-evaluates the source for the key. */
|
|
23
|
+
get: (_target, key) => () => source()?.[key as string],
|
|
24
|
+
/* Live membership: whether the current source object carries the key. */
|
|
25
|
+
has: (_target, key) => carries(key),
|
|
26
|
+
/* Enumerable over the current source, so a merged bag reports spread keys. */
|
|
27
|
+
ownKeys: () => Reflect.ownKeys(source() ?? {}).filter(carries),
|
|
28
|
+
getOwnPropertyDescriptor: (_target, key) =>
|
|
29
|
+
carries(key) ? { enumerable: true, configurable: true } : undefined,
|
|
30
|
+
},
|
|
31
|
+
) as Record<string, () => unknown>
|
|
32
|
+
}
|