@abide/abide 0.44.0 → 0.44.1

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 (67) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +1 -1
  3. package/src/abideResolverPlugin.ts +6 -1
  4. package/src/devEntry.ts +43 -9
  5. package/src/lib/cli/parseArgvForRpc.ts +21 -24
  6. package/src/lib/cli/runCli.ts +9 -24
  7. package/src/lib/cli/tokenizeArgvFlags.ts +84 -0
  8. package/src/lib/server/rpc/defineRpc.ts +9 -1
  9. package/src/lib/server/runtime/createServer.ts +29 -12
  10. package/src/lib/server/runtime/devHotModuleResponse.ts +1 -1
  11. package/src/lib/server/runtime/gzipResponse.ts +14 -16
  12. package/src/lib/server/runtime/runWithRequestScope.ts +4 -1
  13. package/src/lib/server/runtime/types/RequestStore.ts +16 -0
  14. package/src/lib/server/runtime/withResponseDefaults.ts +8 -3
  15. package/src/lib/server/sockets/defineSocket.ts +5 -1
  16. package/src/lib/shared/RPC_ARGS_TYPE.ts +7 -0
  17. package/src/lib/shared/activeCacheStore.ts +5 -14
  18. package/src/lib/shared/activePage.ts +5 -19
  19. package/src/lib/shared/augmentModule.ts +18 -0
  20. package/src/lib/shared/basePath.ts +6 -5
  21. package/src/lib/shared/baseResolver.ts +10 -0
  22. package/src/lib/shared/baseSlot.ts +6 -12
  23. package/src/lib/shared/cache.ts +21 -1
  24. package/src/lib/shared/cacheStoreResolver.ts +12 -0
  25. package/src/lib/shared/cacheStoreSlot.ts +5 -13
  26. package/src/lib/shared/changeAffectsClient.ts +35 -0
  27. package/src/lib/shared/createResolverSlot.ts +37 -0
  28. package/src/lib/shared/debugGate.ts +90 -0
  29. package/src/lib/shared/emitLogRecord.ts +18 -4
  30. package/src/lib/shared/globalCacheStoreResolver.ts +12 -0
  31. package/src/lib/shared/globalCacheStoreSlot.ts +6 -11
  32. package/src/lib/shared/isDebugEnabled.ts +3 -10
  33. package/src/lib/shared/isDebugNegated.ts +4 -6
  34. package/src/lib/shared/pageResolver.ts +16 -0
  35. package/src/lib/shared/pageSlot.ts +5 -14
  36. package/src/lib/shared/requestScopeResolver.ts +12 -0
  37. package/src/lib/shared/requestScopeSlot.ts +7 -12
  38. package/src/lib/shared/responseBodyKind.ts +35 -0
  39. package/src/lib/shared/setBaseResolver.ts +2 -4
  40. package/src/lib/shared/setCacheStoreResolver.ts +3 -5
  41. package/src/lib/shared/setGlobalCacheStoreResolver.ts +3 -5
  42. package/src/lib/shared/setPageResolver.ts +2 -5
  43. package/src/lib/shared/setRequestScopeResolver.ts +3 -5
  44. package/src/lib/shared/types/DebugGate.ts +10 -0
  45. package/src/lib/shared/types/ResolverSlot.ts +10 -0
  46. package/src/lib/shared/writeDts.ts +8 -1
  47. package/src/lib/shared/writePublicAssetsDts.ts +5 -9
  48. package/src/lib/shared/writeRoutesDts.ts +12 -16
  49. package/src/lib/shared/writeRpcDts.ts +13 -12
  50. package/src/lib/shared/writeTestRpcDts.ts +11 -11
  51. package/src/lib/shared/writeTestSocketsDts.ts +9 -9
  52. package/src/lib/ui/compile/abideUiPlugin.ts +9 -7
  53. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +24 -12
  54. package/src/lib/ui/compile/compileModule.ts +37 -16
  55. package/src/lib/ui/compile/compileShadow.ts +75 -1
  56. package/src/lib/ui/compile/createShadowLanguageService.ts +29 -3
  57. package/src/lib/ui/compile/generateBuild.ts +7 -1
  58. package/src/lib/ui/compile/parseTemplate.ts +4 -1
  59. package/src/lib/ui/compile/types/TemplateNode.ts +1 -1
  60. package/src/lib/ui/dom/mountSwappableRange.ts +79 -0
  61. package/src/lib/ui/dom/skeleton.ts +10 -1
  62. package/src/lib/ui/dom/switchBlock.ts +15 -63
  63. package/src/lib/ui/dom/when.ts +11 -64
  64. package/src/lib/ui/runtime/createDoc.ts +102 -12
  65. package/src/zodCjsPlugin.ts +16 -1
  66. package/src/lib/shared/matchesDebugPattern.ts +0 -16
  67. package/src/lib/shared/parseDebugPatterns.ts +0 -21
