@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.
- package/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/src/abideResolverPlugin.ts +6 -1
- package/src/devEntry.ts +43 -9
- package/src/lib/cli/parseArgvForRpc.ts +21 -24
- package/src/lib/cli/runCli.ts +9 -24
- package/src/lib/cli/tokenizeArgvFlags.ts +84 -0
- package/src/lib/server/rpc/defineRpc.ts +9 -1
- package/src/lib/server/runtime/createServer.ts +29 -12
- package/src/lib/server/runtime/devHotModuleResponse.ts +1 -1
- package/src/lib/server/runtime/gzipResponse.ts +14 -16
- package/src/lib/server/runtime/runWithRequestScope.ts +4 -1
- package/src/lib/server/runtime/types/RequestStore.ts +16 -0
- package/src/lib/server/runtime/withResponseDefaults.ts +8 -3
- package/src/lib/server/sockets/defineSocket.ts +5 -1
- package/src/lib/shared/RPC_ARGS_TYPE.ts +7 -0
- package/src/lib/shared/activeCacheStore.ts +5 -14
- package/src/lib/shared/activePage.ts +5 -19
- package/src/lib/shared/augmentModule.ts +18 -0
- package/src/lib/shared/basePath.ts +6 -5
- package/src/lib/shared/baseResolver.ts +10 -0
- package/src/lib/shared/baseSlot.ts +6 -12
- package/src/lib/shared/cache.ts +21 -1
- package/src/lib/shared/cacheStoreResolver.ts +12 -0
- package/src/lib/shared/cacheStoreSlot.ts +5 -13
- package/src/lib/shared/changeAffectsClient.ts +35 -0
- package/src/lib/shared/createResolverSlot.ts +37 -0
- package/src/lib/shared/debugGate.ts +90 -0
- package/src/lib/shared/emitLogRecord.ts +18 -4
- package/src/lib/shared/globalCacheStoreResolver.ts +12 -0
- package/src/lib/shared/globalCacheStoreSlot.ts +6 -11
- package/src/lib/shared/isDebugEnabled.ts +3 -10
- package/src/lib/shared/isDebugNegated.ts +4 -6
- package/src/lib/shared/pageResolver.ts +16 -0
- package/src/lib/shared/pageSlot.ts +5 -14
- package/src/lib/shared/requestScopeResolver.ts +12 -0
- package/src/lib/shared/requestScopeSlot.ts +7 -12
- package/src/lib/shared/responseBodyKind.ts +35 -0
- package/src/lib/shared/setBaseResolver.ts +2 -4
- package/src/lib/shared/setCacheStoreResolver.ts +3 -5
- package/src/lib/shared/setGlobalCacheStoreResolver.ts +3 -5
- package/src/lib/shared/setPageResolver.ts +2 -5
- package/src/lib/shared/setRequestScopeResolver.ts +3 -5
- package/src/lib/shared/types/DebugGate.ts +10 -0
- package/src/lib/shared/types/ResolverSlot.ts +10 -0
- package/src/lib/shared/writeDts.ts +8 -1
- package/src/lib/shared/writePublicAssetsDts.ts +5 -9
- package/src/lib/shared/writeRoutesDts.ts +12 -16
- package/src/lib/shared/writeRpcDts.ts +13 -12
- package/src/lib/shared/writeTestRpcDts.ts +11 -11
- package/src/lib/shared/writeTestSocketsDts.ts +9 -9
- package/src/lib/ui/compile/abideUiPlugin.ts +9 -7
- package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +24 -12
- package/src/lib/ui/compile/compileModule.ts +37 -16
- package/src/lib/ui/compile/compileShadow.ts +75 -1
- package/src/lib/ui/compile/createShadowLanguageService.ts +29 -3
- package/src/lib/ui/compile/generateBuild.ts +7 -1
- package/src/lib/ui/compile/parseTemplate.ts +4 -1
- package/src/lib/ui/compile/types/TemplateNode.ts +1 -1
- package/src/lib/ui/dom/mountSwappableRange.ts +79 -0
- package/src/lib/ui/dom/skeleton.ts +10 -1
- package/src/lib/ui/dom/switchBlock.ts +15 -63
- package/src/lib/ui/dom/when.ts +11 -64
- package/src/lib/ui/runtime/createDoc.ts +102 -12
- package/src/zodCjsPlugin.ts +16 -1
- package/src/lib/shared/matchesDebugPattern.ts +0 -16
- package/src/lib/shared/parseDebugPatterns.ts +0 -21
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { RequestScopeInfo } from './types/RequestScopeInfo.ts'
|
|
1
|
+
import { requestScopeResolver } from './requestScopeResolver.ts'
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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) =>
|
|
23
|
-
.toSorted()
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
|
39
|
+
return [
|
|
40
|
+
rpcUrlForFile(file),
|
|
41
|
+
`RpcArgs<typeof import(${JSON.stringify(importPath)}).${fileStem(file)}>`,
|
|
42
|
+
]
|
|
38
43
|
}),
|
|
39
44
|
)
|
|
40
|
-
const entries =
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
32
|
+
return [
|
|
33
|
+
name,
|
|
34
|
+
`RpcInvoker<typeof import(${JSON.stringify(importPath)}).${fileStem(file)}>`,
|
|
35
|
+
]
|
|
31
36
|
})
|
|
32
|
-
.toSorted()
|
|
33
|
-
const
|
|
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
|
-
|
|
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
|
|
26
|
+
return [
|
|
27
|
+
socketNameForFile(file),
|
|
28
|
+
`typeof import(${JSON.stringify(importPath)}).${fileStem(file)}`,
|
|
29
|
+
]
|
|
26
30
|
})
|
|
27
|
-
.toSorted()
|
|
28
|
-
const
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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 =
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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${
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|