@abide/abide 0.41.1 → 0.43.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.
Files changed (167) hide show
  1. package/AGENTS.md +234 -294
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +51 -95
  4. package/package.json +3 -2
  5. package/src/abideLsp.ts +46 -8
  6. package/src/abideResolverPlugin.ts +107 -19
  7. package/src/buildCli.ts +1 -1
  8. package/src/discoveryEntry.ts +3 -3
  9. package/src/lib/bundle/BundleMenuItem.ts +1 -1
  10. package/src/lib/cli/createClient.ts +12 -12
  11. package/src/lib/cli/dispatchCommand.ts +1 -1
  12. package/src/lib/cli/printSessionStatus.ts +1 -1
  13. package/src/lib/cli/resolveCliTarget.ts +1 -1
  14. package/src/lib/cli/runCli.ts +1 -1
  15. package/src/lib/cli/types/CliManifest.ts +1 -1
  16. package/src/lib/cli/types/CliManifestEntry.ts +2 -2
  17. package/src/lib/mcp/annotationsForMethod.ts +5 -5
  18. package/src/lib/mcp/createMcpServer.ts +3 -3
  19. package/src/lib/mcp/mcpSurface.ts +14 -14
  20. package/src/lib/mcp/types/McpServerOptions.ts +1 -1
  21. package/src/lib/server/DELETE.ts +4 -4
  22. package/src/lib/server/GET.ts +4 -4
  23. package/src/lib/server/HEAD.ts +4 -4
  24. package/src/lib/server/PATCH.ts +4 -4
  25. package/src/lib/server/POST.ts +4 -4
  26. package/src/lib/server/PUT.ts +4 -4
  27. package/src/lib/server/agent.ts +4 -4
  28. package/src/lib/server/env.ts +1 -1
  29. package/src/lib/server/error.ts +49 -7
  30. package/src/lib/server/json.ts +1 -1
  31. package/src/lib/server/rpc/buildErrorConstructors.ts +19 -0
  32. package/src/lib/server/rpc/{defineVerb.ts → defineRpc.ts} +52 -34
  33. package/src/lib/server/rpc/{dispatchVerbInProcess.ts → dispatchRpcInProcess.ts} +3 -3
  34. package/src/lib/server/rpc/fieldErrorsFromIssues.ts +23 -0
  35. package/src/lib/server/rpc/{findVerbByCommandName.ts → findRpcByCommandName.ts} +5 -5
  36. package/src/lib/server/rpc/parseArgs.ts +6 -6
  37. package/src/lib/server/rpc/readBodyWithinLimit.ts +2 -2
  38. package/src/lib/server/rpc/registerRpc.ts +6 -0
  39. package/src/lib/server/rpc/{verbRegistry.ts → rpcRegistry.ts} +4 -4
  40. package/src/lib/server/rpc/{runWithVerbTimeout.ts → runWithRpcTimeout.ts} +4 -4
  41. package/src/lib/server/rpc/types/RemoteHandler.ts +9 -3
  42. package/src/lib/server/rpc/types/{VerbHelper.ts → RpcHelper.ts} +57 -23
  43. package/src/lib/server/rpc/types/{VerbRegistryEntry.ts → RpcRegistryEntry.ts} +3 -3
  44. package/src/lib/server/rpc/types/TypedResponse.ts +2 -2
  45. package/src/lib/server/rpc/unprocessed.ts +6 -7
  46. package/src/lib/server/rpc/validationError.ts +17 -0
  47. package/src/lib/server/runtime/buildInspectorSurface.ts +7 -7
  48. package/src/lib/server/runtime/buildOpenApiSpec.ts +6 -6
  49. package/src/lib/server/runtime/createRouteDispatcher.ts +7 -7
  50. package/src/lib/server/runtime/createServer.ts +4 -4
  51. package/src/lib/server/runtime/crossOriginForbidden.ts +1 -1
  52. package/src/lib/server/runtime/crossOriginGate.ts +4 -4
  53. package/src/lib/server/runtime/devClientFingerprint.ts +2 -1
  54. package/src/lib/server/runtime/logExposedSurfaces.ts +4 -4
  55. package/src/lib/server/runtime/registryManifests.ts +2 -2
  56. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  57. package/src/lib/server/runtime/types/InspectorRpc.ts +27 -0
  58. package/src/lib/server/runtime/types/InspectorSurface.ts +4 -4
  59. package/src/lib/server/runtime/types/RequestStore.ts +1 -1
  60. package/src/lib/server/runtime/warnUnguardedMcp.ts +2 -2
  61. package/src/lib/server/sockets/createSocketDispatcher.ts +15 -14
  62. package/src/lib/server/sockets/types/SocketOperation.ts +2 -2
  63. package/src/lib/shared/HttpError.ts +15 -1
  64. package/src/lib/shared/UNREACHABLE_STATUSES.ts +13 -0
  65. package/src/lib/shared/buildRpcRequest.ts +5 -5
  66. package/src/lib/shared/cache.ts +50 -168
  67. package/src/lib/shared/carriesBodyArgs.ts +4 -4
  68. package/src/lib/shared/createCacheStore.ts +12 -0
  69. package/src/lib/shared/createRemoteFunction.ts +11 -6
  70. package/src/lib/shared/decodeResponse.ts +2 -2
  71. package/src/lib/shared/detectRpcMethod.ts +17 -0
  72. package/src/lib/shared/emitLogRecord.ts +3 -3
  73. package/src/lib/shared/escapeRegex.ts +9 -0
  74. package/src/lib/shared/extraForwardHeaders.ts +1 -1
  75. package/src/lib/shared/fileName.ts +9 -0
  76. package/src/lib/shared/fileStem.ts +3 -1
  77. package/src/lib/shared/findExportCallSite.ts +30 -273
  78. package/src/lib/shared/forwardHeaders.ts +2 -2
  79. package/src/lib/shared/httpErrorFor.ts +26 -0
  80. package/src/lib/shared/isAsciiWhitespace.ts +5 -0
  81. package/src/lib/shared/isIdentPart.ts +9 -0
  82. package/src/lib/shared/isIdentStart.ts +8 -0
  83. package/src/lib/shared/isReadOnlyMethod.ts +4 -4
  84. package/src/lib/shared/keyForRemoteCall.ts +4 -4
  85. package/src/lib/shared/log.ts +1 -1
  86. package/src/lib/shared/outboxProbeSlot.ts +20 -0
  87. package/src/lib/shared/pending.ts +4 -1
  88. package/src/lib/shared/prepareRpcModule.ts +96 -14
  89. package/src/lib/shared/probeRegistries.ts +17 -4
  90. package/src/lib/shared/programNameForPackage.ts +3 -1
  91. package/src/lib/shared/queryStringFromArgs.ts +1 -1
  92. package/src/lib/shared/remoteMetaStore.ts +2 -2
  93. package/src/lib/shared/resolveClientFlags.ts +1 -1
  94. package/src/lib/shared/skipNonCode.ts +230 -0
  95. package/src/lib/shared/streamResponse.ts +9 -6
  96. package/src/lib/shared/stripImport.ts +6 -4
  97. package/src/lib/shared/types/CacheEntry.ts +2 -4
  98. package/src/lib/shared/types/CacheOnContext.ts +1 -11
  99. package/src/lib/shared/types/CacheStore.ts +13 -7
  100. package/src/lib/shared/types/ClientFlags.ts +1 -1
  101. package/src/lib/shared/types/ErrorConstructors.ts +17 -0
  102. package/src/lib/shared/types/ErrorDescriptor.ts +10 -0
  103. package/src/lib/shared/types/ErrorSpec.ts +11 -0
  104. package/src/lib/shared/types/HttpMethod.ts +1 -0
  105. package/src/lib/shared/types/Outbox.ts +9 -0
  106. package/src/lib/shared/types/OutboxEntry.ts +27 -0
  107. package/src/lib/shared/types/RawRemoteFunction.ts +2 -2
  108. package/src/lib/shared/types/RemoteCallable.ts +2 -2
  109. package/src/lib/shared/types/RemoteFunction.ts +21 -6
  110. package/src/lib/shared/types/RequestScopeInfo.ts +1 -1
  111. package/src/lib/shared/types/RpcErrorGuard.ts +40 -0
  112. package/src/lib/shared/types/RpcInvoker.ts +1 -1
  113. package/src/lib/shared/types/StandardSchemaV1.ts +1 -1
  114. package/src/lib/shared/types/ValidationErrorData.ts +20 -0
  115. package/src/lib/shared/url.ts +3 -3
  116. package/src/lib/shared/writeRpcDts.ts +7 -7
  117. package/src/lib/shared/writeTestRpcDts.ts +3 -3
  118. package/src/lib/test/createTestApp.ts +10 -10
  119. package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +45 -0
  120. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  121. package/src/lib/ui/compile/awaitPlan.ts +48 -0
  122. package/src/lib/ui/compile/compileShadow.ts +117 -31
  123. package/src/lib/ui/compile/createShadowLanguageService.ts +48 -0
  124. package/src/lib/ui/compile/desugarSignals.ts +42 -6
  125. package/src/lib/ui/compile/encodeSemanticTokens.ts +37 -0
  126. package/src/lib/ui/compile/generateBuild.ts +45 -62
  127. package/src/lib/ui/compile/generateSSR.ts +90 -60
  128. package/src/lib/ui/compile/ifPlan.ts +30 -0
  129. package/src/lib/ui/compile/makeVarNamer.ts +10 -0
  130. package/src/lib/ui/compile/offsetToPosition.ts +9 -0
  131. package/src/lib/ui/compile/parseTemplate.ts +565 -115
  132. package/src/lib/ui/compile/stripEffects.ts +17 -9
  133. package/src/lib/ui/compile/structuralBlockTokens.ts +101 -0
  134. package/src/lib/ui/compile/switchPlan.ts +22 -0
  135. package/src/lib/ui/compile/tryPlan.ts +29 -0
  136. package/src/lib/ui/compile/types/SemanticToken.ts +11 -0
  137. package/src/lib/ui/compile/types/TemplateAttr.ts +8 -2
  138. package/src/lib/ui/compile/types/TemplateNode.ts +15 -2
  139. package/src/lib/ui/createScope.ts +20 -2
  140. package/src/lib/ui/dom/appendText.ts +1 -5
  141. package/src/lib/ui/dom/applyResolved.ts +1 -5
  142. package/src/lib/ui/dom/disposeRange.ts +6 -10
  143. package/src/lib/ui/dom/hydrate.ts +2 -5
  144. package/src/lib/ui/dom/isComment.ts +6 -0
  145. package/src/lib/ui/dom/mount.ts +1 -2
  146. package/src/lib/ui/dom/withScope.ts +6 -9
  147. package/src/lib/ui/effect.ts +4 -2
  148. package/src/lib/ui/navigate.ts +40 -13
  149. package/src/lib/ui/outbox.ts +30 -110
  150. package/src/lib/ui/remoteProxy.ts +160 -10
  151. package/src/lib/ui/router.ts +3 -3
  152. package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +281 -0
  153. package/src/lib/ui/rpcOutbox/outboxRegistry.ts +69 -0
  154. package/src/lib/ui/runtime/createDoc.ts +3 -3
  155. package/src/lib/ui/runtime/pathSegments.ts +10 -0
  156. package/src/lib/ui/socketChannel.ts +7 -1
  157. package/src/lib/ui/types/Scope.ts +20 -5
  158. package/template/.zed/settings.json +4 -0
  159. package/template/CLAUDE.md +1 -1
  160. package/template/src/server/rpc/getHello.ts +3 -3
  161. package/template/src/ui/pages/page.abide +4 -4
  162. package/template/test/app.test.ts +2 -2
  163. package/src/lib/server/rpc/registerVerb.ts +0 -6
  164. package/src/lib/server/runtime/types/InspectorVerb.ts +0 -27
  165. package/src/lib/shared/detectVerbMethod.ts +0 -17
  166. package/src/lib/shared/types/HttpVerb.ts +0 -1
  167. package/src/lib/ui/types/Outbox.ts +0 -14