@@ -1,6 +1,4 @@
1
- import { requestScopeSlot } from './requestScopeSlot.ts'
2
- import type { RequestScopeInfo } from './types/RequestScopeInfo.ts'
1
+ import { requestScopeResolver } from './requestScopeResolver.ts'
3
2
 
4
- export function setRequestScopeResolver(fn: () => RequestScopeInfo | undefined): void {
5
- requestScopeSlot.resolver = fn
6
- }
3
+ // Registers the runtime's request-scope resolver. Called once per side at boot.
4
+ export const setRequestScopeResolver = requestScopeResolver.set
@@ -0,0 +1,10 @@
1
+ /*
2
+ The two questions a DEBUG string answers for one channel name. `enabled` gates a
3
+ diagnostic channel (inclusion, with exclusions winning); `negated` is the off
4
+ switch for always-on channels (a `-name` pattern silencing the app's own or
5
+ abide's framework voice). Returned by debugGate, already bound to one env value.
6
+ */
7
+ export type DebugGate = {
8
+ enabled(name: string): boolean
9
+ negated(name: string): boolean
10
+ }
@@ -0,0 +1,10 @@
1
+ /*
2
+ The mutable cell a runtime entry registers its resolver into, plus the single
3
+ lazy/value fallback used when no resolver is registered. createServer and
4
+ startClient install side-specific resolvers; isolated tests poke `.resolver` /
5
+ `.fallback` directly, so both fields stay public and mutable.
6
+ */
7
+ export type ResolverSlot<T> = {
8
+ resolver: (() => T | undefined) | undefined
9
+ fallback: T | undefined
10
+ }
@@ -8,5 +8,12 @@ src tsconfig include picks up), wrapping `body` in the shared banner + the
8
8
  differ only in `body`, so the envelope lives here.
9
9
  */
10
10
  export async function writeDts(cwd: string, name: string, body: string): Promise<void> {
11
- await Bun.write(`${cwd}/src/.abide/${name}.d.ts`, `${DTS_BANNER}\n${body}\n\nexport {}\n`)
11
+ const next = `${DTS_BANNER}\n${body}\n\nexport {}\n`
12
+ const file = Bun.file(`${cwd}/src/.abide/${name}.d.ts`)
13
+ // Skip the write when byte-identical — an unchanged rebuild (the common case)
14
+ // otherwise bumps mtime every save, forcing the editor's TS server to re-typecheck.
15
+ if ((await file.exists()) && (await file.text()) === next) {
16
+ return
17
+ }
18
+ await Bun.write(file, next)
12
19
  }
@@ -1,3 +1,4 @@
1
+ import { augmentModule } from './augmentModule.ts'
1
2
  import { writeDts } from './writeDts.ts'
2
3
 
3
4
  /*
@@ -19,13 +20,8 @@ export async function writePublicAssetsDts({
19
20
  importName: string
20
21
  }): Promise<void> {
21
22
  const entries = publicFiles
22
- .map((file) => ` ${JSON.stringify(`/${file}`)}: true`)
23
- .toSorted()
24
- .join('\n')
25
- const body = `declare module '${importName}/shared/url' {
26
- interface PublicAssets {
27
- ${entries}
28
- }
29
- }`
30
- await writeDts(cwd, 'publicAssets', body)
23
+ .map((file): [string, string] => [`/${file}`, 'true'])
24
+ .toSorted(([a], [b]) => (JSON.stringify(a) < JSON.stringify(b) ? -1 : 1))
25
+ const module = augmentModule(`${importName}/shared/url`, 'PublicAssets', entries)
26
+ await writeDts(cwd, 'publicAssets', module)
31
27
  }
@@ -1,3 +1,4 @@
1
+ import { augmentModule } from './augmentModule.ts'
1
2
  import { pageUrlForFile } from './pageUrlForFile.ts'
2
3
  import { routeParamsShape } from './routeParamsShape.ts'
3
4
  import { writeDts } from './writeDts.ts'
@@ -25,21 +26,16 @@ export async function writeRoutesDts({
25
26
  const routes = pageFiles
26
27
  .map((file) => ({ route: pageUrlForFile(file) }))
27
28
  .toSorted((a, b) => a.route.localeCompare(b.route))
28
- const entries = routes
29
- .map(({ route }) => ` ${JSON.stringify(route)}: ${routeParamsShape(route)}`)
30
- .join('\n')
29
+ const routesModule = augmentModule(
30
+ `${importName}/shared/page`,
31
+ 'Routes',
32
+ routes.map(({ route }): [string, string] => [route, routeParamsShape(route)]),
33
+ )
31
34
  /* Keys-only mirror for url()'s autocomplete (values unused — PathParams derives the shape). */
