@abide/abide 0.48.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.
- package/AGENTS.md +85 -59
- package/CHANGELOG.md +216 -0
- package/README.md +21 -10
- package/package.json +15 -4
- package/src/abideLsp.ts +5 -6
- package/src/abideResolverPlugin.ts +197 -169
- package/src/build.ts +79 -56
- package/src/buildCli.ts +3 -5
- package/src/buildDisconnected.ts +2 -5
- package/src/bundleApp.ts +2 -3
- package/src/checkAbide.ts +12 -2
- package/src/compile.ts +2 -4
- package/src/devEntry.ts +113 -28
- package/src/discoveryEntry.ts +3 -2
- package/src/lib/bundle/disconnected.abide +69 -73
- package/src/lib/bundle/infoPlist.ts +13 -7
- package/src/lib/bundle/installDownloads.ts +13 -3
- package/src/lib/bundle/installMacMenu.ts +13 -3
- package/src/lib/bundle/spawnEmbeddedServer.ts +13 -4
- package/src/lib/bundle/waitForServer.ts +6 -1
- package/src/lib/cli/parseArgvForRpc.ts +36 -9
- package/src/lib/cli/tokenizeArgvFlags.ts +4 -2
- package/src/lib/mcp/createMcpResourceServer.ts +13 -2
- package/src/lib/mcp/mcpTools.ts +15 -6
- package/src/lib/mcp/toolResultFromResponse.ts +40 -12
- package/src/lib/server/error.ts +6 -6
- package/src/lib/server/json.ts +17 -3
- package/src/lib/server/rpc/defineRpc.ts +58 -24
- package/src/lib/server/rpc/parseArgs.ts +113 -14
- package/src/lib/server/rpc/resolveRpcJsonSchema.ts +28 -0
- package/src/lib/server/rpc/runWithRpcTimeout.ts +12 -1
- package/src/lib/server/rpc/types/RpcHelper.ts +75 -105
- package/src/lib/server/rpc/types/RpcRegistryEntry.ts +24 -9
- package/src/lib/server/rpc/validationError.ts +1 -1
- package/src/lib/server/runtime/DEV_RELOAD_CLIENT_SCRIPT.ts +0 -15
- package/src/lib/server/runtime/buildCacheSnapshot.ts +10 -9
- package/src/lib/server/runtime/buildInspectorSurface.ts +6 -4
- package/src/lib/server/runtime/buildOpenApiSpec.ts +55 -11
- package/src/lib/server/runtime/buildPreloadManifest.ts +12 -10
- package/src/lib/server/runtime/createAppAssetServer.ts +18 -15
- package/src/lib/server/runtime/createAppRouteResolver.ts +145 -0
- package/src/lib/server/runtime/createPlumbingRouter.ts +212 -0
- package/src/lib/server/runtime/createRouteDispatcher.ts +4 -10
- package/src/lib/server/runtime/createServer.ts +120 -324
- package/src/lib/server/runtime/createUiPageRenderer.ts +137 -26
- package/src/lib/server/runtime/devClientFingerprint.ts +19 -34
- package/src/lib/server/runtime/finalizeResponse.ts +11 -1
- package/src/lib/server/runtime/installAmbientScopeStore.ts +30 -9
- package/src/lib/server/runtime/logExposedSurfaces.ts +8 -7
- package/src/lib/server/runtime/pathStore.ts +15 -0
- package/src/lib/server/runtime/registryManifests.ts +1 -1
- package/src/lib/server/runtime/renderCellBarrierStore.ts +15 -0
- package/src/lib/server/runtime/runWithRequestScope.ts +3 -0
- package/src/lib/server/runtime/serializeCacheSnapshot.ts +6 -4
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +24 -16
- package/src/lib/server/runtime/streamCacheResolutions.ts +14 -7
- package/src/lib/server/runtime/streamFromIterator.ts +30 -2
- package/src/lib/server/runtime/textResponse.ts +23 -0
- package/src/lib/server/runtime/types/DevReloadStamp.ts +6 -10
- package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
- package/src/lib/server/runtime/types/RequestStore.ts +27 -0
- package/src/lib/server/runtime/warnUnguardedMcp.ts +15 -5
- package/src/lib/server/sockets/createSocketDispatcher.ts +9 -11
- package/src/lib/server/sockets/defineSocket.ts +5 -4
- package/src/lib/server/sse.ts +5 -1
- package/src/lib/shared/ASYNC_CELL.ts +5 -0
- package/src/lib/shared/DEV_REBUILD_PATH.ts +6 -0
- package/src/lib/shared/HTTP_METHODS.ts +6 -0
- package/src/lib/shared/HttpError.ts +1 -5
- package/src/lib/shared/MCP_PATH.ts +6 -0
- package/src/lib/shared/PROXIED_SERVER_SUBDIRS.ts +15 -0
- package/src/lib/shared/REF_JSON_TAGS.ts +13 -1
- package/src/lib/shared/RPC_SHIM_GLOBALS.ts +9 -0
- package/src/lib/shared/activeCacheStore.ts +1 -0
- package/src/lib/shared/activePage.ts +1 -0
- package/src/lib/shared/buildArtifact.ts +20 -0
- package/src/lib/shared/buildRpcRequest.ts +6 -2
- package/src/lib/shared/buildSocketOverChannel.ts +4 -3
- package/src/lib/shared/bundleGraphFromMetafile.ts +68 -0
- package/src/lib/shared/cache.ts +156 -131
- package/src/lib/shared/canonicalJson.ts +24 -1
- package/src/lib/shared/changeAffectsClient.ts +12 -7
- package/src/lib/shared/createCacheStore.ts +31 -8
- package/src/lib/shared/createChannelLog.ts +24 -0
- package/src/lib/shared/createLifecycleChannel.ts +7 -1
- package/src/lib/shared/createReachable.ts +47 -78
- package/src/lib/shared/createRemoteFunction.ts +52 -30
- package/src/lib/shared/createRpcServerProgram.ts +820 -0
- package/src/lib/shared/decodeRefJson.ts +4 -4
- package/src/lib/shared/decodeResponse.ts +2 -2
- package/src/lib/shared/decodeWireBody.ts +35 -0
- package/src/lib/shared/detectRpcMethod.ts +8 -1
- package/src/lib/shared/done.ts +8 -2
- package/src/lib/shared/encodeRefJson.ts +4 -4
- package/src/lib/shared/encodeWireBody.ts +18 -0
- package/src/lib/shared/exitOnBuildFailure.ts +4 -3
- package/src/lib/{server/rpc → shared}/fieldErrorsFromIssues.ts +5 -1
- package/src/lib/shared/generateDeclarations.ts +72 -0
- package/src/lib/shared/hasSeedableRequest.ts +25 -0
- package/src/lib/shared/hydrationWindow.ts +53 -0
- package/src/lib/shared/isAsyncCell.ts +11 -0
- package/src/lib/shared/isAsyncIterable.ts +8 -0
- package/src/lib/shared/isSubscribable.ts +3 -3
- package/src/lib/shared/isThenable.ts +9 -0
- package/src/lib/shared/jsonSchemaForType.ts +258 -0
- package/src/lib/shared/lenientDecode.ts +15 -0
- package/src/lib/shared/loadProjectTsConfig.ts +28 -0
- package/src/lib/shared/markFrameworkSourcesIgnored.ts +1 -1
- package/src/lib/shared/matchRoute.ts +4 -14
- package/src/lib/shared/parseEnv.ts +17 -6
- package/src/lib/shared/peek.ts +14 -0
- package/src/lib/shared/pending.ts +9 -6
- package/src/lib/shared/pendingAsyncCellsSlot.ts +13 -0
- package/src/lib/shared/prepareRpcModule.ts +91 -93
- package/src/lib/shared/prepareSocketModule.ts +3 -2
- package/src/lib/shared/probeRegistries.ts +5 -18
- package/src/lib/shared/reachable.ts +7 -10
- package/src/lib/shared/refresh.ts +12 -2
- package/src/lib/shared/refreshing.ts +8 -2
- package/src/lib/shared/resolvedCellsSlot.ts +13 -0
- package/src/lib/shared/reviveWireField.ts +49 -0
- package/src/lib/shared/reviveWireOutput.ts +36 -0
- package/src/lib/shared/rpcServerForRoot.ts +25 -0
- package/src/lib/shared/scanPages.ts +25 -0
- package/src/lib/shared/serializeEnv.ts +18 -6
- package/src/lib/shared/snapshotShippable.ts +8 -7
- package/src/lib/shared/snippet.ts +11 -6
- package/src/lib/shared/streamedCellsSlot.ts +14 -0
- package/src/lib/shared/subscribableFromResponse.ts +4 -4
- package/src/lib/shared/subscribableProbes.ts +1 -1
- package/src/lib/shared/tailProbeSlot.ts +1 -1
- package/src/lib/shared/types/AsyncComputed.ts +20 -0
- package/src/lib/shared/types/AsyncState.ts +13 -0
- package/src/lib/shared/types/CacheEntry.ts +7 -7
- package/src/lib/shared/types/CacheOptions.ts +23 -37
- package/src/lib/shared/types/CachePolicy.ts +25 -0
- package/src/lib/shared/types/CacheSnapshotEntry.ts +6 -5
- package/src/lib/shared/types/ErrorJsonSchemas.ts +8 -0
- package/src/lib/shared/types/HttpMethod.ts +3 -1
- package/src/lib/shared/types/InputCoercion.ts +17 -0
- package/src/lib/shared/types/{Subscribable.ts → NamedAsyncIterable.ts} +1 -1
- package/src/lib/shared/types/OutputWirePlan.ts +17 -0
- package/src/lib/shared/types/PagesScan.ts +7 -0
- package/src/lib/shared/types/PendingAsyncCells.ts +10 -0
- package/src/lib/shared/types/RawRemoteFunction.ts +6 -0
- package/src/lib/shared/types/RemoteCallable.ts +8 -7
- package/src/lib/shared/types/RemoteFunction.ts +16 -15
- package/src/lib/shared/types/ResolvedCells.ts +11 -0
- package/src/lib/shared/types/ReturnBody.ts +15 -0
- package/src/lib/shared/types/RpcBuildStamps.ts +23 -0
- package/src/lib/shared/types/RpcErrorGuard.ts +3 -8
- package/src/lib/shared/types/Socket.ts +4 -4
- package/src/lib/shared/types/SsrPayload.ts +5 -1
- package/src/lib/shared/types/StreamPolicy.ts +11 -0
- package/src/lib/shared/types/StreamedCells.ts +19 -0
- package/src/lib/shared/types/StreamedResolution.ts +24 -6
- package/src/lib/shared/types/TailHooks.ts +1 -1
- package/src/lib/shared/types/WireKind.ts +11 -0
- package/src/lib/shared/url.ts +5 -0
- package/src/lib/shared/validationHttpError.ts +33 -0
- package/src/lib/shared/warmSeedKey.ts +16 -0
- package/src/lib/shared/wireJsonReplacer.ts +30 -0
- package/src/lib/shared/writeRpcDts.ts +11 -1
- package/src/lib/shared/writeTestSocketsDts.ts +1 -1
- package/src/lib/test/createTestApp.ts +19 -1
- package/src/lib/ui/README.md +1 -1
- package/src/lib/ui/activePendingCells.ts +14 -0
- package/src/lib/ui/compile/BLOCK_KEYWORDS.ts +21 -0
- package/src/lib/ui/compile/SSR_ESCAPE.ts +4 -1
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +35 -7
- package/src/lib/ui/compile/abideUiPlugin.ts +19 -2
- package/src/lib/ui/compile/analyzeComponent.ts +89 -6
- package/src/lib/ui/compile/assignmentTargetNames.ts +54 -0
- package/src/lib/ui/compile/asyncInterpolationFields.ts +152 -0
- package/src/lib/ui/compile/asyncValuePositionError.ts +21 -0
- package/src/lib/ui/compile/asyncValuePositionInterpolations.ts +64 -0
- package/src/lib/ui/compile/attrLiftPosition.ts +18 -0
- package/src/lib/ui/compile/bindListenEvent.ts +5 -6
- package/src/lib/ui/compile/cachedSourceFile.ts +40 -0
- package/src/lib/ui/compile/catchBinding.ts +7 -5
- package/src/lib/ui/compile/classifyInterpolationType.ts +46 -0
- package/src/lib/ui/compile/collectAbideDiagnostics.ts +96 -0
- package/src/lib/ui/compile/compileComponent.ts +19 -3
- package/src/lib/ui/compile/compileModule.ts +27 -58
- package/src/lib/ui/compile/compileSSR.ts +52 -13
- package/src/lib/ui/compile/compileShadow.ts +265 -42
- package/src/lib/ui/compile/composeProps.ts +15 -4
- package/src/lib/ui/compile/createShadowLanguageService.ts +116 -68
- package/src/lib/ui/compile/createShadowProgram.ts +35 -6
- package/src/lib/ui/compile/declaredNames.ts +36 -0
- package/src/lib/ui/compile/desugarSignals.ts +419 -37
- package/src/lib/ui/compile/expressionIsPrefixEvaluable.ts +19 -0
- package/src/lib/ui/compile/generateBuild.ts +74 -17
- package/src/lib/ui/compile/generateSSR.ts +286 -75
- package/src/lib/ui/compile/hoistableAwaits.ts +193 -0
- package/src/lib/ui/compile/hoistableChildRenders.ts +112 -0
- package/src/lib/ui/compile/interpolatedTemplateLiteral.ts +3 -1
- package/src/lib/ui/compile/interpolationClassifierForRoot.ts +37 -0
- package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +174 -0
- package/src/lib/ui/compile/isWhitespaceText.ts +10 -2
- package/src/lib/ui/compile/liftAsyncSubExpressions.ts +166 -0
- package/src/lib/ui/compile/lowerAsyncInterpolations.ts +92 -0
- package/src/lib/ui/compile/lowerContext.ts +10 -0
- package/src/lib/ui/compile/lowerDocAccess.ts +51 -10
- package/src/lib/ui/compile/lowerScript.ts +40 -4
- package/src/lib/ui/compile/nodeAtShadowOffset.ts +28 -0
- package/src/lib/ui/compile/parseTemplate.ts +18 -1090
- package/src/lib/ui/compile/parseTemplateRecovering.ts +1385 -0
- package/src/lib/ui/compile/referencedIdentifiers.ts +35 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +26 -40
- package/src/lib/ui/compile/resolveReactiveExport.ts +4 -7
- package/src/lib/ui/compile/scopeCss.ts +27 -4
- package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +65 -0
- package/src/lib/ui/compile/shadowInterpolationClassifier.ts +47 -0
- package/src/lib/ui/compile/sourceFileOptionsSignature.ts +20 -0
- package/src/lib/ui/compile/structuralHeadTokens.ts +107 -0
- package/src/lib/ui/compile/templateSemanticTokens.ts +21 -0
- package/src/lib/ui/compile/templateStartOffset.ts +15 -0
- package/src/lib/ui/compile/tryPlan.ts +4 -3
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +4 -0
- package/src/lib/ui/compile/types/AsyncInterpolationField.ts +19 -0
- package/src/lib/ui/compile/types/InterpolationClassifier.ts +12 -0
- package/src/lib/ui/compile/types/InterpolationKind.ts +7 -0
- package/src/lib/ui/compile/types/ParseDiagnostic.ts +8 -0
- package/src/lib/ui/compile/types/SeedTypeClassifier.ts +15 -0
- package/src/lib/ui/compile/types/TemplateNode.ts +36 -4
- package/src/lib/ui/compile/types/ValuePositionInterpolation.ts +13 -0
- package/src/lib/ui/compile/writtenTemplateNames.ts +84 -0
- package/src/lib/ui/computed.ts +28 -1
- package/src/lib/ui/createScope.ts +35 -68
- package/src/lib/ui/dom/anchoredBranch.ts +142 -0
- package/src/lib/ui/dom/appendText.ts +8 -3
- package/src/lib/ui/dom/awaitBlock.ts +62 -75
- package/src/lib/ui/dom/bindProp.ts +22 -0
- package/src/lib/ui/dom/bindableProp.ts +47 -0
- package/src/lib/ui/dom/cellPending.ts +24 -0
- package/src/lib/ui/dom/discardBoundary.ts +24 -6
- package/src/lib/ui/dom/disposeRange.ts +2 -1
- package/src/lib/ui/dom/each.ts +41 -12
- package/src/lib/ui/dom/eachAsync.ts +39 -3
- package/src/lib/ui/dom/fillBefore.ts +6 -7
- package/src/lib/ui/dom/fillBoundary.ts +2 -3
- package/src/lib/ui/dom/fillRange.ts +4 -4
- package/src/lib/ui/dom/hydrate.ts +6 -12
- package/src/lib/ui/dom/isComment.ts +1 -1
- package/src/lib/ui/dom/matchingRangeClose.ts +29 -0
- package/src/lib/ui/dom/mergeProps.ts +1 -1
- package/src/lib/ui/dom/mount.ts +1 -2
- package/src/lib/ui/dom/mountChild.ts +9 -40
- package/src/lib/ui/dom/mountRange.ts +2 -3
- package/src/lib/ui/dom/mountSlot.ts +1 -1
- package/src/lib/ui/dom/mountStreamedChild.ts +84 -0
- package/src/lib/ui/dom/mountSwappableRange.ts +46 -4
- package/src/lib/ui/dom/mutateDocContainer.ts +65 -0
- package/src/lib/ui/dom/on.ts +2 -2
- package/src/lib/ui/dom/readCell.ts +40 -0
- package/src/lib/ui/dom/restProps.ts +2 -2
- package/src/lib/ui/dom/spreadProps.ts +3 -4
- package/src/lib/ui/dom/switchBlock.ts +30 -7
- package/src/lib/ui/dom/tryBlock.ts +233 -70
- package/src/lib/ui/dom/types/SwitchCase.ts +5 -1
- package/src/lib/ui/dom/when.ts +12 -2
- package/src/lib/ui/dom/withScope.ts +9 -2
- package/src/lib/ui/finalizeStreamedChildren.ts +89 -0
- package/src/lib/ui/flight.ts +56 -0
- package/src/lib/ui/html.ts +12 -1
- package/src/lib/ui/isolateCellBarrier.ts +17 -0
- package/src/lib/ui/linked.ts +48 -2
- package/src/lib/ui/props.ts +17 -0
- package/src/lib/ui/remoteProxy.ts +84 -159
- package/src/lib/ui/renderChain.ts +53 -13
- package/src/lib/ui/renderToStream.ts +22 -18
- package/src/lib/ui/resumeSeedScript.ts +1 -1
- package/src/lib/ui/router.ts +86 -53
- package/src/lib/ui/runtime/AsyncCellError.ts +20 -0
- package/src/lib/ui/runtime/CELL_SEED.ts +14 -0
- package/src/lib/ui/runtime/CHILD_PRESENT.ts +2 -2
- package/src/lib/ui/runtime/CURRENT_BOUNDARY.ts +11 -0
- package/src/lib/ui/runtime/CURRENT_PATH.ts +29 -0
- package/src/lib/ui/runtime/RENDER.ts +10 -7
- package/src/lib/ui/runtime/RESUME.ts +4 -4
- package/src/lib/ui/runtime/STREAMED_CELLS.ts +57 -0
- package/src/lib/ui/runtime/ambientPathBacking.ts +32 -0
- package/src/lib/ui/runtime/applyPatchToTree.ts +17 -4
- package/src/lib/ui/runtime/blockId.ts +31 -0
- package/src/lib/ui/runtime/boundaryFor.ts +11 -0
- package/src/lib/ui/runtime/cellBarrierBacking.ts +30 -0
- package/src/lib/ui/runtime/createAsyncCell.ts +300 -0
- package/src/lib/ui/runtime/createDoc.ts +31 -54
- package/src/lib/ui/runtime/createEffectNode.ts +9 -0
- package/src/lib/ui/runtime/enterRenderPass.ts +5 -4
- package/src/lib/ui/runtime/flushEffects.ts +33 -1
- package/src/lib/ui/runtime/isAsyncFunction.ts +14 -0
- package/src/lib/ui/runtime/nextBlockId.ts +8 -7
- package/src/lib/ui/runtime/renderPath.ts +16 -0
- package/src/lib/ui/runtime/scope.ts +11 -0
- package/src/lib/ui/runtime/toTeardown.ts +10 -3
- package/src/lib/ui/runtime/types/Boundary.ts +9 -0
- package/src/lib/ui/runtime/types/RenderContext.ts +9 -7
- package/src/lib/ui/runtime/types/SsrRender.ts +9 -2
- package/src/lib/ui/runtime/types/UiComponent.ts +1 -5
- package/src/lib/ui/runtime/types/UiProps.ts +6 -5
- package/src/lib/ui/runtime/withOptionalPath.ts +8 -0
- package/src/lib/ui/runtime/withPath.ts +16 -0
- package/src/lib/ui/runtime/withPathFrom.ts +20 -0
- package/src/lib/ui/runtime/withoutHydration.ts +22 -0
- package/src/lib/ui/seedStreamedResolution.ts +31 -6
- package/src/lib/ui/settleAsyncCells.ts +24 -0
- package/src/lib/ui/socketProxy.ts +1 -1
- package/src/lib/ui/startClient.ts +16 -31
- package/src/lib/ui/state.ts +9 -3
- package/src/lib/ui/trackedComputed.ts +68 -0
- package/src/lib/ui/types/Scope.ts +8 -24
- package/src/lib/ui/watch.ts +3 -3
- package/src/serverEntry.ts +14 -0
- package/template/src/server/rpc/getHello.ts +15 -13
- package/template/test/app.test.ts +1 -1
- package/src/lib/server/runtime/devHotModuleResponse.ts +0 -41
- package/src/lib/shared/DEV_HOT_PREFIX.ts +0 -7
- package/src/lib/shared/UNREACHABLE_STATUSES.ts +0 -13
- package/src/lib/shared/hasReplayableRequest.ts +0 -17
- package/src/lib/shared/hydratingSlot.ts +0 -12
- package/src/lib/shared/outboxProbeSlot.ts +0 -20
- package/src/lib/shared/types/Outbox.ts +0 -9
- package/src/lib/shared/types/OutboxEntry.ts +0 -27
- package/src/lib/shared/types/SmartReadOptions.ts +0 -36
- package/src/lib/shared/wakeHydrationPeeks.ts +0 -20
- package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +0 -5
- package/src/lib/ui/compile/REACTIVE_CALLEES.ts +0 -11
- package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +0 -80
- package/src/lib/ui/compile/markupTokens.ts +0 -313
- package/src/lib/ui/compile/structuralBlockTokens.ts +0 -100
- package/src/lib/ui/dom/applyResolved.ts +0 -87
- package/src/lib/ui/dom/scopeLabel.ts +0 -21
- package/src/lib/ui/dom/text.ts +0 -20
- package/src/lib/ui/history.ts +0 -101
- package/src/lib/ui/installHotBridge.ts +0 -93
- package/src/lib/ui/installInspectorBridge.ts +0 -140
- package/src/lib/ui/outbox.ts +0 -35
- package/src/lib/ui/persist.ts +0 -115
- package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +0 -281
- package/src/lib/ui/rpcOutbox/outboxRegistry.ts +0 -69
- package/src/lib/ui/runtime/PATCH_BUS.ts +0 -28
- package/src/lib/ui/runtime/captureModelDoc.ts +0 -28
- package/src/lib/ui/runtime/hotInstances.ts +0 -10
- package/src/lib/ui/runtime/hotReloadEnabled.ts +0 -8
- package/src/lib/ui/runtime/hotReplace.ts +0 -38
- package/src/lib/ui/runtime/liveScopes.ts +0 -15
- package/src/lib/ui/runtime/localStoragePersistence.ts +0 -47
- package/src/lib/ui/runtime/registerHotInstance.ts +0 -23
- package/src/lib/ui/runtime/seedModelDoc.ts +0 -26
- package/src/lib/ui/runtime/types/HotInstance.ts +0 -22
- package/src/lib/ui/runtime/types/PatchEvent.ts +0 -16
- package/src/lib/ui/seedResolved.ts +0 -28
- package/src/lib/ui/sync.ts +0 -48
- package/src/lib/ui/types/History.ts +0 -14
- package/src/lib/ui/types/PersistHandle.ts +0 -11
- package/src/lib/ui/types/PersistenceStore.ts +0 -12
- package/src/lib/ui/types/ResolvedFrame.ts +0 -15
- 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 `&`
|
|
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&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
|
+
}
|