@abide/abide 0.49.0 → 0.50.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 (325) hide show
  1. package/AGENTS.md +70 -52
  2. package/CHANGELOG.md +177 -0
  3. package/README.md +12 -7
  4. package/package.json +14 -5
  5. package/src/abideLsp.ts +5 -6
  6. package/src/abideResolverPlugin.ts +197 -169
  7. package/src/build.ts +79 -56
  8. package/src/buildDisconnected.ts +0 -2
  9. package/src/checkAbide.ts +12 -2
  10. package/src/devEntry.ts +113 -28
  11. package/src/discoveryEntry.ts +3 -2
  12. package/src/lib/bundle/disconnected.abide +69 -73
  13. package/src/lib/bundle/infoPlist.ts +13 -7
  14. package/src/lib/bundle/spawnEmbeddedServer.ts +13 -4
  15. package/src/lib/bundle/waitForServer.ts +6 -1
  16. package/src/lib/mcp/mcpTools.ts +15 -6
  17. package/src/lib/server/error.ts +6 -6
  18. package/src/lib/server/json.ts +17 -3
  19. package/src/lib/server/rpc/defineRpc.ts +58 -24
  20. package/src/lib/server/rpc/parseArgs.ts +98 -13
  21. package/src/lib/server/rpc/resolveRpcJsonSchema.ts +28 -0
  22. package/src/lib/server/rpc/types/RpcHelper.ts +75 -105
  23. package/src/lib/server/rpc/types/RpcRegistryEntry.ts +24 -9
  24. package/src/lib/server/rpc/validationError.ts +1 -1
  25. package/src/lib/server/runtime/DEV_RELOAD_CLIENT_SCRIPT.ts +0 -15
  26. package/src/lib/server/runtime/buildCacheSnapshot.ts +10 -9
  27. package/src/lib/server/runtime/buildInspectorSurface.ts +6 -4
  28. package/src/lib/server/runtime/buildOpenApiSpec.ts +55 -11
  29. package/src/lib/server/runtime/buildPreloadManifest.ts +12 -10
  30. package/src/lib/server/runtime/createAppAssetServer.ts +18 -15
  31. package/src/lib/server/runtime/createAppRouteResolver.ts +145 -0
  32. package/src/lib/server/runtime/createPlumbingRouter.ts +212 -0
  33. package/src/lib/server/runtime/createRouteDispatcher.ts +4 -10
  34. package/src/lib/server/runtime/createServer.ts +120 -324
  35. package/src/lib/server/runtime/createUiPageRenderer.ts +137 -26
  36. package/src/lib/server/runtime/devClientFingerprint.ts +19 -34
  37. package/src/lib/server/runtime/installAmbientScopeStore.ts +30 -9
  38. package/src/lib/server/runtime/logExposedSurfaces.ts +8 -7
  39. package/src/lib/server/runtime/pathStore.ts +15 -0
  40. package/src/lib/server/runtime/registryManifests.ts +1 -1
  41. package/src/lib/server/runtime/renderCellBarrierStore.ts +15 -0
  42. package/src/lib/server/runtime/runWithRequestScope.ts +3 -0
  43. package/src/lib/server/runtime/serializeCacheSnapshot.ts +6 -4
  44. package/src/lib/server/runtime/snapshotEntryFromCache.ts +14 -14
  45. package/src/lib/server/runtime/streamCacheResolutions.ts +14 -7
  46. package/src/lib/server/runtime/streamFromIterator.ts +30 -2
  47. package/src/lib/server/runtime/textResponse.ts +23 -0
  48. package/src/lib/server/runtime/types/DevReloadStamp.ts +6 -10
  49. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  50. package/src/lib/server/runtime/types/RequestStore.ts +27 -0
  51. package/src/lib/server/runtime/warnUnguardedMcp.ts +15 -5
  52. package/src/lib/server/sockets/createSocketDispatcher.ts +9 -11
  53. package/src/lib/server/sockets/defineSocket.ts +5 -4
  54. package/src/lib/server/sse.ts +5 -1
  55. package/src/lib/shared/ASYNC_CELL.ts +5 -0
  56. package/src/lib/shared/DEV_REBUILD_PATH.ts +6 -0
  57. package/src/lib/shared/HTTP_METHODS.ts +6 -0
  58. package/src/lib/shared/HttpError.ts +1 -5
  59. package/src/lib/shared/MCP_PATH.ts +6 -0
  60. package/src/lib/shared/PROXIED_SERVER_SUBDIRS.ts +15 -0
  61. package/src/lib/shared/REF_JSON_TAGS.ts +13 -1
  62. package/src/lib/shared/RPC_SHIM_GLOBALS.ts +9 -0
  63. package/src/lib/shared/activeCacheStore.ts +1 -0
  64. package/src/lib/shared/activePage.ts +1 -0
  65. package/src/lib/shared/buildArtifact.ts +4 -1
  66. package/src/lib/shared/buildSocketOverChannel.ts +4 -3
  67. package/src/lib/shared/bundleGraphFromMetafile.ts +68 -0
  68. package/src/lib/shared/cache.ts +156 -131
  69. package/src/lib/shared/changeAffectsClient.ts +12 -7
  70. package/src/lib/shared/createCacheStore.ts +31 -8
  71. package/src/lib/shared/createLifecycleChannel.ts +7 -1
  72. package/src/lib/shared/createReachable.ts +47 -78
  73. package/src/lib/shared/createRemoteFunction.ts +52 -30
  74. package/src/lib/shared/createRpcServerProgram.ts +820 -0
  75. package/src/lib/shared/decodeRefJson.ts +4 -4
  76. package/src/lib/shared/decodeResponse.ts +2 -2
  77. package/src/lib/shared/decodeWireBody.ts +35 -0
  78. package/src/lib/shared/detectRpcMethod.ts +8 -1
  79. package/src/lib/shared/done.ts +8 -2
  80. package/src/lib/shared/encodeRefJson.ts +4 -4
  81. package/src/lib/shared/encodeWireBody.ts +18 -0
  82. package/src/lib/{server/rpc → shared}/fieldErrorsFromIssues.ts +5 -1
  83. package/src/lib/shared/generateDeclarations.ts +72 -0
  84. package/src/lib/shared/hasSeedableRequest.ts +25 -0
  85. package/src/lib/shared/hydrationWindow.ts +53 -0
  86. package/src/lib/shared/isAsyncCell.ts +11 -0
  87. package/src/lib/shared/isAsyncIterable.ts +8 -0
  88. package/src/lib/shared/isSubscribable.ts +3 -3
  89. package/src/lib/shared/isThenable.ts +9 -0
  90. package/src/lib/shared/jsonSchemaForType.ts +258 -0
  91. package/src/lib/shared/lenientDecode.ts +15 -0
  92. package/src/lib/shared/loadProjectTsConfig.ts +28 -0
  93. package/src/lib/shared/markFrameworkSourcesIgnored.ts +1 -1
  94. package/src/lib/shared/matchRoute.ts +4 -14
  95. package/src/lib/shared/peek.ts +14 -0
  96. package/src/lib/shared/pending.ts +9 -6
  97. package/src/lib/shared/pendingAsyncCellsSlot.ts +13 -0
  98. package/src/lib/shared/prepareRpcModule.ts +91 -93
  99. package/src/lib/shared/prepareSocketModule.ts +3 -2
  100. package/src/lib/shared/probeRegistries.ts +5 -18
  101. package/src/lib/shared/reachable.ts +7 -10
  102. package/src/lib/shared/refresh.ts +12 -2
  103. package/src/lib/shared/refreshing.ts +8 -2
  104. package/src/lib/shared/resolvedCellsSlot.ts +13 -0
  105. package/src/lib/shared/reviveWireField.ts +49 -0
  106. package/src/lib/shared/reviveWireOutput.ts +36 -0
  107. package/src/lib/shared/rpcServerForRoot.ts +25 -0
  108. package/src/lib/shared/scanPages.ts +25 -0
  109. package/src/lib/shared/snapshotShippable.ts +8 -7
  110. package/src/lib/shared/streamedCellsSlot.ts +14 -0
  111. package/src/lib/shared/subscribableFromResponse.ts +4 -4
  112. package/src/lib/shared/subscribableProbes.ts +1 -1
  113. package/src/lib/shared/tailProbeSlot.ts +1 -1
  114. package/src/lib/shared/types/AsyncComputed.ts +20 -0
  115. package/src/lib/shared/types/AsyncState.ts +13 -0
  116. package/src/lib/shared/types/CacheEntry.ts +7 -7
  117. package/src/lib/shared/types/CacheOptions.ts +23 -37
  118. package/src/lib/shared/types/CachePolicy.ts +25 -0
  119. package/src/lib/shared/types/CacheSnapshotEntry.ts +6 -5
  120. package/src/lib/shared/types/ErrorJsonSchemas.ts +8 -0
  121. package/src/lib/shared/types/HttpMethod.ts +3 -1
  122. package/src/lib/shared/types/InputCoercion.ts +17 -0
  123. package/src/lib/shared/types/{Subscribable.ts → NamedAsyncIterable.ts} +1 -1
  124. package/src/lib/shared/types/OutputWirePlan.ts +17 -0
  125. package/src/lib/shared/types/PagesScan.ts +7 -0
  126. package/src/lib/shared/types/PendingAsyncCells.ts +10 -0
  127. package/src/lib/shared/types/RawRemoteFunction.ts +6 -0
  128. package/src/lib/shared/types/RemoteCallable.ts +8 -7
  129. package/src/lib/shared/types/RemoteFunction.ts +16 -15
  130. package/src/lib/shared/types/ResolvedCells.ts +11 -0
  131. package/src/lib/shared/types/ReturnBody.ts +15 -0
  132. package/src/lib/shared/types/RpcBuildStamps.ts +23 -0
  133. package/src/lib/shared/types/RpcErrorGuard.ts +3 -8
  134. package/src/lib/shared/types/Socket.ts +4 -4
  135. package/src/lib/shared/types/SsrPayload.ts +5 -1
  136. package/src/lib/shared/types/StreamPolicy.ts +11 -0
  137. package/src/lib/shared/types/StreamedCells.ts +19 -0
  138. package/src/lib/shared/types/StreamedResolution.ts +24 -6
  139. package/src/lib/shared/types/TailHooks.ts +1 -1
  140. package/src/lib/shared/types/WireKind.ts +11 -0
  141. package/src/lib/shared/validationHttpError.ts +33 -0
  142. package/src/lib/shared/warmSeedKey.ts +16 -0
  143. package/src/lib/shared/wireJsonReplacer.ts +30 -0
  144. package/src/lib/shared/writeRpcDts.ts +11 -1
  145. package/src/lib/shared/writeTestSocketsDts.ts +1 -1
  146. package/src/lib/test/createTestApp.ts +19 -1
  147. package/src/lib/ui/README.md +1 -1
  148. package/src/lib/ui/activePendingCells.ts +14 -0
  149. package/src/lib/ui/compile/BLOCK_KEYWORDS.ts +21 -0
  150. package/src/lib/ui/compile/SSR_ESCAPE.ts +4 -1
  151. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +35 -8
  152. package/src/lib/ui/compile/abideUiPlugin.ts +19 -2
  153. package/src/lib/ui/compile/analyzeComponent.ts +89 -6
  154. package/src/lib/ui/compile/assignmentTargetNames.ts +54 -0
  155. package/src/lib/ui/compile/asyncInterpolationFields.ts +152 -0
  156. package/src/lib/ui/compile/asyncValuePositionError.ts +21 -0
  157. package/src/lib/ui/compile/asyncValuePositionInterpolations.ts +64 -0
  158. package/src/lib/ui/compile/attrLiftPosition.ts +18 -0
  159. package/src/lib/ui/compile/cachedSourceFile.ts +40 -0
  160. package/src/lib/ui/compile/catchBinding.ts +7 -5
  161. package/src/lib/ui/compile/classifyInterpolationType.ts +46 -0
  162. package/src/lib/ui/compile/collectAbideDiagnostics.ts +96 -0
  163. package/src/lib/ui/compile/compileComponent.ts +19 -3
  164. package/src/lib/ui/compile/compileModule.ts +27 -58
  165. package/src/lib/ui/compile/compileSSR.ts +51 -12
  166. package/src/lib/ui/compile/compileShadow.ts +145 -18
  167. package/src/lib/ui/compile/composeProps.ts +12 -2
  168. package/src/lib/ui/compile/createShadowLanguageService.ts +116 -68
  169. package/src/lib/ui/compile/createShadowProgram.ts +35 -6
  170. package/src/lib/ui/compile/declaredNames.ts +36 -0
  171. package/src/lib/ui/compile/desugarSignals.ts +418 -36
  172. package/src/lib/ui/compile/expressionIsPrefixEvaluable.ts +19 -0
  173. package/src/lib/ui/compile/generateBuild.ts +52 -9
  174. package/src/lib/ui/compile/generateSSR.ts +242 -43
  175. package/src/lib/ui/compile/hoistableAwaits.ts +193 -0
  176. package/src/lib/ui/compile/hoistableChildRenders.ts +112 -0
  177. package/src/lib/ui/compile/interpolatedTemplateLiteral.ts +3 -1
  178. package/src/lib/ui/compile/interpolationClassifierForRoot.ts +37 -0
  179. package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +174 -0
  180. package/src/lib/ui/compile/liftAsyncSubExpressions.ts +166 -0
  181. package/src/lib/ui/compile/lowerAsyncInterpolations.ts +92 -0
  182. package/src/lib/ui/compile/lowerContext.ts +10 -0
  183. package/src/lib/ui/compile/lowerDocAccess.ts +26 -23
  184. package/src/lib/ui/compile/lowerScript.ts +40 -4
  185. package/src/lib/ui/compile/nodeAtShadowOffset.ts +28 -0
  186. package/src/lib/ui/compile/parseTemplate.ts +18 -1092
  187. package/src/lib/ui/compile/parseTemplateRecovering.ts +1385 -0
  188. package/src/lib/ui/compile/referencedIdentifiers.ts +35 -0
  189. package/src/lib/ui/compile/renameSignalRefs.ts +21 -22
  190. package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +65 -0
  191. package/src/lib/ui/compile/shadowInterpolationClassifier.ts +47 -0
  192. package/src/lib/ui/compile/sourceFileOptionsSignature.ts +20 -0
  193. package/src/lib/ui/compile/structuralHeadTokens.ts +107 -0
  194. package/src/lib/ui/compile/templateSemanticTokens.ts +21 -0
  195. package/src/lib/ui/compile/templateStartOffset.ts +15 -0
  196. package/src/lib/ui/compile/tryPlan.ts +4 -3
  197. package/src/lib/ui/compile/types/AnalyzedComponent.ts +4 -0
  198. package/src/lib/ui/compile/types/AsyncInterpolationField.ts +19 -0
  199. package/src/lib/ui/compile/types/InterpolationClassifier.ts +12 -0
  200. package/src/lib/ui/compile/types/InterpolationKind.ts +7 -0
  201. package/src/lib/ui/compile/types/ParseDiagnostic.ts +8 -0
  202. package/src/lib/ui/compile/types/SeedTypeClassifier.ts +15 -0
  203. package/src/lib/ui/compile/types/TemplateNode.ts +36 -4
  204. package/src/lib/ui/compile/types/ValuePositionInterpolation.ts +13 -0
  205. package/src/lib/ui/compile/writtenTemplateNames.ts +84 -0
  206. package/src/lib/ui/computed.ts +28 -1
  207. package/src/lib/ui/createScope.ts +35 -68
  208. package/src/lib/ui/dom/anchoredBranch.ts +142 -0
  209. package/src/lib/ui/dom/appendText.ts +8 -3
  210. package/src/lib/ui/dom/awaitBlock.ts +43 -94
  211. package/src/lib/ui/dom/bindProp.ts +22 -0
  212. package/src/lib/ui/dom/bindableProp.ts +47 -0
  213. package/src/lib/ui/dom/cellPending.ts +24 -0
  214. package/src/lib/ui/dom/disposeRange.ts +2 -1
  215. package/src/lib/ui/dom/each.ts +26 -4
  216. package/src/lib/ui/dom/eachAsync.ts +39 -3
  217. package/src/lib/ui/dom/fillBoundary.ts +2 -3
  218. package/src/lib/ui/dom/fillRange.ts +4 -4
  219. package/src/lib/ui/dom/hydrate.ts +6 -12
  220. package/src/lib/ui/dom/isComment.ts +1 -1
  221. package/src/lib/ui/dom/matchingRangeClose.ts +29 -0
  222. package/src/lib/ui/dom/mount.ts +1 -2
  223. package/src/lib/ui/dom/mountChild.ts +9 -40
  224. package/src/lib/ui/dom/mountRange.ts +2 -3
  225. package/src/lib/ui/dom/mountStreamedChild.ts +84 -0
  226. package/src/lib/ui/dom/mountSwappableRange.ts +46 -4
  227. package/src/lib/ui/dom/mutateDocContainer.ts +65 -0
  228. package/src/lib/ui/dom/on.ts +2 -2
  229. package/src/lib/ui/dom/readCell.ts +40 -0
  230. package/src/lib/ui/dom/switchBlock.ts +30 -7
  231. package/src/lib/ui/dom/tryBlock.ts +230 -66
  232. package/src/lib/ui/dom/types/SwitchCase.ts +5 -1
  233. package/src/lib/ui/dom/when.ts +12 -2
  234. package/src/lib/ui/dom/withScope.ts +2 -2
  235. package/src/lib/ui/finalizeStreamedChildren.ts +89 -0
  236. package/src/lib/ui/flight.ts +56 -0
  237. package/src/lib/ui/html.ts +12 -1
  238. package/src/lib/ui/isolateCellBarrier.ts +17 -0
  239. package/src/lib/ui/linked.ts +48 -2
  240. package/src/lib/ui/remoteProxy.ts +84 -159
  241. package/src/lib/ui/renderChain.ts +49 -13
  242. package/src/lib/ui/renderToStream.ts +22 -18
  243. package/src/lib/ui/resumeSeedScript.ts +1 -1
  244. package/src/lib/ui/router.ts +63 -36
  245. package/src/lib/ui/runtime/AsyncCellError.ts +20 -0
  246. package/src/lib/ui/runtime/CELL_SEED.ts +14 -0
  247. package/src/lib/ui/runtime/CURRENT_BOUNDARY.ts +11 -0
  248. package/src/lib/ui/runtime/CURRENT_PATH.ts +29 -0
  249. package/src/lib/ui/runtime/RENDER.ts +10 -7
  250. package/src/lib/ui/runtime/RESUME.ts +4 -4
  251. package/src/lib/ui/runtime/STREAMED_CELLS.ts +57 -0
  252. package/src/lib/ui/runtime/ambientPathBacking.ts +32 -0
  253. package/src/lib/ui/runtime/applyPatchToTree.ts +1 -1
  254. package/src/lib/ui/runtime/blockId.ts +31 -0
  255. package/src/lib/ui/runtime/boundaryFor.ts +11 -0
  256. package/src/lib/ui/runtime/cellBarrierBacking.ts +30 -0
  257. package/src/lib/ui/runtime/createAsyncCell.ts +300 -0
  258. package/src/lib/ui/runtime/createDoc.ts +3 -42
  259. package/src/lib/ui/runtime/createEffectNode.ts +9 -0
  260. package/src/lib/ui/runtime/enterRenderPass.ts +5 -4
  261. package/src/lib/ui/runtime/flushEffects.ts +16 -6
  262. package/src/lib/ui/runtime/isAsyncFunction.ts +14 -0
  263. package/src/lib/ui/runtime/nextBlockId.ts +8 -7
  264. package/src/lib/ui/runtime/renderPath.ts +16 -0
  265. package/src/lib/ui/runtime/types/Boundary.ts +9 -0
  266. package/src/lib/ui/runtime/types/RenderContext.ts +9 -7
  267. package/src/lib/ui/runtime/types/SsrRender.ts +9 -2
  268. package/src/lib/ui/runtime/types/UiComponent.ts +1 -5
  269. package/src/lib/ui/runtime/withOptionalPath.ts +8 -0
  270. package/src/lib/ui/runtime/withPath.ts +16 -0
  271. package/src/lib/ui/runtime/withPathFrom.ts +20 -0
  272. package/src/lib/ui/seedStreamedResolution.ts +31 -6
  273. package/src/lib/ui/settleAsyncCells.ts +24 -0
  274. package/src/lib/ui/socketProxy.ts +1 -1
  275. package/src/lib/ui/startClient.ts +16 -31
  276. package/src/lib/ui/trackedComputed.ts +68 -0
  277. package/src/lib/ui/types/Scope.ts +8 -24
  278. package/src/lib/ui/watch.ts +3 -3
  279. package/src/serverEntry.ts +14 -0
  280. package/template/src/server/rpc/getHello.ts +15 -13
  281. package/template/test/app.test.ts +1 -1
  282. package/src/lib/server/runtime/devHotModuleResponse.ts +0 -41
  283. package/src/lib/shared/DEV_HOT_PREFIX.ts +0 -7
  284. package/src/lib/shared/UNREACHABLE_STATUSES.ts +0 -13
  285. package/src/lib/shared/hasReplayableRequest.ts +0 -17
  286. package/src/lib/shared/hydratingSlot.ts +0 -12
  287. package/src/lib/shared/outboxProbeSlot.ts +0 -20
  288. package/src/lib/shared/types/Outbox.ts +0 -9
  289. package/src/lib/shared/types/OutboxEntry.ts +0 -27
  290. package/src/lib/shared/types/SmartReadOptions.ts +0 -36
  291. package/src/lib/shared/wakeHydrationPeeks.ts +0 -20
  292. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +0 -5
  293. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +0 -11
  294. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +0 -80
  295. package/src/lib/ui/compile/markupTokens.ts +0 -313
  296. package/src/lib/ui/compile/structuralBlockTokens.ts +0 -100
  297. package/src/lib/ui/dom/applyResolved.ts +0 -87
  298. package/src/lib/ui/dom/mutateDocArray.ts +0 -38
  299. package/src/lib/ui/dom/scopeLabel.ts +0 -21
  300. package/src/lib/ui/dom/text.ts +0 -20
  301. package/src/lib/ui/history.ts +0 -108
  302. package/src/lib/ui/installHotBridge.ts +0 -95
  303. package/src/lib/ui/installInspectorBridge.ts +0 -140
  304. package/src/lib/ui/outbox.ts +0 -35
  305. package/src/lib/ui/persist.ts +0 -115
  306. package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +0 -281
  307. package/src/lib/ui/rpcOutbox/outboxRegistry.ts +0 -69
  308. package/src/lib/ui/runtime/PATCH_BUS.ts +0 -28
  309. package/src/lib/ui/runtime/captureModelDoc.ts +0 -43
  310. package/src/lib/ui/runtime/hotInstances.ts +0 -10
  311. package/src/lib/ui/runtime/hotReloadEnabled.ts +0 -8
  312. package/src/lib/ui/runtime/hotReplace.ts +0 -38
  313. package/src/lib/ui/runtime/liveScopes.ts +0 -15
  314. package/src/lib/ui/runtime/localStoragePersistence.ts +0 -47
  315. package/src/lib/ui/runtime/registerHotInstance.ts +0 -23
  316. package/src/lib/ui/runtime/seedModelDoc.ts +0 -26
  317. package/src/lib/ui/runtime/types/HotInstance.ts +0 -22
  318. package/src/lib/ui/runtime/types/PatchEvent.ts +0 -16
  319. package/src/lib/ui/seedResolved.ts +0 -28
  320. package/src/lib/ui/sync.ts +0 -48
  321. package/src/lib/ui/types/History.ts +0 -14
  322. package/src/lib/ui/types/PersistHandle.ts +0 -11
  323. package/src/lib/ui/types/PersistenceStore.ts +0 -12
  324. package/src/lib/ui/types/ResolvedFrame.ts +0 -15
  325. package/src/lib/ui/types/SyncTransport.ts +0 -13