32
- const urlKeys = routes.map(({ route }) => ` ${JSON.stringify(route)}: true`).join('\n')
33
- const body = `declare module '${importName}/shared/page' {
34
- interface Routes {
35
- ${entries}
36
- }
37
- }
38
-
39
- declare module '${importName}/shared/url' {
40
- interface PageRoutes {
41
- ${urlKeys}
42
- }
43
- }`
44
- await writeDts(cwd, 'routes', body)
35
+ const urlModule = augmentModule(
36
+ `${importName}/shared/url`,
37
+ 'PageRoutes',
38
+ routes.map(({ route }): [string, string] => [route, 'true']),
39
+ )
40
+ await writeDts(cwd, 'routes', `${routesModule}\n\n${urlModule}`)
45
41
  }
@@ -1,6 +1,8 @@
1
+ import { augmentModule } from './augmentModule.ts'
1
2
  import { carriesBodyArgs } from './carriesBodyArgs.ts'
2
3
  import { detectRpcMethod } from './detectRpcMethod.ts'
3
4
  import { fileStem } from './fileStem.ts'
5
+ import { RPC_ARGS_TYPE } from './RPC_ARGS_TYPE.ts'
4
6
  import { rpcUrlForFile } from './rpcUrlForFile.ts'
5
7
  import { writeDts } from './writeDts.ts'
6
8
 