@@ -2,12 +2,14 @@ import ts from 'typescript'
2
2
  import { TS_PRINTER } from './TS_PRINTER.ts'
3
3
 
4
4
  /*
5
- Removes `effect(...)` calls from a script for the SSR back-end. Effects are client
5
+ Removes effect calls from a script for the SSR back-end. Effects are client
6
6
  lifecycle — they touch the DOM / run side effects and emit no HTML, so the server
7
7
  render (a snapshot of the pre-effect markup, like every framework) must not run
8
- them. Every `effect(<args>)` call is replaced by `undefined`, dropping its body:
9
- an `effect(() => …)` statement becomes a no-op, and a `const stop = effect(…)`
10
- binding keeps a defined (unused) name. Client compilation keeps effects untouched.
8
+ them. Both surfaces are stripped: the generated/runtime bare `effect(<args>)` and
9
+ the authored scope form `scope().effect(<args>)` (and a captured/destructured handle
10
+ `c.effect(<args>)`), each replaced by `undefined` an `effect(() => …)` statement
11
+ becomes a no-op, and a `const stop = effect(…)` binding keeps a defined (unused) name.
12
+ Client compilation keeps effects untouched.
11
13
  */
12
14
  export function stripEffects(code: string): string {
13
15
  const source = ts.createSourceFile('script.ts', code, ts.ScriptTarget.Latest, true)
@@ -23,11 +25,7 @@ export function stripEffects(code: string): string {
23
25
  export function stripEffectsTransformer(): ts.TransformerFactory<ts.SourceFile> {
24
26
  return (context) => (root) => {
25
27
  const visit = (node: ts.Node): ts.Node => {
26
- if (
27
- ts.isCallExpression(node) &&
28
- ts.isIdentifier(node.expression) &&
29
- node.expression.text === 'effect'
30
- ) {
28
+ if (ts.isCallExpression(node) && isEffectCallee(node.expression)) {
31
29
  return ts.factory.createIdentifier('undefined')
32
30
  }
33
31
  return ts.visitEachChild(node, visit, context)
@@ -35,3 +33,13 @@ export function stripEffectsTransformer(): ts.TransformerFactory<ts.SourceFile>
35
33
  return ts.visitNode(root, visit) as ts.SourceFile
36
34
  }
37
35
  }
36
+
37
+ /* An effect callee: the bare runtime helper (`effect`) or the scope-method form
38
+ (`scope().effect`, `c.effect`) the author writes. Receiver-agnostic on the `.effect`
39
+ member name, matching `effect` being reserved as a scope primitive. */
40
+ function isEffectCallee(expression: ts.Expression): boolean {
41
+ if (ts.isIdentifier(expression)) {
42
+ return expression.text === 'effect'
43
+ }
44
+ return ts.isPropertyAccessExpression(expression) && expression.name.text === 'effect'
45
+ }
@@ -0,0 +1,101 @@
1
+ import type { SemanticToken } from './types/SemanticToken.ts'
2
+
3
+ /*
4
+ Lexical highlighting for `{#…}`/`{:…}`/`{/…}` control-flow framing — the part the
5
+ HTML grammar sees only as text and the shadow program lowers away. A pure scan of
6
+ raw source (independent of a successful parse, so it survives mid-edit), it emits
7
+ an `operator` token for the `{`+sigil opener and a `keyword` token for the block
8
+ word, plus the `of`/`by` connectors inside a `{#for …}` head. Expression interiors
9
+ (and the `{@const}`/`{@html}` tags and bare `{expr}` interpolations) are NOT touched
10
+ here — those are the shadow's job. A keyword allowlist after the sigil prevents
11
+ matching arbitrary `{:foo}` text. Longest phrases first so `for await` beats `for`
12
+ and `else if` beats `else`.
13
+ */
14
+ const BLOCK_KEYWORDS = [
15
+ 'for await',
16
+ 'else if',
17
+ 'if',
18
+ 'for',
19
+ 'await',
20
+ 'switch',
21
+ 'case',
22
+ 'default',
23
+ 'try',
24
+ 'catch',
25
+ 'finally',
26
+ 'then',
27
+ 'else',
28
+ ]
29
+
30
+ const BLOCK_HEAD = new RegExp(`\\{([#:/])\\s*(${BLOCK_KEYWORDS.join('|')})\\b`, 'g')
31
+
32
+ export function structuralBlockTokens(source: string): SemanticToken[] {
33
+ const tokens: SemanticToken[] = []
34
+ for (const match of source.matchAll(BLOCK_HEAD)) {
35
+ const braceStart = match.index
36
+ const sigil = match[1]
37
+ const keyword = match[2]
38
+ if (keyword === undefined) {
39
+ continue
40
+ }
41
+ const keywordStart = braceStart + match[0].length - keyword.length
42
+ tokens.push({ start: braceStart, length: 2, type: 'operator', modifiers: [] })
43
+ tokens.push({ start: keywordStart, length: keyword.length, type: 'keyword', modifiers: [] })
44
+ /* A `{#for …}` head carries abide-only connectors `of`/`by` that the shadow
45
+ lowers away (`of`) or never emits (`by`), so color them here. */
46
+ if (sigil === '#' && (keyword === 'for' || keyword === 'for await')) {
47
+ tokens.push(...forHeadConnectors(source, keywordStart + keyword.length))
48
+ }
49
+ }
50
+ return tokens
51
+ }
52
+
53
+ /*
54
+ Scans a `{#for …}` head from `from` to its closing `}`, emitting `keyword` tokens
55
+ for the `of`/`by` connectors at brace depth 0 — skipping any `of`/`by` nested in a
56
+ destructure, call, or string so an identifier or object key is never miscolored.
57
+ */
58
+ function forHeadConnectors(source: string, from: number): SemanticToken[] {
59
+ const tokens: SemanticToken[] = []
60
+ let depth = 0
61
+ let cursor = from
62
+ while (cursor < source.length) {
63
+ const char = source.charAt(cursor)
64
+ if (char === '"' || char === "'" || char === '`') {
65
+ cursor += 1
66
+ while (cursor < source.length && source.charAt(cursor) !== char) {
67
+ if (source.charAt(cursor) === '\\') {
68
+ cursor += 1
69
+ }
70
+ cursor += 1
71
+ }
72
+ } else if (char === '{' || char === '(' || char === '[') {
73
+ depth += 1
74
+ } else if (char === ')' || char === ']') {
75
+ depth -= 1
76
+ } else if (char === '}') {
77
+ if (depth === 0) {
78
+ break
79
+ }
80
+ depth -= 1
81
+ } else if (depth === 0 && (char === 'o' || char === 'b')) {
82
+ const word = source.startsWith('of', cursor)
83
+ ? 'of'
84
+ : source.startsWith('by', cursor)
85
+ ? 'by'
86
+ : undefined
87
+ const isWordBoundary = (offset: number): boolean => /\s/.test(source.charAt(offset))
88
+ if (
89
+ word !== undefined &&
90
+ isWordBoundary(cursor - 1) &&
91
+ isWordBoundary(cursor + word.length)
92
+ ) {
93
+ tokens.push({ start: cursor, length: word.length, type: 'keyword', modifiers: [] })
94
+ cursor += word.length
95
+ continue
96
+ }
97
+ }
98
+ cursor += 1
99
+ }
100
+ return tokens
101
+ }
@@ -0,0 +1,22 @@
1
+ import type { TemplateNode } from './types/TemplateNode.ts'
2
+
3
+ type Case = Extract<TemplateNode, { kind: 'case' }>
4
+
5
+ /* The structural shape of a `switch`, resolved once so the build and SSR back-ends share one
6
+ reading of its cases and only own emission. Both previously recomputed the `case` filter;
7
+ SSR additionally located the match-less default. */
8
+ export type SwitchPlan = {
9
+ /* The `case` branches in source order (`match` set; the default leaves it unset). */
10
+ cases: Case[]
11
+ /* The match-less default branch, if present. */
12
+ fallback: Case | undefined
13
+ }
14
+
15
+ /* Resolves a `switch` node's cases into the shared structural plan. */
16
+ export function switchPlan(node: Extract<TemplateNode, { kind: 'switch' }>): SwitchPlan {
17
+ const cases = node.children.filter((child): child is Case => child.kind === 'case')
18
+ return {
19
+ cases,
20
+ fallback: cases.find((branch) => branch.match === undefined),
21
+ }
22
+ }
@@ -0,0 +1,29 @@
1
+ import { resolveBranches } from './resolveBranches.ts'
2
+ import type { TemplateNode } from './types/TemplateNode.ts'
3
+
4
+ /* The structural shape of a `try` error boundary, resolved once so the build and SSR
5
+ back-ends share one reading and only own emission. Both previously recomputed branch
6
+ resolution, the `finallyChildren` default, the guarded (children-minus-branch) split, and
7
+ the catch binding / presence. */
8
+ export type TryPlan = {
9
+ /* The guarded content — every child except the `catch`/`finally` branch nodes. */
10
+ guarded: TemplateNode[]
11
+ /* The catch content + its bound name (`_error` default); empty when no catch branch. */
12
+ catchChildren: TemplateNode[]
13
+ catchAs: string
14
+ finallyChildren: TemplateNode[]
15
+ /* No catch → a throw propagates to the enclosing boundary (re-throw / `undefined` thunk). */
16
+ hasCatch: boolean
17
+ }
18
+
19
+ /* Resolves a `try` node's branches into the shared structural plan. */
20
+ export function tryPlan(node: Extract<TemplateNode, { kind: 'try' }>): TryPlan {
21
+ const [catchBranch, finallyBranch] = resolveBranches(node, 'catch', 'finally')
22
+ return {
23
+ guarded: node.children.filter((child) => child.kind !== 'branch'),
24
+ catchChildren: catchBranch?.children ?? [],
25
+ catchAs: catchBranch?.as ?? '_error',
26
+ finallyChildren: finallyBranch?.children ?? [],
27
+ hasCatch: catchBranch !== undefined,
28
+ }
29
+ }
@@ -0,0 +1,11 @@
1
+ /*
2
+ A semantic-highlighting token in original `.abide` source coordinates. `type` and
3
+ `modifiers` are legend names (see ABIDE_SEMANTIC_TOKENS_LEGEND), resolved to wire
4
+ indices only at encode time.
5
+ */
6
+ export type SemanticToken = {
7
+ start: number
8
+ length: number
9
+ type: string
10
+ modifiers: string[]
11
+ }
@@ -13,9 +13,15 @@ absolute offset of `code` in the original `.abide` source (see TextPart) —
13
13
  optional, set only when the parser tracks positions for the type-checking shadow.
14
14
  */
15
15
  export type TemplateAttr =
16
- | { kind: 'static'; name: string; value: string; bare?: true }
17
- | { kind: 'expression'; name: string; code: string; loc?: number }
16
+ | { kind: 'static'; name: string; value: string; bare?: true; nameLoc?: number }
17
+ | { kind: 'expression'; name: string; code: string; loc?: number; nameLoc?: number }
18
18
  | { kind: 'event'; event: string; code: string; loc?: number }
19
19
  | { kind: 'bind'; property: string; code: string; loc?: number }
20
+ /* `class:<name>={code}` — toggles the class on/off by `code`'s truthiness, written
21
+ surgically (classList.toggle) without re-rendering the element. */
22
+ | { kind: 'class'; name: string; code: string; loc?: number }
23
+ /* `style:<property>={code}` — writes one inline style/custom property (style.setProperty)
24
+ reactively, again without a re-render. `property` may be a CSS custom prop (`--pct`). */
25
+ | { kind: 'style'; property: string; code: string; loc?: number }
20
26
  | { kind: 'attach'; code: string; loc?: number }
21
27
  | { kind: 'spread'; code: string; loc?: number }
@@ -40,6 +40,11 @@ export type TemplateNode =
40
40
  async: boolean
41
41
  children: TemplateNode[]
42
42
  loc?: number
43
+ /* Source offsets of the binding name, `by` key, and index — so the shadow
44
+ maps hover/highlighting onto them (absent for synthesised/missing parts). */
45
+ asLoc?: number
46
+ keyLoc?: number
47
+ indexLoc?: number
43
48
  }
44
49
  | { kind: 'if'; condition: string; children: TemplateNode[]; loc?: number }
45
50
  | {
@@ -52,6 +57,8 @@ export type TemplateNode =
52
57
  as: string | undefined
53
58
  children: TemplateNode[]
54
59
  loc?: number
60
+ /* Source offset of an inline blocking `then` binding (`{#await p then v}`). */
61
+ asLoc?: number
55
62
  }
56
63
  | { kind: 'try'; children: TemplateNode[] }
57
64
  | {
@@ -59,13 +66,19 @@ export type TemplateNode =
59
66
  branch: 'then' | 'catch' | 'finally'
60
67
  as: string | undefined
61
68
  children: TemplateNode[]
69
+ /* Source offset of the `then`/`catch` binding, so the shadow maps it. */
70
+ asLoc?: number
62
71
  }
63
72
  | {
64
73
  kind: 'component'
65
74
  name: string
75
+ /* Source offset of the tag name — the anchor for a whole-mount diagnostic
76
+ (a missing required prop, which has no supplied expression to point at). */
77
+ loc?: number
66
78
  /* Each authored attribute as a prop. A `spread` entry (`{...code}`) carries no
67
- `name`; its keys merge in at runtime (`mergeProps`/`spreadProps`). */
68
- props: { name: string; code: string; loc?: number; spread?: boolean }[]
79
+ `name`; its keys merge in at runtime (`mergeProps`/`spreadProps`). `nameLoc` is
80
+ the prop name's source offset the anchor for an excess-prop diagnostic. */
81
+ props: { name: string; code: string; loc?: number; nameLoc?: number; spread?: boolean }[]
69
82
  children: TemplateNode[]
70
83
  }
71
84
  | { kind: 'switch'; subject: string; children: TemplateNode[]; loc?: number }
@@ -1,4 +1,5 @@
1
1
  import { computed } from './computed.ts'
2
+ import { effect } from './effect.ts'
2
3
  import { history } from './history.ts'
3
4
  import { linked } from './linked.ts'
4
5
  import { persist as persistDoc } from './persist.ts'
@@ -37,6 +38,10 @@ export function createScope(
37
38
  const data = (): Doc => (document ??= createDoc({}))
38
39
  const id = parent === undefined ? `scope-${nextId++}` : `${parent.id}.${nextId++}`
39
40
  const children: Scope[] = []
41
+ /* Adopted build teardowns (the reactivity stopper from the mount core). Disposed
42
+ first and in reverse on teardown, before children and capabilities — so the one
43
+ `dispose` runs the order the call sites hand-composed as `stop(); lexical.dispose()`. */
44
+ const owned: Array<() => void> = []
40
45
  /* Context values shared down the tree, held apart from the reactive doc (which
41
46
  a child does not inherit): keyed by name, read by the closest ancestor walk. */
42
47
  const shared = new Map<string, unknown>()
@@ -56,11 +61,17 @@ export function createScope(
56
61
  cell: (path) => data().cell(path),
57
62
  derive: (path, compute) => data().derive(path, compute),
58
63
  snapshot: () => data().snapshot(),
59
- /* The `.value`-cell signal forms, namespaced under the scope standalone
60
- non-serializing cells (owned by the render scope), reached only here. */
64
+ /* The reactive primitives namespaced under the scope but AMBIENT-bound, not
65
+ receiver-bound: each binds whatever scope is rendering and the finest ambient
66
+ build window (branch/row), so the handle is namespacing, not a binding target.
67
+ Binding to the receiver would leak branch-local cells (see ADR-0012). */
61
68
  state,
62
69
  linked,
63
70
  computed,
71
+ effect,
72
+ own: (dispose) => {
73
+ owned.push(dispose)
74
+ },
64
75
  child: (childInitial = {}) => {
65
76
  const created = createScope(childInitial, self)
66
77
  children.push(created)
@@ -91,6 +102,13 @@ export function createScope(
91
102
  canUndo: () => past?.canUndo() ?? false,
92
103
  canRedo: () => past?.canRedo() ?? false,
93
104
  dispose: () => {
105
+ /* Stop the build's reactivity first (reverse order), before tearing down nested
106
+ children and the boundary-crossing capabilities — the order the call sites
107
+ hand-composed as `stop(); lexical.dispose()`. */
108
+ for (let index = owned.length - 1; index >= 0; index -= 1) {
109
+ owned[index]?.()
110
+ }
111
+ owned.length = 0
94
112
  for (const created of children) {
95
113
  created.dispose()
96
114
  }
@@ -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 { isComment } from './isComment.ts'
7
8
  import { parseRawNodes } from './parseRawNodes.ts'
8
9
 
9
10
  const CLOSE = '/abide:html'
@@ -106,8 +107,3 @@ function appendRawHtml(parent: Node, read: () => unknown): void {
106
107
  set(markup())
107
108
  })
108
109
  }
109
-
110
- /* A comment node carrying exactly `data`. */
111
- function isComment(node: Node, data: string): boolean {
112
- return (node as { data?: string }).data === data && node.childNodes.length === 0
113
- }
@@ -1,4 +1,5 @@
1
1
  import { seedResolved } from '../seedResolved.ts'
2
+ import { isComment } from './isComment.ts'
2
3
 
3
4
  /*
4
5
  Bundle-side consumer of an SSR stream chunk, the counterpart of the doc stream's inline
@@ -71,11 +72,6 @@ export function applyResolved(root: Element, frame: string): void {
71
72
  }
72
73
  }
73
74
 
74
- /* A comment node carrying exactly `data`. */
75
- function isComment(node: Node, data: string): boolean {
76
- return (node as { data?: string }).data === data && node.childNodes.length === 0
77
- }
78
-
79
75
  /* Depth-first search for the parent + open-marker comment of a boundary. */
80
76
  function findBoundary(node: Node, open: string): { parent: Node; start: Node } | undefined {
81
77
  for (const child of [...node.childNodes]) {
@@ -3,18 +3,14 @@ import { clearBetween } from './clearBetween.ts'
3
3
 
4
4
  /*
5
5
  The standard teardown for a marker-bounded range (component, layout/page boundary,
6
- slot): stop the content's reactivity, dispose its lexical scope, and clear the nodes
7
- between the markers leaving the markers in place so a hot swap rebuilds the range.
8
- Shared by every range mount (`fillRange`, `mountRange`, `fillBoundary`) so the one
9
- disposer contract lives in a single place.
6
+ slot): dispose its lexical scope (which stops the content's reactivity first, then its
7
+ nested children and capabilities), and clear the nodes between the markers leaving the
8
+ markers in place so a hot swap rebuilds the range. Shared by every range mount
9
+ (`fillRange`, `mountRange`, `fillBoundary`) so the one disposer contract lives in a
10
+ single place.
10
11
  */
11
- export function disposeRange(
12
- scoped: { stop: () => void; lexical: Scope },
13
- start: Comment,
14
- end: Comment,
15
- ): () => void {
12
+ export function disposeRange(scoped: { lexical: Scope }, start: Comment, end: Comment): () => void {
16
13
  return () => {
17
- scoped.stop()
18
14
  scoped.lexical.dispose()
19
15
  clearBetween(start, end)
20
16
  }
@@ -29,11 +29,8 @@ export function hydrate(
29
29
  try {
30
30
  /* Same shared mount core as `mount` (see `withScope`) — a hydrated component owns a
31
31
  scope too, adopting the model its build adopts — run with the claim cursor active. */
32
- const { stop, lexical } = withScope(scopeLabel(host), () => scope(() => build(host, props)))
33
- return () => {
34
- stop()
35
- lexical.dispose()
36
- }
32
+ const { lexical } = withScope(scopeLabel(host), () => scope(() => build(host, props)))
33
+ return () => lexical.dispose()
37
34
  } finally {
38
35
  RENDER.hydration = previous
39
36
  }
@@ -0,0 +1,6 @@
1
+ /* A comment node carrying exactly `data`. Empty-child check distinguishes the
2
+ `<!--data-->` marker from an element that happens to expose a `data` property.
3
+ Shared by the marker-range scanners in appendText/applyResolved. */
4
+ export function isComment(node: Node, data: string): boolean {
5
+ return (node as { data?: string }).data === data && node.childNodes.length === 0
6
+ }
@@ -23,9 +23,8 @@ export function mount(
23
23
  /* Establish this component's lexical scope (nested, `awaiting` so it adopts the model
24
24
  doc the build's first `doc()` creates) and render pass, run the build under it, and
25
25
  restore the previous scope — the shared mount core (see `withScope`). */
26
- const { stop, lexical } = withScope(scopeLabel(host), () => scope(() => build(host, props)))
26
+ const { lexical } = withScope(scopeLabel(host), () => scope(() => build(host, props)))
27
27
  return () => {
28
- stop()
29
28
  lexical.dispose()
30
29
  host.textContent = ''
31
30
  }
@@ -11,23 +11,20 @@ under the current one in `awaiting` mode — so it adopts the model doc its firs
11
11
  creates — brackets a render pass — so its `await`/`try` block ids draw from the shared
12
12
  counter in SSR-stream order — runs `build`, then restores the previous scope (synchronous
13
13
  build, so the restore is exact). `build` returns its reactivity stopper (from `scope` or
14
- `fillBefore`); the caller composes its own DOM teardown (clear host vs clear range) around
15
- the returned `stop`/`lexical`, keeping the one scope/render-pass contract in a single place.
14
+ `fillBefore`), which the lexical scope ADOPTS (`own`) so the scope has a single teardown:
15
+ the caller wraps its own DOM teardown (clear host vs clear range) around one
16
+ `lexical.dispose()`, no longer composing a separate `stop()` at every site.
16
17
  */
17
- export function withScope(
18
- label: string | undefined,
19
- build: () => () => void,
20
- ): { stop: () => void; lexical: Scope } {
18
+ export function withScope(label: string | undefined, build: () => () => void): { lexical: Scope } {
21
19
  const parentScope = CURRENT_SCOPE.current
22
20
  const lexical = createScope({}, parentScope, true, label)
23
21
  enterRenderPass()
24
22
  CURRENT_SCOPE.current = lexical
25
- let stop: () => void = () => undefined
26
23
  try {
27
- stop = build()
24
+ lexical.own(build())
28
25
  } finally {
29
26
  exitRenderPass()
30
27
  CURRENT_SCOPE.current = parentScope
31
28
  }
32
- return { stop, lexical }
29
+ return { lexical }
33
30
  }
@@ -10,9 +10,11 @@ dispose — and may be async (its teardown then runs once the promise settles, b
10
10
  only the reads before its first `await` are tracked). Returns a dispose that runs
11
11
  the final teardown and detaches it from the graph. This is abide's from-scratch
12
12
  effect primitive: the open-on-first-read / close-on-last-reader lifecycle,
13
- grounded in abide's own reactive core.
13
+ grounded in abide's own reactive core. Authored as `scope().effect(...)` (the bare
14
+ name is reserved); this module export stays for the generated binding code that
15
+ calls it directly (and for the SSR strip), so it carries no standalone demo.
14
16
  */
15
- // @documentation effect
17
+ // @documentation plumbing
16
18
  export function effect(fn: () => EffectResult): () => void {
17
19
  /* Re-runs fire after the build, so pin the scope this effect was created under
18
20
  (the dep-tracking observer is managed separately, so this can't disturb it). */
@@ -1,3 +1,6 @@
1
+ import { parseRouteSegments } from '../shared/parseRouteSegments.ts'
2
+ import type { PageRoutes, PathParams } from '../shared/url.ts'
3
+ import { url } from '../shared/url.ts'
1
4
  import { historyEntries } from './runtime/historyEntries.ts'
2
5
  import { runtimePath } from './runtime/runtimePath.ts'
3
6
 
@@ -8,21 +11,19 @@ export type NavigateOptions = {
8
11
  keepScroll?: boolean
9
12
  }
10
13
 
11
- /* Navigates to `path`: writes a history entry (when available) and updates the
12
- reactive route, which re-mounts the matching page via `router`. `replace` swaps
13
- the current entry instead of pushing used when honouring a server redirect, so
14
- the blocked URL isn't left behind in history. Each entry carries a monotonic
15
- `abideEntry` id so the router can bucket/restore its scroll offset across the page
16
- teardown the rebuild does. A push leaves the current entry behind its scroll is
17
- bucketed so back restores it and mints a fresh id. A replace destroys the current
18
- entry and lands fresh content (a redirect), so its saved scroll no longer applies:
14
+ /* Writes an ALREADY-RESOLVED path into history + the reactive route. The router uses this
15
+ for paths it built from a real URL (a clicked link, a popstate, a server redirect) — they
16
+ already carry the mount base, so they must NOT pass through `url()` again. Each entry
17
+ carries a monotonic `abideEntry` id so the router can bucket/restore its scroll offset
18
+ across the page teardown the rebuild does. A push leaves the current entry behind — its
19
+ scroll is bucketed so back restores it and mints a fresh id. A replace destroys the
20
+ current entry and lands fresh content (a redirect), so its saved scroll no longer applies:
19
21
  the bucket is discarded and the id kept, so the new page restores to top/anchor.
20
22
  `keepScroll` opts the destination out of that reset — for an in-page URL swap (e.g.
21
- selecting another episode on the same detail page) where a top jump is jarring: the
22
- live offset is bucketed under the destination entry id, so the post-rebuild `restore`
23
- reapplies it instead of scrolling to top. */
24
- // @documentation navigate
25
- export function navigate(
23
+ selecting another episode on the same detail page) where a top jump is jarring: the live
24
+ offset is bucketed under the destination entry id, so the post-rebuild `restore` reapplies
25
+ it instead of scrolling to top. */
26
+ export function navigatePath(
26
27
  path: string,
27
28
  { replace = false, keepScroll = false }: NavigateOptions = {},
28
29
  ): void {
@@ -48,3 +49,29 @@ export function navigate(
48
49
  }
49
50
  runtimePath.value = path
50
51
  }
52
+
53
+ /* Navigates to a typed in-app path: a route literal with `[name]` segments takes its params
54
+ first (`navigate('/p/[id]', { id })`), then options; a paramless or already-interpolated
55
+ path takes options directly (`navigate('/p', { replace })`). The path is built through
56
+ `url()` — base-correct, params interpolated — exactly as a link href would be, so a
57
+ dynamic `/p/${x}` falls through url()'s paramless branch and is base-prefixed too. */
58
+ // @documentation navigate
59
+ export function navigate<P extends keyof PageRoutes | (string & {})>(
60
+ path: P,
61
+ ...rest: keyof PathParams<P> extends never
62
+ ? [options?: NavigateOptions]
63
+ : [params: PathParams<P>, options?: NavigateOptions]
64
+ ): void {
65
+ /* A path declaring `[name]` segments takes params first, options second — mirroring
66
+ url()'s own arg discrimination; otherwise the first arg is options. */
67
+ const hasParams = parseRouteSegments(path).some((segment) => segment.kind === 'param')
68
+ const [first, second] = rest as [unknown, NavigateOptions?]
69
+ /* Params ride url()'s first vararg slot — a path with `[name]` segments interpolates
70
+ them, a paramless one ignores undefined and just base-prefixes. Widen `path` to
71
+ string so url() takes its plain-path overload; its runtime keys off the segments. */
72
+ const params = hasParams
73
+ ? (first as Record<string, string | number | boolean | undefined>)
74
+ : undefined
75
+ const options = (hasParams ? second : (first as NavigateOptions | undefined)) ?? {}
76
+ navigatePath(url(path as string, params), options)
77
+ }