@@ -0,0 +1,1385 @@
1
+ import { BLOCK_CONNECTORS, BLOCK_OPENERS } from './BLOCK_KEYWORDS.ts'
2
+ import { decodeHtmlEntities } from './decodeHtmlEntities.ts'
3
+ import { interpolatedTemplateLiteral } from './interpolatedTemplateLiteral.ts'
4
+ import { isWhitespaceText } from './isWhitespaceText.ts'
5
+ import { structuralHeadTokens } from './structuralHeadTokens.ts'
6
+ import type { ParseDiagnostic } from './types/ParseDiagnostic.ts'
7
+ import type { SemanticToken } from './types/SemanticToken.ts'
8
+ import type { TemplateAttr } from './types/TemplateAttr.ts'
9
+ import type { TemplateNode } from './types/TemplateNode.ts'
10
+ import type { TextPart } from './types/TextPart.ts'
11
+ import { VOID_TAGS } from './VOID_TAGS.ts'
12
+
13
+ /* Compiled once — the parser tests these per source character, so a per-call literal
14
+ would recompile them across the whole template scan. */
15
+ const WHITESPACE = /\s/
16
+ const WHITESPACE_OR_GT = /[\s>]/
17
+ const ATTR_NAME_END = /[\s=>/]/
18
+ const TAG_NAME_END = /[\s>/]/
19
+ const TAG_NAME_START = /[A-Za-z]/
20
+ const UPPERCASE_START = /^[A-Z]/
21
+ const AWAIT_PREFIX = /^await\b/
22
+ const LEADING_KEYWORD = /^\s*(\S+)/
23
+
24
+ /* Table-driven block dispatch off the shared `BLOCK_KEYWORDS` vocabulary, so the parser
25
+ literally cannot accept a keyword the vocabulary omits (closing the `{#snippet}`-style
26
+ drift class). A `{:` connector keys off its first word (`else if` → `else`). */
27
+ const OPENER_SET = new Set<string>(BLOCK_OPENERS)
28
+ // biome-ignore lint/style/noNonNullAssertion: every BLOCK_CONNECTORS entry is a non-empty string, so split(' ')[0] is defined
29
+ const CONNECTOR_SET = new Set<string>(BLOCK_CONNECTORS.map((connector) => connector.split(' ')[0]!))
30
+
31
+ /* The reporter each module-level helper receives so it can record a diagnostic against
32
+ the closure's accumulator instead of throwing. */
33
+ type Report = (message: string, start: number, length?: number) => void
34
+
35
+ /*
36
+ A minimal compile-time parser for the abide template subset: elements, text with
37
+ `{expr}` interpolation, static/`{expr}`/`on<event>={expr}` attributes, and
38
+ `{#if}/{#for}/{#await}/{#switch}/{#try}` block control flow. Not a full HTML parser — it covers what
39
+ components need and reads brace expressions with quote/nesting awareness so an
40
+ expression containing `<`, `>`, or `}` parses intact. Void elements self-close.
41
+ `<!-- … -->` comments are dropped (no node emitted) so they leave no trace in the
42
+ SSR/client output or hydration cursor.
43
+
44
+ A `<style>` becomes a `style` node IN PLACE (not hoisted): its CSS body is read
45
+ structurally (not via a raw-source regex) so a `<style>` sitting inside a `{expr}`
46
+ or attribute — e.g. one quoted in a code sample — is read as that expression's
47
+ text, never mistaken for a real style. Keeping it in the tree lets the front-end
48
+ scope it to its sibling subtree (`analyzeComponent`); the node emits no DOM/markup.
49
+
50
+ ERROR RECOVERY: this is the non-throwing core. Every malformed construct is RECORDED
51
+ as a `ParseDiagnostic` (via `report`) and the walk recovers with bounded resync,
52
+ returning the best-effort `nodes` alongside every diagnostic — so the LSP surfaces
53
+ all parse errors from one pass over mid-edit source. The throwing `parseTemplate`
54
+ facade re-throws the FIRST diagnostic (preserving today's fail-fast message+offset).
55
+ */
56
+
57
+ /* A line-leading static `import` in a nested script body. The `(?=\s)` requires
58
+ whitespace after the keyword (sparing `import.meta` and no-space `import(...)`),
59
+ and `(?!\s*\()` spares a dynamic `import (...)` written with whitespace before the
60
+ paren — both legitimate lazy paths — so only a true static import statement matches. */
61
+ const NESTED_STATIC_IMPORT = /^[ \t]*import(?=\s)(?!\s*\()/m
62
+
63
+ /* A braced template expression with the absolute source offset of its first
64
+ (post-trim) character, so the type-checking shadow can map a diagnostic back. */
65
+ type Braced = { code: string; loc: number }
66
+
67
+ export function parseTemplateRecovering(
68
+ source: string,
69
+ baseOffset = 0,
70
+ collectTokens = false,
71
+ ): { nodes: TemplateNode[]; diagnostics: ParseDiagnostic[]; tokens: SemanticToken[] } {
72
+ let cursor = 0
73
+
74
+ /* Diagnostics accumulate in walk order; `report` pushes each malformed construct.
75
+ The facade throws `diagnostics[0]` — the first source-order failure — mirroring
76
+ today's fail-fast selection (the walk threw before any later site was reached). */
77
+ const diagnostics: ParseDiagnostic[] = []
78
+ function report(message: string, start: number, length = 0): void {
79
+ diagnostics.push({ message, start, length })
80
+ }
81
+
82
+ /* Semantic-highlighting tokens, a disjoint side-channel that mirrors `diagnostics`:
83
+ each reader pushes a token as the walk passes its lexeme, so ONE grammar drives
84
+ both the tree and the LSP's markup/structural coloring. Off by default — the
85
+ compile path (SSR/client/type-check) passes `collectTokens = false`, so `emit`
86
+ early-returns and the hot walk allocates nothing here. Offsets are bare
87
+ source-local `cursor` values (the token entry parses at `baseOffset 0`). */
88
+ const tokens: SemanticToken[] = []
89
+ function emit(start: number, length: number, type: string): void {
90
+ if (!collectTokens || length <= 0) {
91
+ return
92
+ }
93
+ tokens.push({ start, length, type, modifiers: [] })
94
+ }
95
+
96
+ /* A per-parse counter minting the synthetic binding name for each `{await expr}`
97
+ interpolation — `__await0`, `__await1`, … — so every desugared block in this
98
+ component binds a distinct, collision-safe local. Reset per parse, so the first
99
+ interpolation of any component is always `__await0`. */
100
+ let awaitInterpolationCount = 0
101
+
102
+ /* Reads a `{...}` expression starting at `cursor` (on the `{`), tracking
103
+ string literals and nested braces so the matching `}` is found. `loc` is the
104
+ absolute offset (baseOffset-relative) of the trimmed code's first char. */
105
+ function readBracedExpression(): Braced {
106
+ cursor += 1 // past '{'
107
+ const start = cursor
108
+ let depth = 1
109
+ while (cursor < source.length && depth > 0) {
110
+ const char = source.charAt(cursor)
111
+ if (char === '"' || char === "'" || char === '`') {
112
+ cursor += 1
113
+ while (cursor < source.length && source.charAt(cursor) !== char) {
114
+ if (source.charAt(cursor) === '\\') {
115
+ cursor += 1
116
+ }
117
+ cursor += 1
118
+ }
119
+ } else if (char === '{') {
120
+ depth += 1
121
+ } else if (char === '}') {
122
+ depth -= 1
123
+ }
124
+ cursor += 1
125
+ }
126
+ const raw = source.slice(start, cursor - 1)
127
+ const leading = raw.length - raw.trimStart().length
128
+ return { code: raw.trim(), loc: baseOffset + start + leading }
129
+ }
130
+
131
+ /* True when the cursor sits on a standalone `{await …}` interpolation — a braced
132
+ expression whose first token is a top-level `await`. Peeks WITHOUT consuming
133
+ (skips whitespace after `{`, then matches `await` on a word boundary), so the
134
+ scan loops can route it to `readAwaitInterpolation` before falling to `readText`.
135
+ A `{#`/`{:`/`{/` block token is not interpolation, so it is excluded. */
136
+ function atAwaitInterpolation(): boolean {
137
+ if (source.charAt(cursor) !== '{') {
138
+ return false
139
+ }
140
+ const next = source.charAt(cursor + 1)
141
+ if (next === '#' || next === ':' || next === '/') {
142
+ return false
143
+ }
144
+ let index = cursor + 1
145
+ while (index < source.length && WHITESPACE.test(source.charAt(index))) {
146
+ index += 1
147
+ }
148
+ /* `await` at depth 0 with a following word boundary — spares `awaiting`/`awaitable`. */
149
+ return (
150
+ source.startsWith('await', index) && !/\w/.test(source.charAt(index + 'await'.length))
151
+ )
152
+ }
153
+
154
+ /* Reads a `{await expr}` interpolation and desugars it to a BLOCKING `await` node —
155
+ equivalent to `{#await expr then __awaitN}{__awaitN}{/await}`. The leading `await`
156
+ token is stripped from the expression (it marks the syntactic await, not part of
157
+ the promise); the resolved value binds a fresh synthetic name and renders as the
158
+ block's sole text child. No new node kind or codegen — the synthesised node flows
159
+ through the same blocking-await machinery the explicit block uses. */
160
+ function readAwaitInterpolation(): TemplateNode {
161
+ const { code, loc } = readBracedExpression()
162
+ const afterAwait = code.slice('await'.length)
163
+ const promise = afterAwait.trim()
164
+ if (promise === '') {
165
+ report('[abide] {await …} requires an expression to await', loc)
166
+ return { kind: 'text', parts: [] }
167
+ }
168
+ /* `loc` points at the leading `await`; advance past it (and any whitespace) so the
169
+ node's `loc` aligns with the promise expression — the shadow source-map invariant
170
+ (source text at `loc` equals the emitted code). The synthetic binding has no source
171
+ position, so its `asLoc` and the reading text part's `loc` stay undefined. */
172
+ const promiseLoc =
173
+ loc + 'await'.length + (afterAwait.length - afterAwait.trimStart().length)
174
+ const binding = `__await${awaitInterpolationCount++}`
175
+ return {
176
+ kind: 'await',
177
+ promise,
178
+ blocking: true,
179
+ as: binding,
180
+ children: [{ kind: 'text', parts: [{ kind: 'expression', code: binding }] }],
181
+ loc: promiseLoc,
182
+ asLoc: undefined,
183
+ }
184
+ }
185
+
186
+ /* Skips an HTML comment starting at `cursor` (on `<!--`), advancing past its
187
+ `-->`; an unterminated comment runs to the end of source. Emits no node. */
188
+ function skipComment(): void {
189
+ const start = cursor
190
+ const close = source.indexOf('-->', cursor)
191
+ cursor = close === -1 ? source.length : close + '-->'.length
192
+ emit(start, cursor - start, 'comment')
193
+ }
194
+
195
+ /* True when `cursor` is on a `<style>` open tag — read raw (`readStyle`) so its
196
+ CSS braces never misparse as `{expr}` interpolations. */
197
+ function atStyleTag(): boolean {
198
+ return (
199
+ source.startsWith('<style', cursor) && WHITESPACE_OR_GT.test(source.charAt(cursor + 6))
200
+ )
201
+ }
202
+
203
+ /* Reads a `<style>…</style>` block into a `style` node carrying its CSS body; an
204
+ unterminated block runs to end. The body is read raw (not parsed as markup) so
205
+ CSS braces never misparse as `{expr}`. */
206
+ function readStyle(): TemplateNode {
207
+ const styleLt = cursor // the `<` of `<style`
208
+ const openEnd = source.indexOf('>', cursor)
209
+ const close = source.indexOf('</style>', cursor)
210
+ /* `<style>` bypasses `readElement`, so color its open+close tags here. The open
211
+ `<`/`style`/`>` and, when present, the close `</`/`style`/`>` mirror the
212
+ element path a `<script>` takes through `readElement`. */
213
+ emit(styleLt, 1, 'operator')
214
+ emit(styleLt + 1, 'style'.length, 'tag')
215
+ if (openEnd !== -1) {
216
+ emit(openEnd, 1, 'operator')
217
+ }
218
+ if (close !== -1) {
219
+ emit(close, 2, 'operator')
220
+ emit(close + 2, 'style'.length, 'tag')
221
+ emit(close + 2 + 'style'.length, 1, 'operator')
222
+ }
223
+ const css =
224
+ openEnd !== -1 && (close === -1 || openEnd < close)
225
+ ? source.slice(openEnd + 1, close === -1 ? source.length : close).trim()
226
+ : ''
227
+ cursor = close === -1 ? source.length : close + '</style>'.length
228
+ return { kind: 'style', css }
229
+ }
230
+
231
+ /* True when the cursor sits on a block-directive open `{#`. A `{:`/`{/` is only
232
+ valid INSIDE a block (consumed by readBlockChildren), so at top level it would be
233
+ a stray-branch error — handled where blocks are read, not here. */
234
+ function atBlock(): boolean {
235
+ return source.charAt(cursor) === '{' && source.charAt(cursor + 1) === '#'
236
+ }
237
+
238
+ /* `{children()}` is the slot fill point — the content a parent passed (a component)
239
+ or the route chain below (a layout). It parses to the SAME node the retired `<slot>`
240
+ element produced, so every downstream helper that branches on `tag === 'slot'` is
241
+ unchanged. */
242
+ const CHILDREN_CALL = /^\{\s*children\s*\(\s*\)\s*\}/
243
+ function atChildrenCall(): boolean {
244
+ return source.charAt(cursor) === '{' && CHILDREN_CALL.test(source.slice(cursor))
245
+ }
246
+ function readChildrenCall(): TemplateNode {
247
+ cursor = source.indexOf('}', cursor) + 1 // consume `{children()}`
248
+ return { kind: 'element', tag: 'slot', attrs: [], children: [] }
249
+ }
250
+
251
+ /* Reads a `{#…}` / `{:…}` / `{/…}` token starting on `{`. Tracks string literals
252
+ and nested braces (same scan as readBracedExpression) so a brace/quote inside an
253
+ expression (`{#if obj.has('}')}`) doesn't end the token early. Returns the sigil,
254
+ the trimmed directive body WITHOUT the sigil, and the absolute loc of the body's
255
+ first post-trim char. */
256
+ function readBlockToken(): { sigil: '#' | ':' | '/'; body: string; loc: number } {
257
+ const braceStart = cursor // the `{` — anchor for the `{`+sigil operator token
258
+ const sigil = source.charAt(cursor + 1) as '#' | ':' | '/'
259
+ cursor += 2 // past `{` and the sigil
260
+ const start = cursor
261
+ let depth = 1
262
+ while (cursor < source.length && depth > 0) {
263
+ const char = source.charAt(cursor)
264
+ if (char === '"' || char === "'" || char === '`') {
265
+ cursor += 1
266
+ while (cursor < source.length && source.charAt(cursor) !== char) {
267
+ if (source.charAt(cursor) === '\\') {
268
+ cursor += 1
269
+ }
270
+ cursor += 1
271
+ }
272
+ } else if (char === '{') {
273
+ depth += 1
274
+ } else if (char === '}') {
275
+ depth -= 1
276
+ }
277
+ cursor += 1
278
+ }
279
+ const raw = source.slice(start, cursor - 1)
280
+ const leading = raw.length - raw.trimStart().length
281
+ /* Color the `{#…}`/`{:…}`/`{/…}` framing off the SAME reader that builds the block
282
+ tree — the walk only reaches a real head, so a `{#for}` inside a `{expr}`/string
283
+ is never miscolored (unlike the old raw-source regex). */
284
+ if (collectTokens) {
285
+ for (const token of structuralHeadTokens(source, braceStart, sigil, start + leading)) {
286
+ tokens.push(token)
287
+ }
288
+ }
289
+ return { sigil, body: raw.trim(), loc: baseOffset + start + leading }
290
+ }
291
+
292
+ /* The leading keyword of a directive body (`if`, `for`, `await`, `switch`, `try`,
293
+ `else`, `then`, `catch`, `finally`, `case`, `default`). */
294
+ function headKeyword(body: string): string {
295
+ const match = body.match(LEADING_KEYWORD)
296
+ return match?.[1] ?? ''
297
+ }
298
+
299
+ /* Reads a `{#…}` control block: the open token, its children up to a continuation
300
+ `{:…}` (a branch) or close `{/…}`, recursing. Emits `if`/`each`/`await`/`switch`/`try`
301
+ nodes with their `case`/`branch` children — the same TemplateNode kinds the back-ends
302
+ and `skeletonContext` consume. Dispatch is table-driven off `OPENER_SET`, so an
303
+ opener the shared vocabulary omits self-reports (and cannot be silently accepted). */
304
+ function readBlock(): TemplateNode {
305
+ const open = readBlockToken() // sigil is '#'
306
+ const keyword = headKeyword(open.body)
307
+ if (!OPENER_SET.has(keyword)) {
308
+ report(
309
+ `[abide] unknown control block {#${keyword}} — expected ${BLOCK_OPENERS.join('/')}`,
310
+ open.loc,
311
+ )
312
+ return { kind: 'text', parts: [] }
313
+ }
314
+ if (keyword === 'if') {
315
+ const start = open.body.indexOf('if') + 2
316
+ const condition = open.body.slice(start).trim()
317
+ if (condition === '') {
318
+ report(
319
+ '[abide] {#if} requires a condition expression',
320
+ exprLoc(open.loc, open.body, start),
321
+ )
322
+ }
323
+ const children = readBlockChildren('if')
324
+ return { kind: 'if', condition, children, loc: exprLoc(open.loc, open.body, start) }
325
+ }
326
+ if (keyword === 'for') {
327
+ const head = parseForHead(open.body, open.loc, report)
328
+ const children = readBlockChildren('for')
329
+ return {
330
+ kind: 'each',
331
+ items: head.items,
332
+ as: head.as,
333
+ index: head.index,
334
+ key: head.key,
335
+ async: head.async,
336
+ children,
337
+ loc: head.loc,
338
+ asLoc: head.asLoc,
339
+ keyLoc: head.keyLoc,
340
+ indexLoc: head.indexLoc,
341
+ }
342
+ }
343
+ if (keyword === 'await') {
344
+ const start = open.body.indexOf('await') + 5
345
+ const afterAwait = open.body.slice(start)
346
+ const thenAt = keywordAtDepthZero(afterAwait, 'then')
347
+ const promise = (thenAt === -1 ? afterAwait : afterAwait.slice(0, thenAt)).trim()
348
+ if (promise === '') {
349
+ report(
350
+ '[abide] {#await} requires a promise expression',
351
+ exprLoc(open.loc, open.body, start),
352
+ )
353
+ }
354
+ const as =
355
+ thenAt === -1
356
+ ? undefined
357
+ : afterAwait.slice(thenAt + 'then'.length).trim() || undefined
358
+ const children = readBlockChildren('await')
359
+ return {
360
+ kind: 'await',
361
+ promise,
362
+ blocking: thenAt !== -1,
363
+ as,
364
+ children,
365
+ loc: exprLoc(open.loc, open.body, start),
366
+ asLoc:
367
+ as === undefined
368
+ ? undefined
369
+ : exprLoc(open.loc, open.body, start + thenAt + 'then'.length),
370
+ }
371
+ }
372
+ if (keyword === 'switch') {
373
+ const start = open.body.indexOf('switch') + 6
374
+ const subject = open.body.slice(start).trim()
375
+ if (subject === '') {
376
+ report(
377
+ '[abide] {#switch} requires a subject expression',
378
+ exprLoc(open.loc, open.body, start),
379
+ )
380
+ }
381
+ const children = readBlockChildren('switch')
382
+ return { kind: 'switch', subject, children, loc: exprLoc(open.loc, open.body, start) }
383
+ }
384
+ if (keyword === 'try') {
385
+ const children = readBlockChildren('try')
386
+ return { kind: 'try', children }
387
+ }
388
+ if (keyword === 'snippet') {
389
+ const head = open.body.slice(open.body.indexOf('snippet') + 'snippet'.length).trim()
390
+ const parenAt = head.indexOf('(')
391
+ const name = (parenAt === -1 ? head : head.slice(0, parenAt)).trim()
392
+ if (name === '') {
393
+ report('[abide] {#snippet} requires a name, e.g. {#snippet row(item)}', open.loc)
394
+ }
395
+ /* Params ride the parens: `{#snippet row({ item })}` → `{ item }`. */
396
+ const params =
397
+ parenAt === -1
398
+ ? undefined
399
+ : head.slice(parenAt + 1, head.lastIndexOf(')')).trim() || undefined
400
+ const children = readBlockChildren('snippet')
401
+ return {
402
+ kind: 'snippet',
403
+ name,
404
+ params,
405
+ children,
406
+ loc:
407
+ parenAt === -1
408
+ ? undefined
409
+ : exprLoc(open.loc, open.body, open.body.indexOf('(') + 1),
410
+ }
411
+ }
412
+ /* Unreachable: `OPENER_SET` gates every branch above. Kept for exhaustiveness. */
413
+ return { kind: 'text', parts: [] }
414
+ }
415
+
416
+ /* Shared scan body for block/branch child loops. Reads one node at the current
417
+ cursor position and pushes it onto `nodes`. Returns false when the caller's
418
+ terminator fires (caller decides what to do next); returns true while scanning
419
+ continues. `onBranch` fires when `{:` is seen — block-children consume it as a
420
+ new branch node; branch-children let the caller exit instead. */
421
+ function scanNode(nodes: TemplateNode[], onBranch: (() => boolean) | undefined): boolean {
422
+ /* Branch/close tokens — delegate to the caller's terminator logic. */
423
+ if (source.startsWith('{/', cursor)) {
424
+ return false
425
+ }
426
+ if (source.startsWith('{:', cursor)) {
427
+ return onBranch === undefined ? false : onBranch()
428
+ }
429
+ if (atBlock()) {
430
+ nodes.push(readBlock())
431
+ } else if (atChildrenCall()) {
432
+ nodes.push(readChildrenCall())
433
+ } else if (atAwaitInterpolation()) {
434
+ nodes.push(readAwaitInterpolation())
435
+ } else if (source.startsWith('<!--', cursor)) {
436
+ skipComment()
437
+ } else if (atStyleTag()) {
438
+ nodes.push(readStyle())
439
+ } else if (source.charAt(cursor) === '<') {
440
+ nodes.push(readElement())
441
+ } else {
442
+ nodes.push(readText())
443
+ }
444
+ return true
445
+ }
446
+
447
+ /* Reads children of a block until its close `{/<keyword>}`. A continuation token
448
+ `{:…}` ends the current branch's children and starts a new `case`/`branch` node
449
+ (per construct). The leading children (before the first `{:…}`) are the block's
450
+ own children (the `if`/`await`/`try` then-content). Returns the full children list
451
+ INCLUDING the case/branch nodes, producing the same TemplateNode tree the back-ends consume. */
452
+ function readBlockChildren(keyword: string): TemplateNode[] {
453
+ const nodes: TemplateNode[] = []
454
+ while (cursor < source.length) {
455
+ const keepGoing = scanNode(nodes, () => {
456
+ nodes.push(readBranch(keyword))
457
+ return true
458
+ })
459
+ if (!keepGoing) {
460
+ const close = readBlockToken() // consume the close `{/keyword}`
461
+ const closeKeyword = headKeyword(close.body)
462
+ /* The close must name its open block — a mismatch (`{#if}…{/for}`) or
463
+ crossed nesting (`{#if}{#for}…{/if}{/for}`) would otherwise silently
464
+ mis-parse into a structurally wrong tree. Recover by returning the
465
+ children collected so far (the close token is already consumed), so the
466
+ enclosing loop re-checks the next `{/…}` against its own keyword. */
467
+ if (closeKeyword !== keyword) {
468
+ report(
469
+ `[abide] {/${closeKeyword}} does not close the open {#${keyword}} — expected {/${keyword}}`,
470
+ close.loc,
471
+ )
472
+ return nodes
473
+ }
474
+ return nodes
475
+ }
476
+ }
477
+ /* Reached EOF without the close — record the unterminated block and return the
478
+ partial children (cursor is at EOF, so the walk terminates). */
479
+ report(
480
+ `[abide] unterminated {#${keyword}} block — missing {/${keyword}}`,
481
+ baseOffset + cursor,
482
+ )
483
+ return nodes
484
+ }
485
+
486
+ /* Reads a continuation token `{:…}` and the children up to the NEXT continuation or
487
+ close, returning the branch node for the parent construct. Handles `if`-chain
488
+ branches (else / else if) and `for await` catch branches. Dispatch is table-driven
489
+ off `CONNECTOR_SET` — a `{:…}` the shared vocabulary omits self-reports. */
490
+ function readBranch(parentKeyword: string): TemplateNode {
491
+ const token = readBlockToken() // sigil ':'
492
+ const keyword = headKeyword(token.body)
493
+ const branchChildren = readBranchChildren()
494
+ if (!CONNECTOR_SET.has(keyword)) {
495
+ report(`[abide] {:${keyword}} is not valid inside {#${parentKeyword}}`, token.loc)
496
+ return { kind: 'text', parts: [] }
497
+ }
498
+ if (parentKeyword === 'if') {
499
+ if (keyword === 'else' && headKeyword(token.body.slice(4).trim()) === 'if') {
500
+ const start = token.body.indexOf('if') + 2
501
+ const condition = token.body.slice(start).trim()
502
+ if (condition === '') {
503
+ report(
504
+ '[abide] {:else if} requires a condition expression',
505
+ exprLoc(token.loc, token.body, start),
506
+ )
507
+ }
508
+ return {
509
+ kind: 'case',
510
+ match: undefined,
511
+ condition,
512
+ children: branchChildren,
513
+ loc: exprLoc(token.loc, token.body, start),
514
+ }
515
+ }
516
+ if (keyword === 'else') {
517
+ return { kind: 'case', match: undefined, children: branchChildren }
518
+ }
519
+ }
520
+ if (parentKeyword === 'for' && keyword === 'catch') {
521
+ const as = token.body.slice(token.body.indexOf('catch') + 5).trim() || undefined
522
+ return {
523
+ kind: 'branch',
524
+ branch: 'catch',
525
+ as,
526
+ children: branchChildren,
527
+ asLoc:
528
+ as === undefined
529
+ ? undefined
530
+ : exprLoc(token.loc, token.body, token.body.indexOf('catch') + 5),
531
+ }
532
+ }
533
+ if (parentKeyword === 'switch') {
534
+ if (keyword === 'case') {
535
+ const start = token.body.indexOf('case') + 4
536
+ const match = token.body.slice(start).trim()
537
+ if (match === '') {
538
+ report(
539
+ '[abide] {:case} requires a value expression',
540
+ exprLoc(token.loc, token.body, start),
541
+ )
542
+ }
543
+ return {
544
+ kind: 'case',
545
+ match,
546
+ children: branchChildren,
547
+ loc: exprLoc(token.loc, token.body, start),
548
+ }
549
+ }
550
+ if (keyword === 'default') {
551
+ return { kind: 'case', match: undefined, children: branchChildren }
552
+ }
553
+ }
554
+ if (parentKeyword === 'await') {
555
+ if (keyword === 'then') {
556
+ const as = token.body.slice(token.body.indexOf('then') + 4).trim() || undefined
557
+ return {
558
+ kind: 'branch',
559
+ branch: 'then',
560
+ as,
561
+ children: branchChildren,
562
+ asLoc:
563
+ as === undefined
564
+ ? undefined
565
+ : exprLoc(token.loc, token.body, token.body.indexOf('then') + 4),
566
+ }
567
+ }
568
+ if (keyword === 'catch') {
569
+ const as = token.body.slice(token.body.indexOf('catch') + 5).trim() || undefined
570
+ return {
571
+ kind: 'branch',
572
+ branch: 'catch',
573
+ as,
574
+ children: branchChildren,
575
+ asLoc:
576
+ as === undefined
577
+ ? undefined
578
+ : exprLoc(token.loc, token.body, token.body.indexOf('catch') + 5),
579
+ }
580
+ }
581
+ if (keyword === 'finally') {
582
+ return {
583
+ kind: 'branch',
584
+ branch: 'finally',
585
+ as: undefined,
586
+ children: branchChildren,
587
+ }
588
+ }
589
+ }
590
+ if (parentKeyword === 'try') {
591
+ if (keyword === 'catch') {
592
+ const as = token.body.slice(token.body.indexOf('catch') + 5).trim() || undefined
593
+ return {
594
+ kind: 'branch',
595
+ branch: 'catch',
596
+ as,
597
+ children: branchChildren,
598
+ asLoc:
599
+ as === undefined
600
+ ? undefined
601
+ : exprLoc(token.loc, token.body, token.body.indexOf('catch') + 5),
602
+ }
603
+ }
604
+ if (keyword === 'finally') {
605
+ return {
606
+ kind: 'branch',
607
+ branch: 'finally',
608
+ as: undefined,
609
+ children: branchChildren,
610
+ }
611
+ }
612
+ }
613
+ /* A connector that IS in the vocabulary but wrong for THIS parent (e.g. `{:then}`
614
+ inside `{#if}`) — same message string as the leading guard, recover to text. */
615
+ report(`[abide] {:${keyword}} is not valid inside {#${parentKeyword}}`, token.loc)
616
+ return { kind: 'text', parts: [] }
617
+ }
618
+
619
+ /* Children of a branch: read until the next `{:…}` or `{/…}` WITHOUT consuming it
620
+ (the caller's readBlockChildren loop handles those). */
621
+ function readBranchChildren(): TemplateNode[] {
622
+ const nodes: TemplateNode[] = []
623
+ /* Pass undefined for onBranch so scanNode returns false on `{:`, leaving it unconsumed. */
624
+ while (cursor < source.length && scanNode(nodes, undefined)) {
625
+ // continue
626
+ }
627
+ return nodes
628
+ }
629
+
630
+ function readText(): TemplateNode {
631
+ const parts: TextPart[] = []
632
+ let literal = ''
633
+ while (cursor < source.length && source.charAt(cursor) !== '<') {
634
+ if (source.charAt(cursor) === '{') {
635
+ if (atChildrenCall()) {
636
+ break // a slot fill point — handled by the enclosing scan loop
637
+ }
638
+ const next = source.charAt(cursor + 1)
639
+ if (next === '#' || next === ':' || next === '/') {
640
+ break // a block/continuation/close token — not interpolation
641
+ }
642
+ if (atAwaitInterpolation()) {
643
+ break // a `{await …}` — read as its own node by the enclosing scan loop
644
+ }
645
+ if (literal !== '') {
646
+ parts.push({ kind: 'static', value: decodeHtmlEntities(literal) })
647
+ literal = ''
648
+ }
649
+ const { code, loc } = readBracedExpression()
650
+ parts.push({ kind: 'expression', code, loc })
651
+ } else {
652
+ literal += source.charAt(cursor)
653
+ cursor += 1
654
+ }
655
+ }
656
+ if (literal !== '') {
657
+ parts.push({ kind: 'static', value: decodeHtmlEntities(literal) })
658
+ }
659
+ return { kind: 'text', parts }
660
+ }
661
+
662
+ function readAttributes(): TemplateAttr[] {
663
+ const attrs: TemplateAttr[] = []
664
+ while (cursor < source.length) {
665
+ while (WHITESPACE.test(source.charAt(cursor))) {
666
+ cursor += 1
667
+ }
668
+ const char = source.charAt(cursor)
669
+ if (char === '>' || char === '/' || char === undefined) {
670
+ break
671
+ }
672
+ /* `{...expr}` standing where an attribute name would — a spread of an object's
673
+ keys onto the tag: props on a component, attributes on a native element. Only
674
+ a `<template>` directive rejects it (see `readElement`). */
675
+ if (char === '{') {
676
+ const { code, loc } = readBracedExpression()
677
+ if (!code.startsWith('...')) {
678
+ report(
679
+ `[abide] a bare {expr} is not a valid attribute — write {...expr} to spread an object's keys as props`,
680
+ loc,
681
+ )
682
+ continue
683
+ }
684
+ /* Advance `loc` past `...` and any whitespace so it points at the spread
685
+ EXPRESSION, not the dots — the shadow source-map invariant (source text at
686
+ `loc` equals the emitted code) requires `loc` and `code` to align. */
687
+ const inner = code.slice(3)
688
+ const leading = inner.length - inner.trimStart().length
689
+ attrs.push({ kind: 'spread', code: inner.trim(), loc: loc + 3 + leading })
690
+ continue
691
+ }
692
+ let name = ''
693
+ const nameStart = cursor // source-local offset of the attribute name token
694
+ const nameLoc = baseOffset + cursor // absolute offset of the attribute name
695
+ while (cursor < source.length && !ATTR_NAME_END.test(source.charAt(cursor))) {
696
+ name += source.charAt(cursor)
697
+ cursor += 1
698
+ }
699
+ emit(nameStart, cursor - nameStart, 'attribute')
700
+ while (WHITESPACE.test(source.charAt(cursor))) {
701
+ cursor += 1
702
+ }
703
+ if (source.charAt(cursor) !== '=') {
704
+ attrs.push({ kind: 'static', name, value: '', bare: true, nameLoc }) // boolean attribute
705
+ continue
706
+ }
707
+ emit(cursor, 1, 'operator') // the `=`
708
+ cursor += 1 // past '='
709
+ while (WHITESPACE.test(source.charAt(cursor))) {
710
+ cursor += 1
711
+ }
712
+ if (source.charAt(cursor) === '{') {
713
+ const { code, loc } = readBracedExpression()
714
+ if (name.startsWith('on')) {
715
+ attrs.push({ kind: 'event', event: name.slice(2), code, loc })
716
+ } else if (name.startsWith('bind:')) {
717
+ attrs.push({ kind: 'bind', property: name.slice(5), code, loc })
718
+ } else if (name.startsWith('class:')) {
719
+ attrs.push({ kind: 'class', name: name.slice(6), code, loc })
720
+ } else if (name.startsWith('style:')) {
721
+ attrs.push({ kind: 'style', property: name.slice(6), code, loc })
722
+ } else if (name === 'attach') {
723
+ attrs.push({ kind: 'attach', code, loc })
724
+ } else {
725
+ attrs.push({ kind: 'expression', name, code, loc, nameLoc })
726
+ }
727
+ } else if (source.charAt(cursor) === '"' || source.charAt(cursor) === "'") {
728
+ const quote = source.charAt(cursor)
729
+ const openingQuoteOffset = cursor
730
+ cursor += 1
731
+ /* Scan the quoted span into static/`{expr}` parts (the text-node model),
732
+ reusing the brace/quote-aware expression reader so a `}` inside an
733
+ expression doesn't close the value early. `string` tokens color the
734
+ literal runs (delimiters included), split around each `{expr}`. */
735
+ const parts: TextPart[] = []
736
+ let literal = ''
737
+ let segmentStart = openingQuoteOffset
738
+ while (cursor < source.length && source.charAt(cursor) !== quote) {
739
+ if (source.charAt(cursor) === '{') {
740
+ if (literal !== '') {
741
+ parts.push({ kind: 'static', value: decodeHtmlEntities(literal) })
742
+ literal = ''
743
+ }
744
+ emit(segmentStart, cursor - segmentStart, 'string')
745
+ const { code, loc } = readBracedExpression()
746
+ parts.push({ kind: 'expression', code, loc })
747
+ segmentStart = cursor
748
+ } else {
749
+ literal += source.charAt(cursor)
750
+ cursor += 1
751
+ }
752
+ }
753
+ cursor += 1 // past closing quote
754
+ emit(segmentStart, cursor - segmentStart, 'string')
755
+ if (literal !== '') {
756
+ parts.push({ kind: 'static', value: decodeHtmlEntities(literal) })
757
+ }
758
+ /* No `{expr}` → a plain static attribute; its literal is entity-decoded
759
+ like a text node (SSR/clone re-escape it), so a value such as `&amp;`
760
+ round-trips instead of double-escaping. Otherwise an interpolated value. */
761
+ if (parts.every((part) => part.kind === 'static')) {
762
+ const value = parts
763
+ .map((part) => (part.kind === 'static' ? part.value : ''))
764
+ .join('')
765
+ attrs.push({ kind: 'static', name, value, nameLoc })
766
+ } else {
767
+ attrs.push({ kind: 'interpolated', name, parts, nameLoc })
768
+ }
769
+ } else {
770
+ /* Unquoted value (`<input type=text>`): runs to the next whitespace or
771
+ `>`, per the HTML unquoted-attribute rule. No delimiter to consume. */
772
+ const valueStart = cursor
773
+ let value = ''
774
+ while (cursor < source.length && !WHITESPACE_OR_GT.test(source.charAt(cursor))) {
775
+ /* Stop before a `/` that closes the tag (`<Comp x=y/>`) so the value
776
+ doesn't swallow the self-closing slash and defeat detection; a `/`
777
+ elsewhere (e.g. a URL `href=/a/b`) stays part of the value. */
778
+ if (source.charAt(cursor) === '/' && source.charAt(cursor + 1) === '>') {
779
+ break
780
+ }
781
+ value += source.charAt(cursor)
782
+ cursor += 1
783
+ }
784
+ emit(valueStart, cursor - valueStart, 'string')
785
+ /* Decode entities like the quoted path does — a real HTML parser decodes
786
+ unquoted attribute values too, so `value=a&amp;b` is the value `a&b`. */
787
+ attrs.push({ kind: 'static', name, value: decodeHtmlEntities(value) })
788
+ }
789
+ }
790
+ return attrs
791
+ }
792
+
793
+ function readElement(): TemplateNode {
794
+ const ltOffset = cursor // the `<`
795
+ cursor += 1 // past '<'
796
+ let tag = ''
797
+ const tagStart = cursor // absolute (baseOffset-relative) offset of the tag name
798
+ while (cursor < source.length && !TAG_NAME_END.test(source.charAt(cursor))) {
799
+ tag += source.charAt(cursor)
800
+ cursor += 1
801
+ }
802
+ /* Color the `<` and the tag name only when a real name starts the tag (a bare
803
+ `<` in stray text is not an element) — a lowercase name is a `tag`, an
804
+ uppercase one a component `type`. */
805
+ if (tag.length > 0 && TAG_NAME_START.test(tag.charAt(0))) {
806
+ emit(ltOffset, 1, 'operator')
807
+ emit(tagStart, tag.length, UPPERCASE_START.test(tag) ? 'type' : 'tag')
808
+ }
809
+ const attrs = readAttributes()
810
+ let selfClosing = false
811
+ const closeMarkOffset = cursor
812
+ if (source.charAt(cursor) === '/') {
813
+ selfClosing = true
814
+ cursor += 1
815
+ }
816
+ /* The tag's `>` (or `/>` self-close) operator; also the `>` of a `<script>`/
817
+ `<style>` open tag, since both flow through here. */
818
+ if (selfClosing) {
819
+ emit(closeMarkOffset, 2, 'operator')
820
+ } else if (source.charAt(cursor) === '>') {
821
+ emit(cursor, 1, 'operator')
822
+ }
823
+ cursor += 1 // past '>'
824
+ /* A nested `<script>` is a scoped reactive block: its body is raw JS read
825
+ verbatim to its `</script>` (not parsed as markup), scoped by the
826
+ containing branch when compiled. */
827
+ if (tag === 'script' && !selfClosing) {
828
+ /* Body starts at the current cursor; its absolute source offset lets the shadow
829
+ map a diagnostic raised inside it (e.g. a top-level await) back to source. */
830
+ const bodyLoc = baseOffset + cursor
831
+ const close = source.indexOf('</script>', cursor)
832
+ const end = close === -1 ? source.length : close
833
+ const code = source.slice(cursor, end)
834
+ /* The raw body is skipped (JS, not markup), but the `</script>` close tag is
835
+ colored like any element close: `</` + `script` + `>`. */
836
+ if (close !== -1) {
837
+ emit(close, 2, 'operator')
838
+ emit(close + 2, 'script'.length, 'tag')
839
+ emit(close + 2 + 'script'.length, 1, 'operator')
840
+ }
841
+ cursor = close === -1 ? source.length : end + '</script>'.length
842
+ /* A static `import` can't live here: a nested script compiles INTO the
843
+ branch's render-function body, where an import is illegal — and an
844
+ import nested in a branch falsely implies conditional/lazy loading ES
845
+ imports can't do (they hoist module-wide and load unconditionally). The
846
+ leading `<script>` hoists imports to module scope for the whole template,
847
+ so they belong there. The pattern spares dynamic `import(...)` (with or
848
+ without whitespace) and `import.meta` — the real lazy paths. */
849
+ if (NESTED_STATIC_IMPORT.test(code)) {
850
+ report(
851
+ "import statements must live in the component's leading <script>, not a nested <template> script — they hoist to module scope for the whole template. For lazy loading, use a dynamic import() inside an effect.",
852
+ bodyLoc,
853
+ )
854
+ }
855
+ return { kind: 'script', code, loc: bodyLoc }
856
+ }
857
+ /* A capitalised tag is a child component; its attributes become props and
858
+ its children become slot content (rendered where the child puts <slot>). */
859
+ if (UPPERCASE_START.test(tag)) {
860
+ const slotted = selfClosing ? [] : readChildren(tag)
861
+ return {
862
+ kind: 'component',
863
+ name: tag,
864
+ loc: baseOffset + tagStart,
865
+ props: toProps(attrs),
866
+ children: slotted,
867
+ }
868
+ }
869
+ /* `{...expr}` spreads onto a component (its props) or a native element (its
870
+ attributes), but a `<template>` directive has no such bag — reject it there. */
871
+ if (tag === 'template' && attrs.some((attr) => attr.kind === 'spread')) {
872
+ report(
873
+ '[abide] {...expr} spread is not supported on a <template> directive',
874
+ baseOffset + tagStart,
875
+ )
876
+ }
877
+ const children = selfClosing || VOID_TAGS.has(tag) ? [] : readChildren(tag)
878
+ if (tag === 'slot') {
879
+ report(
880
+ '[abide] the <slot> element was removed — render passed content with {children()} (with {#if children}{children()}{:else}…{/if} for a fallback)',
881
+ baseOffset + tagStart,
882
+ )
883
+ return { kind: 'element', tag: 'slot', attrs, children }
884
+ }
885
+ if (tag === 'template') {
886
+ return toSnippetOrTemplate(attrs, children, report)
887
+ }
888
+ return { kind: 'element', tag, attrs, children }
889
+ }
890
+
891
+ /* A `{:…}`/`{/…}` reached OUTSIDE a block (the root scan or an element's children —
892
+ not readBlockChildren/readBranchChildren, which consume their own) is a continuation
893
+ or close with no open `{#…}`. Record it and ADVANCE past the token's `}` (or to EOF):
894
+ readText refuses to advance past `{:`/`{/`, so this explicit advance is the anti-spin
895
+ move that keeps every enclosing scan loop terminating. */
896
+ function reportStrayBranch(): void {
897
+ if (source.startsWith('{:', cursor) || source.startsWith('{/', cursor)) {
898
+ const end = source.indexOf('}', cursor)
899
+ const token = source.slice(cursor, end === -1 ? source.length : end + 1)
900
+ report(`[abide] ${token} has no open {#…} block`, baseOffset + cursor, token.length)
901
+ cursor = end === -1 ? source.length : end + 1
902
+ }
903
+ }
904
+
905
+ function readChildren(closeTag: string): TemplateNode[] {
906
+ const nodes: TemplateNode[] = []
907
+ while (cursor < source.length) {
908
+ if (source.startsWith(`</${closeTag}`, cursor)) {
909
+ /* The close tag `</tag>`: `</` operator, the tag/type name, then `>`. */
910
+ if (closeTag.length > 0 && TAG_NAME_START.test(closeTag.charAt(0))) {
911
+ const gtOffset = source.indexOf('>', cursor)
912
+ emit(cursor, 2, 'operator')
913
+ emit(
914
+ cursor + 2,
915
+ closeTag.length,
916
+ UPPERCASE_START.test(closeTag) ? 'type' : 'tag',
917
+ )
918
+ if (gtOffset !== -1) {
919
+ emit(gtOffset, 1, 'operator')
920
+ }
921
+ }
922
+ cursor = source.indexOf('>', cursor) + 1
923
+ break
924
+ }
925
+ reportStrayBranch()
926
+ if (source.startsWith('<!--', cursor)) {
927
+ skipComment()
928
+ } else if (atStyleTag()) {
929
+ nodes.push(readStyle())
930
+ } else if (atBlock()) {
931
+ nodes.push(readBlock())
932
+ } else if (atChildrenCall()) {
933
+ nodes.push(readChildrenCall())
934
+ } else if (atAwaitInterpolation()) {
935
+ nodes.push(readAwaitInterpolation())
936
+ } else if (source.charAt(cursor) === '<') {
937
+ nodes.push(readElement())
938
+ } else {
939
+ nodes.push(readText())
940
+ }
941
+ }
942
+ return nodes
943
+ }
944
+
945
+ const roots: TemplateNode[] = []
946
+ while (cursor < source.length) {
947
+ reportStrayBranch()
948
+ if (source.startsWith('<!--', cursor)) {
949
+ skipComment()
950
+ } else if (atStyleTag()) {
951
+ roots.push(readStyle())
952
+ } else if (atBlock()) {
953
+ roots.push(readBlock())
954
+ } else if (atChildrenCall()) {
955
+ roots.push(readChildrenCall())
956
+ } else if (atAwaitInterpolation()) {
957
+ roots.push(readAwaitInterpolation())
958
+ } else if (source.charAt(cursor) === '<') {
959
+ roots.push(readElement())
960
+ } else {
961
+ roots.push(readText())
962
+ }
963
+ }
964
+ rejectStrayBranches(roots, undefined, report)
965
+ return { nodes: roots, diagnostics, tokens }
966
+ }
967
+
968
+ /* Finds the index of a ` <token> ` keyword (` of `, ` by `) at brace/paren/bracket
969
+ depth 0, scanning left to right, skipping string literals. Returns -1 if absent. */
970
+ function indexOfKeywordAtDepthZero(text: string, keyword: string): number {
971
+ let depth = 0
972
+ let i = 0
973
+ while (i < text.length) {
974
+ const char = text.charAt(i)
975
+ if (char === '"' || char === "'" || char === '`') {
976
+ i += 1
977
+ while (i < text.length && text.charAt(i) !== char) {
978
+ if (text.charAt(i) === '\\') {
979
+ i += 1
980
+ }
981
+ i += 1
982
+ }
983
+ } else if (char === '{' || char === '(' || char === '[') {
984
+ depth += 1
985
+ } else if (char === '}' || char === ')' || char === ']') {
986
+ depth -= 1
987
+ } else if (depth === 0 && text.startsWith(keyword, i)) {
988
+ return i
989
+ }
990
+ i += 1
991
+ }
992
+ return -1
993
+ }
994
+
995
+ /* Index of a bare keyword (`then`) at brace/paren/bracket depth 0, requiring whitespace
996
+ on both sides — so it isn't matched inside an identifier (`q.then(x)`) and MAY sit
997
+ terminally. Unlike ` of `/` by `, an await `then` can end the head: `{#await p then}`
998
+ is a blocking await with no value binding, which a trailing-space match would miss
999
+ (folding `then` into the promise). Skips string literals. -1 if absent. */
1000
+ function keywordAtDepthZero(text: string, word: string): number {
1001
+ let depth = 0
1002
+ let i = 0
1003
+ while (i < text.length) {
1004
+ const char = text.charAt(i)
1005
+ if (char === '"' || char === "'" || char === '`') {
1006
+ i += 1
1007
+ while (i < text.length && text.charAt(i) !== char) {
1008
+ if (text.charAt(i) === '\\') {
1009
+ i += 1
1010
+ }
1011
+ i += 1
1012
+ }
1013
+ } else if (char === '{' || char === '(' || char === '[') {
1014
+ depth += 1
1015
+ } else if (char === '}' || char === ')' || char === ']') {
1016
+ depth -= 1
1017
+ } else if (
1018
+ depth === 0 &&
1019
+ text.startsWith(word, i) &&
1020
+ i > 0 &&
1021
+ WHITESPACE.test(text.charAt(i - 1)) &&
1022
+ (i + word.length === text.length || WHITESPACE.test(text.charAt(i + word.length)))
1023
+ ) {
1024
+ return i
1025
+ }
1026
+ i += 1
1027
+ }
1028
+ return -1
1029
+ }
1030
+
1031
+ /* The depth-0 comma index in a binding (`{id, title}, i` → the comma after `}`),
1032
+ so a destructuring pattern's inner commas don't split the binding from its index. */
1033
+ function bindingCommaAtDepthZero(text: string): number {
1034
+ let depth = 0
1035
+ let i = 0
1036
+ while (i < text.length) {
1037
+ const char = text.charAt(i)
1038
+ if (char === '{' || char === '(' || char === '[') {
1039
+ depth += 1
1040
+ } else if (char === '}' || char === ')' || char === ']') {
1041
+ depth -= 1
1042
+ } else if (char === ',' && depth === 0) {
1043
+ return i
1044
+ }
1045
+ i += 1
1046
+ }
1047
+ return -1
1048
+ }
1049
+
1050
+ /* Absolute source offset of the trimmed expression beginning at `start` within a
1051
+ directive `body` whose first char is at absolute `bodyLoc` — skips the leading
1052
+ whitespace `.trim()` drops so the offset points at the expression's first real char
1053
+ (the shadow source-map invariant: source text at `loc` equals the emitted code). */
1054
+ function exprLoc(bodyLoc: number, body: string, start: number): number {
1055
+ const raw = body.slice(start)
1056
+ return bodyLoc + start + (raw.length - raw.trimStart().length)
1057
+ }
1058
+
1059
+ /* Parses `for [await] <binding>[, <index>] of <iterable> [by <key>]`. Offsets are
1060
+ tracked against the original `body` (not trimmed slices) so `loc` points at the
1061
+ iterable expression's first char — `bodyLoc` is the absolute offset of `body[0]`.
1062
+ A missing `of` is RECORDED and a best-effort head returned so `readBlock` still builds
1063
+ an `each` node (bounded recovery — no new node kind). */
1064
+ function parseForHead(
1065
+ body: string,
1066
+ bodyLoc: number,
1067
+ report: Report,
1068
+ ): {
1069
+ items: string
1070
+ as: string
1071
+ index: string | undefined
1072
+ key: string | undefined
1073
+ async: boolean
1074
+ loc: number
1075
+ asLoc: number | undefined
1076
+ keyLoc: number | undefined
1077
+ indexLoc: number | undefined
1078
+ } {
1079
+ const leadingSpace = (text: string): number => text.length - text.trimStart().length
1080
+ const skipSpace = (i: number): number => {
1081
+ let at = i
1082
+ while (at < body.length && WHITESPACE.test(body.charAt(at))) {
1083
+ at += 1
1084
+ }
1085
+ return at
1086
+ }
1087
+ let bindingStart = skipSpace(body.indexOf('for') + 3)
1088
+ const isAsync = AWAIT_PREFIX.test(body.slice(bindingStart))
1089
+ if (isAsync) {
1090
+ bindingStart = skipSpace(bindingStart + 'await'.length)
1091
+ }
1092
+ const region = body.slice(bindingStart)
1093
+ const ofAt = indexOfKeywordAtDepthZero(region, ' of ')
1094
+ if (ofAt === -1) {
1095
+ report('[abide] {#for} requires `<binding> of <iterable>`', bodyLoc)
1096
+ return {
1097
+ items: '',
1098
+ as: '_item',
1099
+ index: undefined,
1100
+ key: undefined,
1101
+ async: isAsync,
1102
+ loc: bodyLoc,
1103
+ asLoc: undefined,
1104
+ keyLoc: undefined,
1105
+ indexLoc: undefined,
1106
+ }
1107
+ }
1108
+ const left = region.slice(0, ofAt).trim()
1109
+ const itemsStart = skipSpace(bindingStart + ofAt + ' of '.length)
1110
+ let itemsRegion = body.slice(itemsStart)
1111
+ const byAt = indexOfKeywordAtDepthZero(itemsRegion, ' by ')
1112
+ const keyRaw = byAt === -1 ? '' : itemsRegion.slice(byAt + ' by '.length)
1113
+ const key = byAt === -1 ? undefined : keyRaw.trim()
1114
+ const keyLoc =
1115
+ byAt === -1 ? undefined : bodyLoc + itemsStart + byAt + ' by '.length + leadingSpace(keyRaw)
1116
+ if (byAt !== -1) {
1117
+ itemsRegion = itemsRegion.slice(0, byAt)
1118
+ }
1119
+ const commaAt = bindingCommaAtDepthZero(left)
1120
+ const as = (commaAt === -1 ? left : left.slice(0, commaAt)).trim()
1121
+ const indexRaw = commaAt === -1 ? '' : left.slice(commaAt + 1)
1122
+ const index = commaAt === -1 ? undefined : indexRaw.trim()
1123
+ /* `left` is trimmed and starts at `bindingStart` (skipSpace'd), so the binding
1124
+ name begins exactly there; the index sits past the comma. */
1125
+ return {
1126
+ items: itemsRegion.trim(),
1127
+ as: as === '' ? '_item' : as,
1128
+ index,
1129
+ key,
1130
+ async: isAsync,
1131
+ loc: bodyLoc + itemsStart,
1132
+ asLoc: as === '' ? undefined : bodyLoc + bindingStart,
1133
+ keyLoc,
1134
+ indexLoc:
1135
+ commaAt === -1
1136
+ ? undefined
1137
+ : bodyLoc + bindingStart + commaAt + 1 + leadingSpace(indexRaw),
1138
+ }
1139
+ }
1140
+
1141
+ /* A `case` node (`<template else>`/`<template case>`/`<template default>`) is valid
1142
+ only as a direct child of its `<template if>`/`<template switch>`; a `branch`
1143
+ (`then`/`catch`/`finally`) only inside its `<template await>`/`<template try>`.
1144
+ A sibling `<template else>` — closed off from its `if` — parses to a stray `case`
1145
+ sitting beside the `if`, which would otherwise be silently dropped. Record it so
1146
+ the mistake surfaces at compile time. Recurses so a stray branch nested anywhere
1147
+ is caught, not just at the root. */
1148
+ function rejectStrayBranches(
1149
+ nodes: TemplateNode[],
1150
+ parentKind: TemplateNode['kind'] | undefined,
1151
+ report: Report,
1152
+ ): void {
1153
+ for (const node of nodes) {
1154
+ if (node.kind === 'case' && parentKind !== 'if' && parentKind !== 'switch') {
1155
+ report(
1156
+ '[abide] <template else>/<template case> must be nested inside its <template if>/<template switch> — a sibling branch is not supported',
1157
+ node.loc ?? 0,
1158
+ )
1159
+ }
1160
+ /* `elseif` is an `if`-chain branch; inside a `switch` it would silently read as the
1161
+ default (match-less), so reject the cross-construct mix. */
1162
+ if (node.kind === 'case' && node.condition !== undefined && parentKind === 'switch') {
1163
+ report(
1164
+ '[abide] <template elseif> belongs to a <template if> chain, not a <template switch> — use <template case>',
1165
+ node.loc ?? 0,
1166
+ )
1167
+ }
1168
+ if (
1169
+ node.kind === 'branch' &&
1170
+ parentKind !== 'await' &&
1171
+ parentKind !== 'try' &&
1172
+ parentKind !== 'each'
1173
+ ) {
1174
+ report(
1175
+ '[abide] <template then>/<template catch>/<template finally> must be nested inside its <template await>/<template try>/<template each await>',
1176
+ node.asLoc ?? 0,
1177
+ )
1178
+ }
1179
+ if (node.kind === 'if' || node.kind === 'switch') {
1180
+ rejectMisplacedBranchContent(node, report)
1181
+ }
1182
+ if ('children' in node) {
1183
+ rejectStrayBranches(node.children, node.kind, report)
1184
+ }
1185
+ }
1186
+ }
1187
+
1188
+ /* A `<template if>` chain's `then` content precedes its first branch tag
1189
+ (`elseif`/`else`); a `<template switch>` renders only its branch tags. So rendered
1190
+ content sitting AFTER the first branch in an `if` — or ANYWHERE in a `switch` —
1191
+ belongs to no branch: today it silently folds into `then` / is dropped. Record it so
1192
+ the misplacement surfaces. Whitespace stays transparent, and `<script>`/`<style>` are
1193
+ directives (scoping, not rendered output), so both remain legal anywhere. */
1194
+ function rejectMisplacedBranchContent(
1195
+ node: Extract<TemplateNode, { kind: 'if' | 'switch' }>,
1196
+ report: Report,
1197
+ ): void {
1198
+ const firstBranch = node.children.findIndex((child) => child.kind === 'case')
1199
+ node.children.forEach((child, index) => {
1200
+ const isRenderedContent =
1201
+ child.kind !== 'case' &&
1202
+ child.kind !== 'script' &&
1203
+ child.kind !== 'style' &&
1204
+ !isWhitespaceText(child)
1205
+ /* In a switch nothing but branches renders, so every position is illegal; in an if
1206
+ only content past the first branch is (the leading then-content is legal). */
1207
+ const illegalPosition =
1208
+ node.kind === 'switch' || (firstBranch !== -1 && index > firstBranch)
1209
+ if (isRenderedContent && illegalPosition) {
1210
+ report(
1211
+ node.kind === 'switch'
1212
+ ? '[abide] a <template switch> renders only its <template case>/<template default> branches — move stray content into a branch'
1213
+ : '[abide] content after a <template elseif>/<template else> belongs to no branch — the then-content must precede the first branch tag',
1214
+ node.loc ?? 0,
1215
+ )
1216
+ }
1217
+ })
1218
+ /* In an `if` chain the match-less `<template else>` is the trailing block, so any
1219
+ branch after it (a second `else`, or an `elseif`) compiles to invalid `} else {…}
1220
+ else if {…}` (SSR/type-shadow) or a silently-shadowed branch (the `switchBlock`
1221
+ default wins). Reject so the misordering surfaces here, not as opaque codegen. */
1222
+ if (node.kind === 'if') {
1223
+ const branches = node.children.filter(
1224
+ (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
1225
+ )
1226
+ const elseIndex = branches.findIndex(
1227
+ (branch) => branch.match === undefined && branch.condition === undefined,
1228
+ )
1229
+ if (elseIndex !== -1 && elseIndex < branches.length - 1) {
1230
+ report(
1231
+ '[abide] <template else> must be the last branch of its <template if> chain — no <template elseif>/<template else> may follow it',
1232
+ node.loc ?? 0,
1233
+ )
1234
+ }
1235
+ }
1236
+ }
1237
+
1238
+ /* Turns a component's attributes into props. A component has no directives —
1239
+ every attribute is a prop under its written name, so `on*`/`bind:`/`attach`
1240
+ round-trip to their original names (the kinds the tag-blind attribute parser
1241
+ assigned) instead of being dropped. A static value becomes a string literal —
1242
+ a bare attribute coerces to `true` instead; every other kind keeps its `code`,
1243
+ letting a prop hold any value, functions included (e.g. an `onclick` callback). */
1244
+ function toProps(
1245
+ attrs: TemplateAttr[],
1246
+ ): { name: string; code: string; loc?: number; nameLoc?: number; spread?: boolean }[] {
1247
+ return attrs.map((attr) => {
1248
+ /* A `{...expr}` spread carries no name — its keys merge in at runtime
1249
+ (`mergeProps`/`spreadProps`); `spread: true` marks it for the back-ends. */
1250
+ if (attr.kind === 'spread') {
1251
+ return { name: '', code: attr.code, loc: attr.loc, spread: true }
1252
+ }
1253
+ if (attr.kind === 'static') {
1254
+ /* A bare attribute (`<Toggle on />`) is a boolean flag: coerce it to
1255
+ `true` so the prop reads as a boolean, not the empty string a native
1256
+ element would serialise. An explicit `on=""` stays the empty string. */
1257
+ return {
1258
+ name: attr.name,
1259
+ code: attr.bare ? 'true' : JSON.stringify(attr.value),
1260
+ nameLoc: attr.nameLoc,
1261
+ }
1262
+ }
1263
+ /* `label="hi {name}"` — a string-valued prop, the template-literal concatenation
1264
+ of its parts (lowered downstream like any other prop value). */
1265
+ if (attr.kind === 'interpolated') {
1266
+ return {
1267
+ name: attr.name,
1268
+ code: interpolatedTemplateLiteral(attr.parts),
1269
+ nameLoc: attr.nameLoc,
1270
+ }
1271
+ }
1272
+ /* A `bind:<name>={target}` is a two-way prop under its BARE name (`value`, not
1273
+ `bind:value`): the back-ends pass it with a write-back channel so the child can
1274
+ push changes upstream (`bindProp`), and the child upgrades it to a writable cell
1275
+ (`bindableProp`) when it writes or forwards it. */
1276
+ if (attr.kind === 'bind') {
1277
+ return { name: attr.property, code: attr.code, loc: attr.loc, bind: true as const }
1278
+ }
1279
+ /* Every other non-static kind keeps its `code`/`loc`; only the prop name differs —
1280
+ a directive (`event`/`class`/`style`/`attach`) round-trips to its written name as
1281
+ a passthrough prop. */
1282
+ const name =
1283
+ attr.kind === 'event'
1284
+ ? `on${attr.event}`
1285
+ : attr.kind === 'class'
1286
+ ? `class:${attr.name}`
1287
+ : attr.kind === 'style'
1288
+ ? `style:${attr.property}`
1289
+ : attr.kind === 'attach'
1290
+ ? 'attach'
1291
+ : attr.name
1292
+ /* Only `expression` carries a name offset; event/attach are framework-handled
1293
+ passthrough excluded from the strict prop check, so theirs is unused. */
1294
+ const nameLoc = attr.kind === 'expression' ? attr.nameLoc : undefined
1295
+ return { name, code: attr.code, loc: attr.loc, nameLoc }
1296
+ })
1297
+ }
1298
+
1299
+ /* The attribute's source name (`on<event>`/`bind:<property>` reconstructed). */
1300
+ function attrName(attr: TemplateAttr): string {
1301
+ if (attr.kind === 'event') {
1302
+ return `on${attr.event}`
1303
+ }
1304
+ if (attr.kind === 'bind') {
1305
+ return `bind:${attr.property}`
1306
+ }
1307
+ if (attr.kind === 'attach') {
1308
+ return 'attach'
1309
+ }
1310
+ if (attr.kind === 'class') {
1311
+ return `class:${attr.name}`
1312
+ }
1313
+ if (attr.kind === 'style') {
1314
+ return `style:${attr.property}`
1315
+ }
1316
+ /* A spread has no name. `attrName` only feeds `<template>` directive lookups, and a
1317
+ spread on a `<template>` is rejected at parse, so this branch is unreachable in
1318
+ practice. (Spread itself IS supported — on components as props and on native
1319
+ elements as attributes; only `<template>` directives reject it.) */
1320
+ if (attr.kind === 'spread') {
1321
+ return ''
1322
+ }
1323
+ return attr.name
1324
+ }
1325
+
1326
+ /* The absolute source anchor for a `<template>` migration diagnostic — the attribute's
1327
+ name offset when tracked, its value offset next, else 0 (no test asserts this offset). */
1328
+ function attrAnchor(attr: TemplateAttr): number {
1329
+ if ('nameLoc' in attr && attr.nameLoc !== undefined) {
1330
+ return attr.nameLoc
1331
+ }
1332
+ if ('loc' in attr && attr.loc !== undefined) {
1333
+ return attr.loc
1334
+ }
1335
+ return 0
1336
+ }
1337
+
1338
+ /* The control-flow attribute names that used to drive `<template>` directives — now
1339
+ moved to `{#…}` blocks. A `<template>` carrying one is a migration error. */
1340
+ const CONTROL_DIRECTIVES = new Set([
1341
+ 'if',
1342
+ 'elseif',
1343
+ 'else',
1344
+ 'each',
1345
+ 'await',
1346
+ 'then',
1347
+ 'catch',
1348
+ 'finally',
1349
+ 'switch',
1350
+ 'case',
1351
+ 'default',
1352
+ 'try',
1353
+ ])
1354
+
1355
+ /* A `<template>` is now ONLY a plain inert element (e.g. client-side cloning).
1356
+ Snippet declarations moved to `{#snippet name(args)}…{/snippet}` blocks; control
1357
+ flow moved to `{#…}` blocks — both record migration errors here, then fall through
1358
+ to the plain-element node so the walk continues (bounded recovery). */
1359
+ function toSnippetOrTemplate(
1360
+ attrs: TemplateAttr[],
1361
+ children: TemplateNode[],
1362
+ report: Report,
1363
+ ): TemplateNode {
1364
+ const find = (name: string) => attrs.find((attr) => attrName(attr) === name)
1365
+ const directive = attrs.find((attr) => CONTROL_DIRECTIVES.has(attrName(attr)))
1366
+ if (directive !== undefined) {
1367
+ const name = attrName(directive)
1368
+ const block = name === 'elseif' || name === 'else' ? 'if' : name
1369
+ report(
1370
+ `[abide] <template ${name}> control flow was removed — use the {#${block}…} block instead`,
1371
+ attrAnchor(directive),
1372
+ )
1373
+ }
1374
+ /* `<template name>` snippet declarations were retired for the `{#snippet name(args)}`
1375
+ block — reject with a migration error. */
1376
+ const named = find('name')
1377
+ if (named !== undefined) {
1378
+ report(
1379
+ '[abide] <template name> snippet declarations were removed — use a {#snippet name(args)}…{/snippet} block',
1380
+ attrAnchor(named),
1381
+ )
1382
+ }
1383
+ /* A plain inert `<template>` element (e.g. client-side cloning) — keep as an element. */
1384
+ return { kind: 'element', tag: 'template', attrs, children }
1385
+ }