@@ -26,24 +28,23 @@ export async function writeRpcDts({
26
28
  rpcFiles: string[]
27
29
  importName: string
28
30
  }): Promise<void> {
29
- const lines = await Promise.all(
30
- rpcFiles.map(async (file) => {
31
+ const pairs = await Promise.all(
32
+ rpcFiles.map(async (file): Promise<[string, string] | undefined> => {
31
33
  const method = detectRpcMethod(await Bun.file(`${rpcDir}/${file}`).text())
32
34
  // A body rpc's args can't ride a URL — leave it out of the url() rpc map.
33
35
  if (!method || carriesBodyArgs(method)) {
34
36
  return undefined
35
37
  }
36
38
  const importPath = `../server/rpc/${file}`
37
- return ` ${JSON.stringify(rpcUrlForFile(file))}: RpcArgs<typeof import(${JSON.stringify(importPath)}).${fileStem(file)}>`
39
+ return [
40
+ rpcUrlForFile(file),
41
+ `RpcArgs<typeof import(${JSON.stringify(importPath)}).${fileStem(file)}>`,
42
+ ]
38
43
  }),
39
44
  )
40
- const entries = lines.filter((line) => line !== undefined).toSorted()
41
- const body = `type RpcArgs<Fn> = Fn extends (args: infer Args) => unknown ? Exclude<Args, FormData> : never
42
-
43
- declare module '${importName}/shared/url' {
44
- interface RpcRoutes {
45
- ${entries.join('\n')}
46
- }
47
- }`
48
- await writeDts(cwd, 'rpc', body)
45
+ const entries = pairs
46
+ .filter((pair) => pair !== undefined)
47
+ .toSorted(([a], [b]) => (JSON.stringify(a) < JSON.stringify(b) ? -1 : 1))
48
+ const module = augmentModule(`${importName}/shared/url`, 'RpcRoutes', entries)
49
+ await writeDts(cwd, 'rpc', `${RPC_ARGS_TYPE}\n\n${module}`)
49
50
  }
@@ -1,5 +1,7 @@
1
+ import { augmentModule } from './augmentModule.ts'
1
2
  import { commandNameForUrl } from './commandNameForUrl.ts'
2
3
  import { fileStem } from './fileStem.ts'
4
+ import { RPC_ARGS_TYPE } from './RPC_ARGS_TYPE.ts'
3
5
  import { rpcUrlForFile } from './rpcUrlForFile.ts'
4
6
  import { writeDts } from './writeDts.ts'
5
7
 
@@ -24,22 +26,20 @@ export async function writeTestRpcDts({
24
26
  importName: string
25
27
  }): Promise<void> {
26
28
  const entries = rpcFiles
27
- .map((file) => {
29
+ .map((file): [string, string] => {
28
30
  const name = commandNameForUrl(rpcUrlForFile(file))
29
31
  const importPath = `../server/rpc/${file}`
30
- return ` ${JSON.stringify(name)}: RpcInvoker<typeof import(${JSON.stringify(importPath)}).${fileStem(file)}>`
32
+ return [
33
+ name,
34
+ `RpcInvoker<typeof import(${JSON.stringify(importPath)}).${fileStem(file)}>`,
35
+ ]
31
36
  })
32
- .toSorted()
33
- const body = `type RpcArgs<Fn> = Fn extends (args: infer Args) => unknown ? Exclude<Args, FormData> : never
37
+ .toSorted(([a], [b]) => (JSON.stringify(a) < JSON.stringify(b) ? -1 : 1))
38
+ const helperTypes = `${RPC_ARGS_TYPE}
34
39
  type RpcReturn<Fn> = Fn extends (...args: never[]) => Promise<infer Return> ? Return : never
35
40
  type RpcInvoker<Fn> = ((args?: RpcArgs<Fn>) => Promise<RpcReturn<Fn>>) & {
36
41
  raw: (args?: RpcArgs<Fn>) => Promise<Response>
37
- }
38
-
39
- declare module '${importName}/test/createTestApp' {
40
- interface RpcClient {
41
- ${entries.join('\n')}
42
- }
43
42
  }`
44
- await writeDts(cwd, 'testRpc', body)
43
+ const module = augmentModule(`${importName}/test/createTestApp`, 'RpcClient', entries)
44
+ await writeDts(cwd, 'testRpc', `${helperTypes}\n\n${module}`)
45
45
  }
@@ -1,3 +1,4 @@
1
+ import { augmentModule } from './augmentModule.ts'
1
2
  import { fileStem } from './fileStem.ts'
2
3
  import { socketNameForFile } from './socketNameForFile.ts'
3
4
  import { writeDts } from './writeDts.ts'
@@ -20,15 +21,14 @@ export async function writeTestSocketsDts({
20
21
  importName: string
21
22
  }): Promise<void> {
22
23
  const entries = socketFiles
23
- .map((file) => {
24
+ .map((file): [string, string] => {
24
25
  const importPath = `../server/sockets/${file}`
25
- return ` ${JSON.stringify(socketNameForFile(file))}: typeof import(${JSON.stringify(importPath)}).${fileStem(file)}`
26
+ return [
27
+ socketNameForFile(file),
28
+ `typeof import(${JSON.stringify(importPath)}).${fileStem(file)}`,
29
+ ]
26
30
  })
27
- .toSorted()
28
- const body = `declare module '${importName}/test/createTestApp' {
29
- interface SocketClient {
30
- ${entries.join('\n')}
31
- }
32
- }`
33
- await writeDts(cwd, 'testSockets', body)
31
+ .toSorted(([a], [b]) => (JSON.stringify(a) < JSON.stringify(b) ? -1 : 1))
32
+ const module = augmentModule(`${importName}/test/createTestApp`, 'SocketClient', entries)
33
+ await writeDts(cwd, 'testSockets', module)
34
34
  }
@@ -3,7 +3,6 @@ import type { BunPlugin } from 'bun'
3
3
  import { fileName } from '../../shared/fileName.ts'
4
4
  import { messageFromError } from '../../shared/messageFromError.ts'
5
5
  import { AbideCompileError } from './AbideCompileError.ts'
6
- import { analyzeComponent } from './analyzeComponent.ts'
7
6
  import { compileModule } from './compileModule.ts'
8
7
  import { nearestProjectRoot } from './nearestProjectRoot.ts'
9
8
  import { offsetToLineColumn } from './offsetToLineColumn.ts'
@@ -63,14 +62,17 @@ export const abideUiPlugin: BunPlugin = {
63
62
  throw new Error(`${message.replace(/^\[abide\]\s*/, `[abide] ${at} — `)}`)
64
63
  }
65
64
  }
66
- const code = compileAbide(() => compileModule(source, { isLayout, moduleId }))
65
+ /* One compile pass yields both the module code and its scoped `<style>` blocks —
66
+ the styles come from the analysis `compileModule` already ran, so the loader no
67
+ longer re-analyzes the source just to recover them. */
68
+ const { code, styles } = compileAbide(() =>
69
+ compileModule(source, { isLayout, moduleId }),
70
+ )
67
71
  /* Browser build with `<style>`(s): concatenate every scoped block's CSS and
68
72
  pull it into the bundle via one virtual import, keyed by `moduleId` so the
69
- registry id and the CSS id agree. */
70
- const styles = compileAbide(() =>
71
- toBrowser ? analyzeComponent(source, moduleId).styles : [],
72
- )
73
- if (styles.length === 0) {
73
+ registry id and the CSS id agree. Server builds skip the import (SSR styling
74
+ comes from the already-linked sheet). */
75
+ if (!toBrowser || styles.length === 0) {
74
76
  return { contents: code, loader: 'ts' }
75
77
  }
76
78
  const virtual = `abide-style:${moduleId}`
@@ -15,20 +15,32 @@ and require that name to be imported or locally bound. A helper name inside a st
15
15
  comment is not a `CallExpression` callee, so a docs component quoting framework code
16
16
  (`mount(...)` in a snippet) never false-positives. Compile-time only; never on the hot path.
17
17
  */
18
- export function assertRuntimeHelpersBound(module: string, context: string): void {
18
+ /* `module` is either the source string (standalone use — parsed here) or the already-parsed
19
+ tree of the bodies WITHOUT the prepended import block (compile pipeline — shares the one
20
+ parse the dead-import filter made). In the latter case the import block's bindings aren't in
21
+ the tree, so `importedHelpers` supplies the helper names that block will bind. */
22
+ export function assertRuntimeHelpersBound(
23
+ module: string | ts.SourceFile,
24
+ importedHelpers: Set<string>,
25
+ context: string,
26
+ ): void {
19
27
  /* The EMITTED local names (the `$$` alias when set) — codegen calls those, and the
20
28
  aliased import binds them, so the bound/called check must use the same form. */
21
29
  const helperNames = new Set(UI_RUNTIME_IMPORTS.map((entry) => entry.alias ?? entry.name))
22
- const source = ts.createSourceFile(
23
- 'module.ts',
24
- module,
25
- ts.ScriptTarget.Latest,
26
- /* setParentNodes */ true,
27
- )
28
- /* Names a bare call can resolve to: every import binding plus every declared name.
29
- Collected generously (any identifier in a binding position) — over-approximating the
30
- bound set only risks missing a defect, never raising a false alarm on valid output. */
31
- const bound = new Set<string>()
30
+ const source =
31
+ typeof module === 'string'
32
+ ? ts.createSourceFile(
33
+ 'module.ts',
34
+ module,
35
+ ts.ScriptTarget.Latest,
36
+ /* setParentNodes */ true,
37
+ )
38
+ : module
39
+ /* Names a bare call can resolve to: the import block's helper bindings (supplied when the
40
+ tree omits them) plus every import binding and declared name in the tree. Collected
41
+ generously (any identifier in a binding position) — over-approximating the bound set only
42
+ risks missing a defect, never raising a false alarm on valid output. */
43
+ const bound = new Set<string>(importedHelpers)
32
44
  const calledHelpers: { name: string; position: number }[] = []
33
45
  const visit = (node: ts.Node): void => {
34
46
  if (
@@ -62,7 +74,7 @@ export function assertRuntimeHelpersBound(module: string, context: string): void
62
74
  if (unbound !== undefined) {
63
75
  const { line, character } = source.getLineAndCharacterOfPosition(unbound.position)
64
76
  throw new Error(
65
- `[abide] ${context} calls runtime helper \`${unbound.name}\` at line ${line + 1}:${character + 1} but never imports it — the dead-import filter dropped it. Please report this with the component source.\nOutput:\n${module}`,
77
+ `[abide] ${context} calls runtime helper \`${unbound.name}\` at line ${line + 1}:${character + 1} but never imports it — the dead-import filter dropped it. Please report this with the component source.\nOutput:\n${source.text}`,
66
78
  )
67
79
  }
68
80
  }
@@ -5,6 +5,7 @@ import { assertRuntimeHelpersBound } from './assertRuntimeHelpersBound.ts'
5
5
  import { assertTranspiles } from './assertTranspiles.ts'
6
6
  import { compileComponent } from './compileComponent.ts'
7
7
  import { compileSSR } from './compileSSR.ts'
8
+ import type { AnalyzedComponent } from './types/AnalyzedComponent.ts'
8
9
  import { UI_RUNTIME_IMPORTS } from './UI_RUNTIME_IMPORTS.ts'
9
10
 
10
11
  /*
@@ -26,7 +27,7 @@ what the `.abide` bundler loader emits.
26
27
  export function compileModule(
27
28
  source: string,
28
29
  options: { isLayout?: boolean; moduleId?: string; hot?: boolean } = {},
29
- ): string {
30
+ ): { code: string; styles: AnalyzedComponent['styles'] } {
30
31
  const isLayout = options.isLayout ?? false
31
32
  /* Run the shared front-end once and feed it to both back-ends — the analysis is
32
33
  pure over (source, moduleId), so the client and SSR builds reuse one parse
@@ -52,7 +53,10 @@ export function compileModule(
52
53
  : `${entry.name}: ${entry.alias}`,
53
54
  ).join(', ')
54
55
  const id = JSON.stringify(options.moduleId)
55
- return `const { ${names}, hotReplace } = window.__abide
56
+ /* Hot mode never imports the scoped CSS (it reuses the live bundle's sheet), so
57
+ styles are empty here regardless of the analyzed blocks. */
58
+ return {
59
+ code: `const { ${names}, hotReplace } = window.__abide
56
60
  ${userImports}
57
61
  function build(host, $props) {
58
62
  ${body}
@@ -63,7 +67,9 @@ function component(host, $props) {
63
67
  component.build = build
64
68
  component.__abideId = ${id}
65
69
  if (!hotReplace(${id}, component)) location.reload()
66
- `
70
+ `,
71
+ styles: [],
72
+ }
67
73
  }
68
74
 
69
75
  const ssrBody = indent(compileSSR(source, isLayout, options.moduleId, analyzed))
@@ -101,10 +107,24 @@ ${options.moduleId === undefined ? '' : `component.__abideId = ${JSON.stringify(
101
107
  string/comment/HTML literal (e.g. an `on`-attribute in static markup) no longer
102
108
  forces a spurious import, so no per-surface scoping is needed: a client-only helper
103
109
  simply doesn't appear as an identifier in the SSR body. */
104
- const referenced = collectIdentifiers(`${userImports}\n${body}\n${ssrBody}\n${moduleBody}`)
105
- const importBlock = UI_RUNTIME_IMPORTS.filter((entry) =>
110
+ /* Parse the generated bodies ONCE and feed the single tree to both AST passes: the
111
+ dead-import filter (`collectIdentifiers`) and the binding backstop
112
+ (`assertRuntimeHelpersBound`). The import block isn't in this tree yet — it is derived
113
+ from the filter's result — so the backstop is told the names that block will bind
114
+ (`importedHelpers`), which is exactly what it would have read from the prepended imports
115
+ had it re-parsed the whole module. `setParentNodes` is required by the backstop's
116
+ `getStart`/line lookups. */
117
+ const bodySource = ts.createSourceFile(
118
+ 'module.ts',
119
+ `${userImports}\n${moduleBody}`,
120
+ ts.ScriptTarget.Latest,
121
+ /* setParentNodes */ true,
122
+ )
123
+ const referenced = collectIdentifiers(bodySource)
124
+ const keptImports = UI_RUNTIME_IMPORTS.filter((entry) =>
106
125
  referenced.has(entry.alias ?? entry.name),
107
126
  )
127
+ const importBlock = keptImports
108
128
  .map((entry) => {
109
129
  const local =
110
130
  entry.alias === undefined || entry.alias === entry.name
@@ -126,9 +146,15 @@ ${moduleBody}`
126
146
  /* `assertTranspiles` only proves the output PARSES — a call to an un-imported helper is
127
147
  valid syntax, so it slips through. This second guard proves the output is BOUND: every
128
148
  runtime helper it calls is actually imported (an independent check of the dead-import
129
- filter above), turning a runtime `ReferenceError` into a located compile error. */
130
- assertRuntimeHelpersBound(module, 'component module generation')
131
- return module
149
+ filter above), turning a runtime `ReferenceError` into a located compile error. It walks
150
+ the SAME tree the filter just parsed, with the kept helper imports supplied as the bound
151
+ set the prepended import block provides. */
152
+ assertRuntimeHelpersBound(
153
+ bodySource,
154
+ new Set(keptImports.map((entry) => entry.alias ?? entry.name)),
155
+ 'component module generation',
156
+ )
157
+ return { code: module, styles: analyzed.styles }
132
158
  }
133
159
 
134
160
  /* Indents a body block for embedding inside a wrapper function. Lines whose start
@@ -170,14 +196,9 @@ function unescapedBacktickCount(line: string): number {
170
196
  in a handler — leaves the scanner unable to find the substitution's closing `}` without
171
197
  the parser's re-scan, so a token-only pass mis-reads the rest of the module as template
172
198
  text and drops every helper referenced after it (an `import`-less `effect`/`mountChild`
173
- → a `ReferenceError` at mount → the router's reload fallback → a refresh loop). */
174
- function collectIdentifiers(code: string): Set<string> {
175
- const source = ts.createSourceFile(
176
- 'module.ts',
177
- code,
178
- ts.ScriptTarget.Latest,
179
- /* setParentNodes */ false,
180
- )
199
+ → a `ReferenceError` at mount → the router's reload fallback → a refresh loop). Takes the
200
+ already-parsed source so the one tree feeds this pass and the binding backstop both. */
201
+ function collectIdentifiers(source: ts.SourceFile): Set<string> {
181
202
  const names = new Set<string>()
182
203
  const visit = (node: ts.Node): void => {
183
204
  if (ts.isIdentifier(node)) {
@@ -97,7 +97,11 @@ export function compileShadow(source: string, propsType = 'Record<string, any>')
97
97
  for (const line of scope) {
98
98
  builder.flush(line)
99
99
  }
100
- emitNodes(parseTemplate(source.slice(templateStart), templateStart).nodes, builder)
100
+ const templateNodes = parseTemplate(source.slice(templateStart), templateStart).nodes
101
+ /* Nested `<script>` blocks inline into the synchronous `build()` too, so a top-level
102
+ await in one is the same build-breaker — flag it, mapped via the node's body offset. */
103
+ collectNestedScriptAwaitDiagnostics(templateNodes, diagnostics)
104
+ emitNodes(templateNodes, builder)
101
105
  builder.raw('}\n')
102
106
  return { ...builder.result(), diagnostics }
103
107
  }
@@ -214,6 +218,73 @@ function collectReservedNameDiagnostics(
214
218
  visit(file)
215
219
  }
216
220
 
221
+ /* Pushes a diagnostic for every `await` sitting at the script's top level — outside any
222
+ nested function. The generated `build()` runs the leading script synchronously, so a
223
+ top-level await transpiles to `await` in a non-async function and breaks the bundle; the
224
+ shadow's render fn is async, so `tsc` alone never flags it (a check/runtime parity gap).
225
+ Stops descending at function boundaries — their own async-ness is the author's concern —
226
+ and catches both `await expr` and `for await (… of …)`, flagging the `await` keyword. */
227
+ function collectTopLevelAwaitDiagnostics(
228
+ file: ts.SourceFile,
229
+ scriptStart: number,
230
+ diagnostics: ShadowDiagnostic[],
231
+ ): void {
232
+ const message =
233
+ 'top-level `await` is not allowed in a `<script>` — the component build runs synchronously. Use `{#await expr}…{:then value}…{/await}` markup for blocking data, or `await` inside an async event handler.'
234
+ const visit = (node: ts.Node): void => {
235
+ /* A nested function introduces its own (possibly async) scope; its awaits are legal. */
236
+ if (
237
+ ts.isFunctionDeclaration(node) ||
238
+ ts.isFunctionExpression(node) ||
239
+ ts.isArrowFunction(node) ||
240
+ ts.isMethodDeclaration(node) ||
241
+ ts.isGetAccessorDeclaration(node) ||
242
+ ts.isSetAccessorDeclaration(node) ||
243
+ ts.isConstructorDeclaration(node)
244
+ ) {
245
+ return
246
+ }
247
+ /* The `await` keyword token: the AwaitExpression's first child, or a `for await`'s
248
+ await modifier. */
249
+ const keyword = ts.isAwaitExpression(node)
250
+ ? node.getChildAt(0, file)
251
+ : ts.isForOfStatement(node)
252
+ ? node.awaitModifier
253
+ : undefined
254
+ if (keyword !== undefined) {
255
+ diagnostics.push({
256
+ start: scriptStart + keyword.getStart(file),
257
+ length: keyword.getEnd() - keyword.getStart(file),
258
+ message,
259
+ })
260
+ }
261
+ ts.forEachChild(node, visit)
262
+ }
263
+ visit(file)
264
+ }
265
+
266
+ /* Recursively visits every nested `<script>` in the template and flags a top-level await in
267
+ its body — the same trap as the leading script (it inlines into the sync `build()`), mapped
268
+ through the node's body offset (`loc`). Walks `children` on every node kind that carries
269
+ them, so a script buried in an `{#if}`/`{#for}`/`{#await}` branch is still reached. */
270
+ function collectNestedScriptAwaitDiagnostics(
271
+ nodes: TemplateNode[],
272
+ diagnostics: ShadowDiagnostic[],
273
+ ): void {
274
+ for (const node of nodes) {
275
+ if (node === undefined) {
276
+ continue
277
+ }
278
+ if (node.kind === 'script' && node.loc !== undefined) {
279
+ const file = ts.createSourceFile('nested.ts', node.code, ts.ScriptTarget.Latest, true)
280
+ collectTopLevelAwaitDiagnostics(file, node.loc, diagnostics)
281
+ }
282
+ if ('children' in node) {
283
+ collectNestedScriptAwaitDiagnostics(node.children, diagnostics)
284
+ }
285
+ }
286
+ }
287
+
217
288
  /* Walks the leading `<script>` and produces the shadow's module imports, the
218
289
  module-scope type declarations, the value-typed scope lines, and the `props<Shape>()`
219
290
  prop shapes. `scriptStart` is the body's absolute offset in the source, so verbatim
@@ -232,6 +303,9 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
232
303
  `$$scope`, …), so an author binding may not start with it — that's the contract that
233
304
  lets a user freely name a variable after any helper. Flag every such declaration. */
234
305
  collectReservedNameDiagnostics(file, scriptStart, diagnostics)
306
+ /* The leading script runs in the synchronous `build()`, so a top-level await breaks the
307
+ bundle — catch it here with a legible message instead of an opaque transpile error. */
308
+ collectTopLevelAwaitDiagnostics(file, scriptStart, diagnostics)
235
309
  /* A verbatim span: original text + the segment mapping it back, relative to the
236
310
  line start (the caller rebases shadowStart onto the running shadow length). */
237
311
  const span = (node: ts.Node, prefixLength: number): ScopeLine['segments'][number] => ({
@@ -47,6 +47,21 @@ export function createShadowLanguageService(cwd: string): ShadowLanguageService
47
47
  const versions = new Map<string, number>()
48
48
  const shadows = new Map<string, CompiledShadow>()
49
49
  const parseErrors = new Map<string, string>()
50
+ /* Memo of `shadowText` output keyed by source path, tagged with the shadow
51
+ version it was compiled at; a stale tag forces recompilation. `update`/`close`
52
+ bump that version, so the next read recompiles. */
53
+ const compiledAt = new Map<string, { version: number; code: string }>()
54
+ /* Memo of `pagePropsType` (route re-parse) per source path; the path → props
55
+ type mapping is immutable, so no version tag is needed. */
56
+ const propsTypes = new Map<string, string | undefined>()
57
+
58
+ /* The route props type for a component, parsed once and reused. */
59
+ const propsTypeOf = (abidePath: string): string | undefined => {
60
+ if (!propsTypes.has(abidePath)) {
61
+ propsTypes.set(abidePath, pagePropsType(abidePath))
62
+ }
63
+ return propsTypes.get(abidePath)
64
+ }
50
65
 
51
66
  /* Ambient declarations for bundler-handled asset imports (`*.css`, …). */
52
67
  const assets = assetModulesFile(cwd)
@@ -55,18 +70,29 @@ export function createShadowLanguageService(cwd: string): ShadowLanguageService
55
70
  overlays.has(abidePath) || ts.sys.fileExists(abidePath)
56
71
 
57
72
  /* Compiles (and caches) a component's shadow from its overlay or disk text; a
58
- template parse error yields a minimal valid module + a recorded message. */
73
+ template parse error yields a minimal valid module + a recorded message.
74
+ Memoised against the shadow's version (bumped by `update`/`close`), so
75
+ repeated reads at the same version skip the ~700-line compile and the
76
+ `shadows`/`parseErrors` caches stay current. */
59
77
  const shadowText = (abidePath: string): string => {
78
+ const version = versions.get(suffixed(abidePath)) ?? 0
79
+ const memo = compiledAt.get(abidePath)
80
+ if (memo !== undefined && memo.version === version) {
81
+ return memo.code
82
+ }
60
83
  const source = overlays.get(abidePath) ?? ts.sys.readFile(abidePath) ?? ''
61
84
  try {
62
- const compiled = compileShadow(source, pagePropsType(abidePath))
85
+ const compiled = compileShadow(source, propsTypeOf(abidePath))
63
86
  shadows.set(abidePath, compiled)
64
87
  parseErrors.delete(abidePath)
88
+ compiledAt.set(abidePath, { version, code: compiled.code })
65
89
  return compiled.code
66
90
  } catch (error) {
67
91
  shadows.set(abidePath, { code: '', mappings: [] })
68
92
  parseErrors.set(abidePath, messageFromError(error))
69
- return 'export default function (): void {}\n'
93
+ const code = 'export default function (): void {}\n'
94
+ compiledAt.set(abidePath, { version, code })
95
+ return code
70
96
  }
71
97
  }
72
98
 
@@ -125,7 +125,13 @@ export function generateBuild(
125
125
  (typeof node.attrs)[number],
126
126
  {
127
127
  kind:
128
- 'expression' | 'interpolated' | 'event' | 'attach' | 'bind' | 'class' | 'style'
128
+ | 'expression'
129
+ | 'interpolated'
130
+ | 'event'
131
+ | 'attach'
132
+ | 'bind'
133
+ | 'class'
134
+ | 'style'
129
135
  }
130
136
  >,
131
137
  varName: string,
@@ -615,6 +615,9 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
615
615
  verbatim to its `</script>` (not parsed as markup), scoped by the
616
616
  containing branch when compiled. */
617
617
  if (tag === 'script' && !selfClosing) {
618
+ /* Body starts at the current cursor; its absolute source offset lets the shadow
619
+ map a diagnostic raised inside it (e.g. a top-level await) back to source. */
620
+ const bodyLoc = baseOffset + cursor
618
621
  const close = source.indexOf('</script>', cursor)
619
622
  const end = close === -1 ? source.length : close
620
623
  const code = source.slice(cursor, end)
@@ -631,7 +634,7 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
631
634
  "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.",
632
635
  )
633
636
  }
634
- return { kind: 'script', code }
637
+ return { kind: 'script', code, loc: bodyLoc }
635
638
  }
636
639
  /* A capitalised tag is a child component; its attributes become props and
637
640
  its children become slot content (rendered where the child puts <slot>). */
@@ -12,7 +12,7 @@ positions for the type-checking shadow, ignored by the runtime back-ends.
12
12
  */
13
13
  export type TemplateNode =
14
14
  | { kind: 'text'; parts: TextPart[] }
15
- | { kind: 'script'; code: string }
15
+ | { kind: 'script'; code: string; loc?: number }
16
16
  /* A `<style>` declared in the template. `css` is its raw body, read structurally
17
17
  so a `<style>` inside an expression isn't mistaken for one. The node stays in
18
18
  place: it scopes its sibling subtree, so the front-end derives each scope