@akanjs/devkit 3.1.0-rc.9 → 3.1.1-rc.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/CHANGELOG.md +382 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/akanConfig/akanConfig.test.ts +8 -4
- package/akanConfig/akanConfig.ts +3 -2
- package/applicationBuildRunner.test.ts +77 -2
- package/applicationBuildRunner.ts +28 -5
- package/artifact/implicitRootLayout.test.ts +11 -1
- package/artifact/implicitRootLayout.ts +33 -8
- package/biome.base.json +8 -3
- package/biomeBase.test.ts +78 -0
- package/biomeBase.ts +1 -1
- package/codeAgent/agent/AkanCodeCompaction.ts +49 -0
- package/codeAgent/agent/AkanCodeServices.ts +2 -3
- package/codeAgent/agent/CodeAgent.ts +46 -2
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/akanCodeCompaction.test.ts +36 -0
- package/codeAgent/agent/codeAgent.test.ts +61 -3
- package/codeAgent/agent/codeAgentContract.test.ts +1 -0
- package/codeAgent/index.ts +1 -0
- package/codeAgent/skills/akan-code/SKILL.md +4 -0
- package/codeAgent/skills/akan-store/SKILL.md +4 -2
- package/codeAgent/tools/AkanCodePlugins.ts +3 -1
- package/codeAgent/tools/DeepSeekWeb.ts +127 -0
- package/codeAgent/tools/WebToolPack.ts +149 -25
- package/codeAgent/tools/akanCodePlugins.test.ts +191 -0
- package/codeAgent/tools/deepSeekWeb.test.ts +113 -0
- package/commandDecorators/argMeta.ts +1 -0
- package/commandDecorators/command.ts +49 -10
- package/commandDecorators/commandBuilder.ts +6 -3
- package/commandDecorators/commandDecorators.test.ts +169 -6
- package/commandDecorators/helpFormatter.ts +2 -1
- package/commandDecorators/targetMeta.ts +10 -0
- package/executors.test.ts +53 -1
- package/executors.ts +10 -6
- package/frontendBuild/csrArtifactBuilder.test.ts +40 -0
- package/frontendBuild/csrArtifactBuilder.ts +24 -1
- package/frontendBuild/cssCandidateCache.test.ts +16 -0
- package/frontendBuild/cssCandidateCache.ts +8 -6
- package/frontendBuild/dictionaryAssetBuilder.test.ts +77 -0
- package/frontendBuild/dictionaryAssetBuilder.ts +49 -0
- package/frontendBuild/fontPruner.ts +1 -0
- package/frontendBuild/mcpAppViewBuilder.test.ts +170 -0
- package/frontendBuild/mcpAppViewBuilder.ts +245 -0
- package/frontendBuild/precompressArtifacts.test.ts +42 -0
- package/frontendBuild/precompressArtifacts.ts +21 -10
- package/frontendBuild/routeClientBuilder.ts +1 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +3 -0
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/incrementalBuilder/buildBatch.proc.ts +3 -3
- package/incrementalBuilder/buildBatchProtocol.ts +2 -2
- package/lint/__fixtures__/no-conditional-st-hook/bad.tsx +45 -0
- package/lint/__fixtures__/no-conditional-st-hook/good.tsx +33 -0
- package/lint/no-conditional-st-hook.grit +146 -0
- package/mobile/desktopServerStage.ts +2 -1
- package/mobile/nativeApi.ts +1 -1
- package/mobile/nativeApp.ts +1 -1
- package/mobile/nativeConfig.ts +1 -1
- package/mobile/updatePackFolder.ts +1 -1
- package/mobile/updateUploader.ts +1 -1
- package/package.json +15 -15
- package/routeSourceValidator.test.ts +19 -4
- package/routeSourceValidator.ts +62 -14
- package/scanInfo.ts +36 -3
- package/subspace.test.ts +3 -2
- package/subspaceClone.ts +1 -12
- package/transforms/barrelImportsPlugin.ts +4 -2
- package/transforms/mcpAppPageTrimmer.test.ts +89 -0
- package/transforms/mcpAppPageTrimmer.ts +70 -0
- package/transforms/pureLazyMarker.test.ts +58 -0
- package/transforms/pureLazyMarker.ts +42 -0
- package/transforms/sideEffectImportOrder.test.ts +183 -0
- package/transforms/sideEffectImportOrder.ts +109 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { mkdir, readdir, rename, rm, unlink } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseRouteModuleKey } from "akanjs/common";
|
|
4
|
+
import type { BaseBuildArtifact } from "akanjs/server";
|
|
5
|
+
import type { PageEntry } from "../artifact/implicitRootLayout";
|
|
6
|
+
import type { App } from "../commandDecorators";
|
|
7
|
+
import { McpAppPageTrimmer } from "../transforms/mcpAppPageTrimmer";
|
|
8
|
+
import { PureLazyMarker } from "../transforms/pureLazyMarker";
|
|
9
|
+
import { CsrArtifactBuilder } from "./csrArtifactBuilder";
|
|
10
|
+
import { CsrEntryFiles } from "./csrEntryFiles";
|
|
11
|
+
|
|
12
|
+
type CssAsset = NonNullable<BaseBuildArtifact["cssAssets"]>[string];
|
|
13
|
+
|
|
14
|
+
interface McpAppViewBuildOption {
|
|
15
|
+
basePaths: string[];
|
|
16
|
+
cssAssets: Record<string, CssAsset>;
|
|
17
|
+
/** The CSR shell's own bundle options, so a view inlines the same env and stubs the same stylesheets. */
|
|
18
|
+
options: ReturnType<typeof CsrArtifactBuilder.bundleOptions>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface McpAppView {
|
|
22
|
+
name: string;
|
|
23
|
+
page: PageEntry;
|
|
24
|
+
/** The generated root layout that renders `System.Provider` for the page, whose `McpAppRoot` the view uses. */
|
|
25
|
+
root: PageEntry | null;
|
|
26
|
+
/** The generated wrappers of the `_overrides.tsx` the web draws outside the root layout, outermost first: they also
|
|
27
|
+
* reach what it draws beside the page, its toasts and approvals. */
|
|
28
|
+
rootOverrides: PageEntry[];
|
|
29
|
+
/** The generated wrappers of the rest of the `_overrides.tsx` over the page, outermost first, around it alone. */
|
|
30
|
+
overrides: PageEntry[];
|
|
31
|
+
basePath: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
//* One single-file HTML per `.app()`, which an MCP host renders as that app's view: its entry imports the app's page,
|
|
35
|
+
//* trimmed to its loads, and the page's root layout for its providers. Built beside `csr/`, never inside it: a native
|
|
36
|
+
//* app packs `csr/`, which needs none of them.
|
|
37
|
+
export class McpAppViewBuilder {
|
|
38
|
+
/** The folder the server reads views from: `WebRouter.mcpAppViewDir`. */
|
|
39
|
+
static readonly dirName = "mcp-apps";
|
|
40
|
+
/** `PageDefinition.promptName`, which also keeps a view's file name inside its folder. */
|
|
41
|
+
static readonly #appName = /^[A-Za-z0-9_-]{1,64}$/;
|
|
42
|
+
|
|
43
|
+
readonly #app: App;
|
|
44
|
+
readonly #command: "build" | "start";
|
|
45
|
+
|
|
46
|
+
constructor(app: App, command: "build" | "start") {
|
|
47
|
+
this.#app = app;
|
|
48
|
+
this.#command = command;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get outputDir(): string {
|
|
52
|
+
return this.#command === "build"
|
|
53
|
+
? path.join(this.#app.dist.cwdPath, McpAppViewBuilder.dirName)
|
|
54
|
+
: path.join(this.#app.cwdPath, ".akan", "artifact", McpAppViewBuilder.dirName);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async build(
|
|
58
|
+
pageEntries: PageEntry[],
|
|
59
|
+
{ basePaths, cssAssets, options }: McpAppViewBuildOption,
|
|
60
|
+
): Promise<McpAppView[]> {
|
|
61
|
+
const views = await McpAppViewBuilder.discover(pageEntries, basePaths);
|
|
62
|
+
if (!views.length) {
|
|
63
|
+
await rm(this.outputDir, { recursive: true, force: true });
|
|
64
|
+
return views;
|
|
65
|
+
}
|
|
66
|
+
//? Built aside, then moved in a file at a time: a host may read a view while `akan start` rebuilds them.
|
|
67
|
+
const stagingDir = `${this.outputDir}.next`;
|
|
68
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
69
|
+
const generatedDir = path.join(this.#app.cwdPath, ".akan", "generated", McpAppViewBuilder.dirName);
|
|
70
|
+
await rm(generatedDir, { recursive: true, force: true });
|
|
71
|
+
await mkdir(generatedDir, { recursive: true });
|
|
72
|
+
const files = Object.fromEntries(
|
|
73
|
+
views.flatMap((view) => [
|
|
74
|
+
[path.join(generatedDir, `${view.name}.html`), McpAppViewBuilder.htmlOf(view)],
|
|
75
|
+
[path.join(generatedDir, `${view.name}.view.tsx`), McpAppViewBuilder.entryOf(view, generatedDir)],
|
|
76
|
+
]),
|
|
77
|
+
);
|
|
78
|
+
const pagePaths = [...new Set(views.map((view) => view.page.moduleAbsPath))];
|
|
79
|
+
const result = await Bun.build({
|
|
80
|
+
...options,
|
|
81
|
+
plugins: [...options.plugins, McpAppPageTrimmer.plugin(pagePaths), PureLazyMarker.plugin()],
|
|
82
|
+
entrypoints: views.map((view) => path.join(generatedDir, `${view.name}.html`)),
|
|
83
|
+
files,
|
|
84
|
+
root: generatedDir,
|
|
85
|
+
outdir: stagingDir,
|
|
86
|
+
});
|
|
87
|
+
if (!result.success) {
|
|
88
|
+
const logs = result.logs.map((log) => log.message).join("\n");
|
|
89
|
+
throw new Error(`[mcp-app-build] failed${logs ? `\n${logs}` : ""}`);
|
|
90
|
+
}
|
|
91
|
+
for (const view of views) await this.#inline(view, stagingDir, cssAssets[view.basePath]);
|
|
92
|
+
await this.#publish(views, stagingDir);
|
|
93
|
+
return views;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async #publish(views: McpAppView[], stagingDir: string) {
|
|
97
|
+
const names = new Set(views.map((view) => `${view.name}.html`));
|
|
98
|
+
await mkdir(this.outputDir, { recursive: true });
|
|
99
|
+
for (const name of names) await rename(path.join(stagingDir, name), path.join(this.outputDir, name));
|
|
100
|
+
for (const name of await readdir(this.outputDir))
|
|
101
|
+
if (!names.has(name)) await rm(path.join(this.outputDir, name), { recursive: true, force: true });
|
|
102
|
+
const strays = await readdir(stagingDir);
|
|
103
|
+
if (strays.length)
|
|
104
|
+
this.#app.logger.warn(
|
|
105
|
+
`[mcp-app-build] ${strays.join(", ")} reached no view; a view loads nothing beside its own file`,
|
|
106
|
+
);
|
|
107
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Every `.app()` the pages declare, read off their sources without evaluating them. */
|
|
111
|
+
static async discover(pageEntries: PageEntry[], basePaths: string[]): Promise<McpAppView[]> {
|
|
112
|
+
const { RouteSourceValidator } = await import("../routeSourceValidator");
|
|
113
|
+
const roots = pageEntries.filter((entry) => parseRouteModuleKey(entry.key).isInternalRootLayout);
|
|
114
|
+
const overrides = pageEntries.filter((entry) => parseRouteModuleKey(entry.key).kind === "overrides");
|
|
115
|
+
const layoutNodes = new Set(
|
|
116
|
+
pageEntries.flatMap((entry) => {
|
|
117
|
+
const parsed = parseRouteModuleKey(entry.key);
|
|
118
|
+
return parsed.kind === "layout" && !parsed.isInternalRootLayout ? [parsed.routeSegments.join("/")] : [];
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
const owners = new Map<string, { name: string; key: string }>();
|
|
122
|
+
const views: McpAppView[] = [];
|
|
123
|
+
for (const page of pageEntries) {
|
|
124
|
+
if (parseRouteModuleKey(page.key).kind !== "page") continue;
|
|
125
|
+
const source = await Bun.file(page.moduleAbsPath).text();
|
|
126
|
+
const apps =
|
|
127
|
+
RouteSourceValidator.validateRouteSourceExports(source, page.moduleAbsPath, "page").chain?.apps ?? [];
|
|
128
|
+
for (const name of apps) {
|
|
129
|
+
if (!McpAppViewBuilder.#appName.test(name))
|
|
130
|
+
throw new Error(
|
|
131
|
+
`[route-convention] app name "${name}" in ${page.key} must match ${McpAppViewBuilder.#appName}`,
|
|
132
|
+
);
|
|
133
|
+
//? Keyed in lower case: a case-insensitive disk would write two such views to one file.
|
|
134
|
+
const owner = owners.get(name.toLowerCase());
|
|
135
|
+
if (owner?.name === name)
|
|
136
|
+
throw new Error(`[route-convention] app "${name}" is declared by both ${owner.key} and ${page.key}`);
|
|
137
|
+
if (owner)
|
|
138
|
+
throw new Error(
|
|
139
|
+
`[route-convention] apps "${owner.name}" (${owner.key}) and "${name}" (${page.key}) differ only in case; their views would share one file`,
|
|
140
|
+
);
|
|
141
|
+
owners.set(name.toLowerCase(), { name, key: page.key });
|
|
142
|
+
const root = await McpAppViewBuilder.#rootOf(page.key, roots);
|
|
143
|
+
views.push({
|
|
144
|
+
name,
|
|
145
|
+
page,
|
|
146
|
+
root,
|
|
147
|
+
...McpAppViewBuilder.#overridesOf(page.key, root, overrides, layoutNodes),
|
|
148
|
+
basePath: CsrEntryFiles.basePathOfPageKey(page.key, basePaths) ?? "",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return views;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
static htmlOf(view: McpAppView): string {
|
|
156
|
+
return `<!doctype html>
|
|
157
|
+
<html lang="en">
|
|
158
|
+
<head>
|
|
159
|
+
<meta charset="utf-8" />
|
|
160
|
+
<title>${view.name}</title>
|
|
161
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
162
|
+
</head>
|
|
163
|
+
<body>
|
|
164
|
+
<div id="root"></div>
|
|
165
|
+
<script type="module" src="./${view.name}.view.tsx"></script>
|
|
166
|
+
</body>
|
|
167
|
+
</html>
|
|
168
|
+
`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
static entryOf(view: McpAppView, fromDir: string): string {
|
|
172
|
+
const specifier = (absPath: string) => {
|
|
173
|
+
const relative = path.relative(fromDir, absPath).split(path.sep).join("/");
|
|
174
|
+
return JSON.stringify(relative.startsWith(".") ? relative : `./${relative}`);
|
|
175
|
+
};
|
|
176
|
+
const importsOf = (entries: PageEntry[], name: string) =>
|
|
177
|
+
entries.map((entry, idx) => `import ${name}${idx} from ${specifier(entry.moduleAbsPath)};\n`).join("");
|
|
178
|
+
const namesOf = (entries: PageEntry[], name: string) => entries.map((_, idx) => `${name}${idx}`).join(", ");
|
|
179
|
+
return `import { McpAppBoot } from "akanjs/webkit";
|
|
180
|
+
import * as page from ${specifier(view.page.moduleAbsPath)};
|
|
181
|
+
${view.root ? `import * as root from ${specifier(view.root.moduleAbsPath)};` : "const root = {};"}
|
|
182
|
+
${importsOf(view.rootOverrides, "rootOverride")}${importsOf(view.overrides, "override")}void McpAppBoot.run({
|
|
183
|
+
app: ${JSON.stringify(view.name)},
|
|
184
|
+
page,
|
|
185
|
+
root,
|
|
186
|
+
rootOverrides: [${namesOf(view.rootOverrides, "rootOverride")}],
|
|
187
|
+
overrides: [${namesOf(view.overrides, "override")}],
|
|
188
|
+
basePath: ${JSON.stringify(view.basePath)},
|
|
189
|
+
});
|
|
190
|
+
`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
//? By route segments, as the route tree nests them (`RouteLayering`): a folder's `_overrides.tsx` also covers the
|
|
194
|
+
//? page file named like the folder (`memo/_overrides.tsx` over `memo.tsx`), and one on a root node sits outside the
|
|
195
|
+
//? root layout — the root layout's own node, or one above it that holds no layout of its own.
|
|
196
|
+
static #overridesOf(pageKey: string, root: PageEntry | null, overrides: PageEntry[], layoutNodes: Set<string>) {
|
|
197
|
+
const pageSegments = parseRouteModuleKey(pageKey).routeSegments;
|
|
198
|
+
const rootDepth = root ? parseRouteModuleKey(root.key).routeSegments.length : 0;
|
|
199
|
+
const over = overrides
|
|
200
|
+
.map((entry) => ({ entry, segments: parseRouteModuleKey(entry.key).routeSegments }))
|
|
201
|
+
.filter(({ segments }) => segments.every((segment, idx) => pageSegments[idx] === segment))
|
|
202
|
+
.sort((a, b) => a.segments.length - b.segments.length);
|
|
203
|
+
const onRootNode = (segments: string[]) =>
|
|
204
|
+
segments.length === rootDepth || (segments.length < rootDepth && !layoutNodes.has(segments.join("/")));
|
|
205
|
+
return {
|
|
206
|
+
rootOverrides: over.filter(({ segments }) => onRootNode(segments)).map(({ entry }) => entry),
|
|
207
|
+
overrides: over.filter(({ segments }) => !onRootNode(segments)).map(({ entry }) => entry),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
//? The outermost root boundary over the page: the one a page is drawn under without an ancestor root, which alone
|
|
212
|
+
//? renders `System.Provider` and so exports `McpAppRoot`.
|
|
213
|
+
static async #rootOf(pageKey: string, roots: PageEntry[]): Promise<PageEntry | null> {
|
|
214
|
+
const dirOf = (key: string) => key.slice(0, key.lastIndexOf("/") + 1);
|
|
215
|
+
const pageDir = dirOf(pageKey);
|
|
216
|
+
const over = roots
|
|
217
|
+
.filter((root) => pageDir.startsWith(dirOf(root.key)))
|
|
218
|
+
.sort((a, b) => dirOf(a.key).length - dirOf(b.key).length);
|
|
219
|
+
for (const root of over)
|
|
220
|
+
if ((await Bun.file(root.moduleAbsPath).text()).includes("export function McpAppRoot")) return root;
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async #inline(view: McpAppView, dir: string, cssAsset: CssAsset | undefined) {
|
|
225
|
+
const htmlPath = path.join(dir, `${view.name}.html`);
|
|
226
|
+
let html = await Bun.file(htmlPath).text();
|
|
227
|
+
if (cssAsset) {
|
|
228
|
+
const artifactDir = path.join(
|
|
229
|
+
this.#command === "build" ? this.#app.dist.cwdPath : this.#app.cwdPath,
|
|
230
|
+
".akan",
|
|
231
|
+
"artifact",
|
|
232
|
+
);
|
|
233
|
+
const css = await Bun.file(path.join(artifactDir, cssAsset.cssRelPath)).text();
|
|
234
|
+
html = CsrArtifactBuilder.injectBeforeHeadEnd(html, CsrArtifactBuilder.createInlineStyle(css));
|
|
235
|
+
} else this.#app.logger.warn(`[mcp-app-build] no compiled stylesheet for ${view.name}; its view ships without CSS`);
|
|
236
|
+
const scripts: string[] = [];
|
|
237
|
+
html = await CsrArtifactBuilder.replaceModuleScriptSrc(html, async (src) => {
|
|
238
|
+
const jsPath = CsrArtifactBuilder.resolveHtmlAssetPath(htmlPath, src);
|
|
239
|
+
scripts.push(jsPath);
|
|
240
|
+
return await Bun.file(jsPath).text();
|
|
241
|
+
});
|
|
242
|
+
await Bun.write(htmlPath, html);
|
|
243
|
+
for (const script of scripts) await unlink(script).catch(() => undefined);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import zlib from "node:zlib";
|
|
5
|
+
import type { App } from "../commandDecorators";
|
|
6
|
+
import { tempDirs } from "../testHelpers";
|
|
7
|
+
import { precompressArtifacts } from "./precompressArtifacts";
|
|
8
|
+
|
|
9
|
+
const tempRoot = tempDirs("akan-devkit-precompress-");
|
|
10
|
+
|
|
11
|
+
const brotliAt = (bytes: Buffer, quality: number) =>
|
|
12
|
+
zlib.brotliCompressSync(bytes, {
|
|
13
|
+
params: {
|
|
14
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: quality,
|
|
15
|
+
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: bytes.byteLength,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("precompressArtifacts", () => {
|
|
20
|
+
test("writes sidecars for client chunks, stylesheets and dictionaries, a dictionary's brotli at quality 11", async () => {
|
|
21
|
+
const distPath = await tempRoot();
|
|
22
|
+
const artifactDir = path.join(distPath, ".akan/artifact");
|
|
23
|
+
const files = {
|
|
24
|
+
"client/chunk.js": `export const x = ${JSON.stringify("chunk ".repeat(400))};`,
|
|
25
|
+
"styles/root.css": `.a{color:red}${".b{margin:0}".repeat(200)}`,
|
|
26
|
+
"dict/en-abc.js": `export default JSON.parse(${JSON.stringify(JSON.stringify({ a: { t: "word ".repeat(400) } }))});`,
|
|
27
|
+
};
|
|
28
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
29
|
+
await mkdir(path.dirname(path.join(artifactDir, rel)), { recursive: true });
|
|
30
|
+
await writeFile(path.join(artifactDir, rel), content);
|
|
31
|
+
}
|
|
32
|
+
const app = { dist: { cwdPath: distPath }, verbose: () => undefined } as unknown as App;
|
|
33
|
+
|
|
34
|
+
expect((await precompressArtifacts(app)).files).toBe(3);
|
|
35
|
+
const dictPath = path.join(artifactDir, "dict/en-abc.js");
|
|
36
|
+
const dictBytes = await readFile(dictPath);
|
|
37
|
+
expect(await readFile(`${dictPath}.br`)).toEqual(brotliAt(dictBytes, 11));
|
|
38
|
+
const chunkPath = path.join(artifactDir, "client/chunk.js");
|
|
39
|
+
expect(await readFile(`${chunkPath}.br`)).toEqual(brotliAt(await readFile(chunkPath), 9));
|
|
40
|
+
expect(await Bun.file(`${dictPath}.gz`).exists()).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -6,8 +6,10 @@ import { FontPruner } from "./fontPruner";
|
|
|
6
6
|
|
|
7
7
|
const COMPRESSIBLE_EXTS = new Set([".css", ".html", ".js", ".json", ".svg"]);
|
|
8
8
|
const MIN_COMPRESS_BYTES = 1024;
|
|
9
|
-
// Brotli 11 is ~100x slower than gzip: worth it for the one CSS asset per basePath
|
|
10
|
-
|
|
9
|
+
// Brotli 11 is ~100x slower than gzip: worth it for the one CSS asset per basePath and one dictionary per locale, not
|
|
10
|
+
// for hundreds of JS chunks.
|
|
11
|
+
const MAX_BROTLI_QUALITY = 11;
|
|
12
|
+
const BROTLI_QUALITY_BY_EXT = { ".css": MAX_BROTLI_QUALITY } as const;
|
|
11
13
|
const DEFAULT_BROTLI_QUALITY = 9;
|
|
12
14
|
const GZIP_LEVEL = 9;
|
|
13
15
|
const { formatBytes } = FontPruner;
|
|
@@ -20,14 +22,16 @@ export interface PrecompressArtifactsResult {
|
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export async function precompressArtifacts(app: App): Promise<PrecompressArtifactsResult> {
|
|
23
|
-
//* WebRouter serves client/ and
|
|
25
|
+
//* WebRouter serves client/, styles/ and dict/ through the same sidecar-aware #fileResponse.
|
|
26
|
+
const artifactDir = path.join(app.dist.cwdPath, ".akan/artifact");
|
|
24
27
|
const roots = [
|
|
25
|
-
path.join(
|
|
26
|
-
path.join(
|
|
28
|
+
{ root: path.join(artifactDir, "client") },
|
|
29
|
+
{ root: path.join(artifactDir, "styles") },
|
|
30
|
+
{ root: path.join(artifactDir, "dict"), brotliQuality: MAX_BROTLI_QUALITY },
|
|
27
31
|
];
|
|
28
32
|
const result: PrecompressArtifactsResult = { files: 0, inputBytes: 0, outputBytes: 0, brotliBytes: 0 };
|
|
29
33
|
|
|
30
|
-
await Promise.all(roots.map((root) => precompressRoot(root, result)));
|
|
34
|
+
await Promise.all(roots.map(({ root, brotliQuality }) => precompressRoot(root, result, brotliQuality)));
|
|
31
35
|
if (result.files > 0) {
|
|
32
36
|
app.verbose(
|
|
33
37
|
`[precompress] wrote ${result.files} sidecars (${formatBytes(result.inputBytes)} -> gzip ${formatBytes(
|
|
@@ -38,7 +42,11 @@ export async function precompressArtifacts(app: App): Promise<PrecompressArtifac
|
|
|
38
42
|
return result;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
async function precompressRoot(
|
|
45
|
+
async function precompressRoot(
|
|
46
|
+
root: string,
|
|
47
|
+
result: PrecompressArtifactsResult,
|
|
48
|
+
brotliQuality?: number,
|
|
49
|
+
): Promise<void> {
|
|
42
50
|
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) return;
|
|
43
51
|
const glob = new Bun.Glob("**/*");
|
|
44
52
|
for await (const filePath of glob.scan({ cwd: root, absolute: true })) {
|
|
@@ -46,7 +54,7 @@ async function precompressRoot(root: string, result: PrecompressArtifactsResult)
|
|
|
46
54
|
const bytes = await Bun.file(filePath).bytes();
|
|
47
55
|
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
48
56
|
const gz = Bun.gzipSync(buffer, { level: GZIP_LEVEL });
|
|
49
|
-
const br = brotliCompress(buffer, path.extname(filePath).toLowerCase());
|
|
57
|
+
const br = brotliCompress(buffer, brotliQuality ?? brotliQualityOf(path.extname(filePath).toLowerCase()));
|
|
50
58
|
await Promise.all([Bun.write(`${filePath}.gz`, gz), Bun.write(`${filePath}.br`, br)]);
|
|
51
59
|
result.files += 1;
|
|
52
60
|
result.inputBytes += bytes.byteLength;
|
|
@@ -55,8 +63,11 @@ async function precompressRoot(root: string, result: PrecompressArtifactsResult)
|
|
|
55
63
|
}
|
|
56
64
|
}
|
|
57
65
|
|
|
58
|
-
function
|
|
59
|
-
|
|
66
|
+
function brotliQualityOf(ext: string): number {
|
|
67
|
+
return BROTLI_QUALITY_BY_EXT[ext as keyof typeof BROTLI_QUALITY_BY_EXT] ?? DEFAULT_BROTLI_QUALITY;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function brotliCompress(buffer: ArrayBuffer, quality: number): Buffer {
|
|
60
71
|
return zlib.brotliCompressSync(buffer, {
|
|
61
72
|
params: {
|
|
62
73
|
[zlib.constants.BROTLI_PARAM_QUALITY]: quality,
|
|
@@ -169,6 +169,7 @@ export class RouteClientBuilder {
|
|
|
169
169
|
plugins: [
|
|
170
170
|
await createBarrelImportsPlugin(this.#app, {
|
|
171
171
|
pipeAfter: reactFastRefresh ? RouteClientBuilder.normalizeNamedDefaultFunctionForFastRefresh : undefined,
|
|
172
|
+
external: VENDOR_SPECIFIERS,
|
|
172
173
|
}),
|
|
173
174
|
],
|
|
174
175
|
external: VENDOR_SPECIFIERS,
|
|
@@ -8,6 +8,7 @@ import { computeRouteSeedIndex, type RouteSeedIndex, saveRouteSeedIndex } from "
|
|
|
8
8
|
import type { App } from "../commandDecorators";
|
|
9
9
|
import { ClientEntriesBundler } from "./clientEntriesBundler";
|
|
10
10
|
import { CssCompiler, type ImportedStylesheet } from "./cssCompiler";
|
|
11
|
+
import { DictionaryAssetBuilder } from "./dictionaryAssetBuilder";
|
|
11
12
|
import { FontOptimizer } from "./fontOptimizer";
|
|
12
13
|
import { PagesBundleBuilder } from "./pagesBundleBuilder";
|
|
13
14
|
import { RouteClientBuilder } from "./routeClientBuilder";
|
|
@@ -56,6 +57,7 @@ export class SsrBaseArtifactBuilder {
|
|
|
56
57
|
|
|
57
58
|
const pageEntries = await resolveSsrPageEntriesForApp(this.#app, pageKeys);
|
|
58
59
|
const { cssCompiler, optimizedFonts, cssAssets } = await this.#buildStyleAssets();
|
|
60
|
+
const dictAssets = await new DictionaryAssetBuilder(this.#app, this.#absArtifactDir).build(akanConfig.i18n);
|
|
59
61
|
const pagesBundle = await new PagesBundleBuilder(this.#app, this.#command, pageEntries, this.#serverEnv).build();
|
|
60
62
|
this.#app.verbose(
|
|
61
63
|
`[base-artifact] pages bundle -> ${pagesBundle.bundlePath} (buildId=${pagesBundle.buildId}, splitting=${pagesBundle.splitting}, entry=${pagesBundle.entryBytes} bytes, outputs=${pagesBundle.outputCount}, chunks=${pagesBundle.chunkCount}, total=${pagesBundle.outputBytes} bytes)`,
|
|
@@ -75,6 +77,7 @@ export class SsrBaseArtifactBuilder {
|
|
|
75
77
|
rscRuntimeSsrManifest,
|
|
76
78
|
vendorMap,
|
|
77
79
|
cssAssets,
|
|
80
|
+
dictAssets,
|
|
78
81
|
pagesBundlePath:
|
|
79
82
|
this.#command === "build"
|
|
80
83
|
? path.relative(this.#absArtifactDir, pagesBundle.bundlePath)
|
|
@@ -108,10 +108,10 @@ class BuildBatch {
|
|
|
108
108
|
async #buildShell(): Promise<void> {
|
|
109
109
|
const started = Date.now();
|
|
110
110
|
try {
|
|
111
|
-
await new CsrArtifactBuilder(this.#app, "start", "en", { cssAssets: this.#request.cssAssets }).
|
|
112
|
-
this.#logger.verbose(`
|
|
111
|
+
await new CsrArtifactBuilder(this.#app, "start", "en", { cssAssets: this.#request.cssAssets }).buildMcpAppViews();
|
|
112
|
+
this.#logger.verbose(`mcp-app-views ok (${Date.now() - started}ms)`);
|
|
113
113
|
} catch (err) {
|
|
114
|
-
this.#fail("shell", "
|
|
114
|
+
this.#fail("shell", "mcp-app-views", err);
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
|
|
@@ -3,8 +3,8 @@ import type { BaseBuildArtifact, CssPayload, HmrTrace, PagesBundlePayload } from
|
|
|
3
3
|
|
|
4
4
|
export type OptimizedFonts = Awaited<ReturnType<FontOptimizer["optimize"]>>;
|
|
5
5
|
|
|
6
|
-
// `base` is the boot build: it always runs alone, before the builder can serve anything. `shell` is the
|
|
7
|
-
//
|
|
6
|
+
// `base` is the boot build: it always runs alone, before the builder can serve anything. `shell` is the MCP App views'
|
|
7
|
+
// single-file build under the dev CSR registry, made on request and reported to no tab.
|
|
8
8
|
export type BuildBatchNeed = "base" | "pages" | "css" | "csr" | "ssr" | "shell";
|
|
9
9
|
|
|
10
10
|
// JSON only: the worker is spawned per batch and receives this in its argv.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const A = () => {
|
|
2
|
+
const busy = st.use.xUploading() || !!st.use.xFormUploading().voice; // @flag
|
|
3
|
+
const shown = open && st.sel((s) => s.a); // @flag
|
|
4
|
+
const label = name ?? st.use.defaultName(); // @flag
|
|
5
|
+
const picked = open ? st.use.b() : null; // @flag
|
|
6
|
+
const latest = open ? null : st.ref((s) => s.c); // @flag
|
|
7
|
+
if (open) { const d = st.use.d(); } // @flag
|
|
8
|
+
if (open) { run(); } else { const e = st.slice.inOrg.use.e(); } // @flag
|
|
9
|
+
switch (mode) { case "a": st.use.f(); } // @flag
|
|
10
|
+
for (const item of items) st.use.g(); // @flag
|
|
11
|
+
while (more) st.use.h(); // @flag
|
|
12
|
+
try { run(); } catch { st.use.i(); } // @flag
|
|
13
|
+
const rows = items.map((item) => st.use.j()); // @flag
|
|
14
|
+
useEffect(() => { st.use.k(); }, []); // @flag
|
|
15
|
+
const tool = open && st.tool("x").desc("d").exec(() => run()); // @flag
|
|
16
|
+
const card = open ? st.tool("y").desc("d").card(() => null) : null; // @flag
|
|
17
|
+
const [l] = open ? st.useState("l", String).desc("d").init("") : []; // @flag
|
|
18
|
+
if (open) st.expose("m", String).desc("d").value("x"); // @flag
|
|
19
|
+
const save = st.tool("z").desc("d").exec(async () => { st.use.n(); }); // @flag
|
|
20
|
+
const { title = st.use.title() } = props; // @flag
|
|
21
|
+
let cached = initial; cached ||= st.use.s(); // @flag
|
|
22
|
+
cached ??= st.use.t(); // @flag
|
|
23
|
+
cached &&= st.use.u(); // @flag
|
|
24
|
+
for (let i = 0; i < st.use.count(); i++) run(i); // @flag
|
|
25
|
+
const formatted = props.format?.(st.use.v()); // @flag
|
|
26
|
+
const chained = props.api?.format(st.use.w()); // @flag
|
|
27
|
+
const computed = open ? st["use"].x() : null; // @flag
|
|
28
|
+
const computedKey = open ? st.use["y"]() : null; // @flag
|
|
29
|
+
const computedSlice = open ? st.slice["inOrg"].use.z() : null; // @flag
|
|
30
|
+
useEffect(() => { void (async () => { await load(); st.use.aa(); })(); }, []); // @flag
|
|
31
|
+
return <div onClick={() => st.use.o()} />; // @flag
|
|
32
|
+
};
|
|
33
|
+
export const B = () => <Load.Units renderList={(list) => <X a={st.use.p()} />} />; // @flag
|
|
34
|
+
export const C = () => <Load.Stream of={q}>{(v) => <X a={st.sel((s) => s.q)} />}</Load.Stream>; // @flag
|
|
35
|
+
export const useD = () => useMemo(() => st.use.r(), []); // @flag
|
|
36
|
+
export const E = ({ name = st.use.defaultName() }) => <p>{name}</p>; // @flag
|
|
37
|
+
export function F({ name = st.sel((s) => s.name) }) { return <p>{name}</p>; } // @flag
|
|
38
|
+
export const G = () => {
|
|
39
|
+
const renderRow = (item) => <X a={st.use.bb()} />; // @flag
|
|
40
|
+
return <Load.Units renderItem={renderRow} />;
|
|
41
|
+
};
|
|
42
|
+
export const H = () => {
|
|
43
|
+
function row(item) { return <X a={st.ref((s) => s.cc)} />; } // @flag
|
|
44
|
+
return <ul>{items.map(row)}</ul>;
|
|
45
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const A = () => {
|
|
2
|
+
const uploading = st.use.xUploading(); // @ok
|
|
3
|
+
const formUploading = st.use.xFormUploading(); // @ok
|
|
4
|
+
const busy = uploading || !!formUploading.voice; // @ok
|
|
5
|
+
const first = st.use.a() || fallback; // @ok
|
|
6
|
+
const picked = st.use.b() ? one : two; // @ok
|
|
7
|
+
if (st.use.c()) run(); // @ok
|
|
8
|
+
switch (st.use.mode()) { case "a": run(); } // @ok
|
|
9
|
+
for (const item of st.use.items()) run(item); // @ok
|
|
10
|
+
const selected = st.sel((s) => s.d); // @ok
|
|
11
|
+
const latest = st.ref((s) => s.e); // @ok
|
|
12
|
+
const save = st.tool("save").desc("d").exec(async () => { await st.do.saveX(); }); // @ok
|
|
13
|
+
const [f, setF] = st.useState("f", String).desc("d").init(""); // @ok
|
|
14
|
+
st.expose("g", String).desc("d").value(f); // @ok
|
|
15
|
+
useEffect(() => st.sub((s) => s.h, run), []); // @ok
|
|
16
|
+
return <div onClick={() => st.do.setNameOnX(st.get().name)}>{open ? <Child /> : null}</div>; // @ok
|
|
17
|
+
};
|
|
18
|
+
export const B = memo(() => { const a = st.use.a(); return null; }); // @ok
|
|
19
|
+
export const C = forwardRef((props, ref) => { const b = st.sel((s) => s.b); return null; }); // @ok
|
|
20
|
+
export const useTools = () => { const tool = (name) => st.tool(name).desc("d").exec(() => run()); tool("a"); }; // @ok
|
|
21
|
+
export const D = () => <Load.Units renderItem={(item) => <Row key={item.id} id={item.id} />} />; // @ok
|
|
22
|
+
export const E = () => { const busy = open && other.use.x(); return busy; }; // @ok
|
|
23
|
+
export const F = () => { const Submit = useMemo(() => () => { const v = st.use.x(); return v; }, []); return <Submit />; }; // @ok
|
|
24
|
+
export const G = () => { const Submit = useMemo(() => (open ? () => null : () => { const save = st.tool("s").desc("d").exec(run); return <Button onClick={save} />; }), [open]); return <Submit />; }; // @ok
|
|
25
|
+
export const H = () => { for (let i = st.use.start(); i < 3; i++) run(i); return null; }; // @ok
|
|
26
|
+
export const I = isNative ? () => { const a = st.use.a(); return a; } : () => null; // @ok
|
|
27
|
+
export const J = () => { const a = (() => st.use.x())(); return a; }; // @ok
|
|
28
|
+
export const K = () => { const Row = () => { const a = st.use.a(); return a; }; return <List row={Row} />; }; // @ok
|
|
29
|
+
const renderPage = () => { const a = st.use.a(); return a; }; // @ok
|
|
30
|
+
export const L = page().render(renderPage); // @ok
|
|
31
|
+
export const M = () => { const v = st.use.x(); props.onPick?.(v); return v; }; // @ok
|
|
32
|
+
export const N = () => { const a = st["use"].a(); const b = st.use["b"](); return a ?? b; }; // @ok
|
|
33
|
+
export const O = () => { let v = cached; v = st.use.a(); return v; }; // @ok
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// `st.use.<key>()`, `st.slice.<name>.use.<key>()`, `st.sel()`, `st.ref()` and the builders' last step
|
|
5
|
+
// (`st.tool(…)….exec()` / `.card()`, `st.useState(…)….init()`, `st.expose(…)….value()`) are React hooks that Biome's
|
|
6
|
+
// `useHookAtTopLevel` cannot see: it recognises a hook by a `use` prefix on the called name. A hook a render skips
|
|
7
|
+
// shifts every later hook by one slot, and production React does not check the order — a later `useRef` then
|
|
8
|
+
// receives a frozen store value and the screen dies far from the cause ("Cannot add property current, object is not
|
|
9
|
+
// extensible").
|
|
10
|
+
//
|
|
11
|
+
// Reported: the right operand of `||` / `&&` / `??` and of `||=` / `&&=` / `??=`, a ternary branch, an `if` / `else`
|
|
12
|
+
// body, a `switch` case, a loop (a `for` initializer runs once and is not one), a `catch` block, a default value,
|
|
13
|
+
// the arguments of an optional call, and a callback handed to a call or a JSX prop inside a component, inline or by
|
|
14
|
+
// a camelCase name (`.map`, `useEffect`, an event handler, a render prop — `Load.Units` calls `renderList` only while
|
|
15
|
+
// the list is non-empty). A test position is not reported: `if (st.use.x())` always runs. Each position counts up to
|
|
16
|
+
// the nearest function only, so a component defined in `useMemo` or picked by a module-level ternary is its own
|
|
17
|
+
// render; a PascalCase name is a component, which React renders rather than calls.
|
|
18
|
+
//
|
|
19
|
+
// Not reported: a hook after an early `return` (GritQL sees no statement order), a local helper the component
|
|
20
|
+
// calls (`const tool = (name) => st.tool(name)…exec(…)` runs a fixed number of times when called at top level),
|
|
21
|
+
// and a hook reached through anything but the `st` identifier.
|
|
22
|
+
//
|
|
23
|
+
pattern st_hook_call() {
|
|
24
|
+
or {
|
|
25
|
+
`st.use.$key($args)`,
|
|
26
|
+
`st.slice.$slice.use.$key($args)`,
|
|
27
|
+
`st.sel($args)`,
|
|
28
|
+
`st.ref($args)`,
|
|
29
|
+
`$chain.exec($args)` where { $chain <: r"st\s*\.\s*tool\s*\([\s\S]*" },
|
|
30
|
+
`$chain.card($args)` where { $chain <: r"st\s*\.\s*tool\s*\([\s\S]*" },
|
|
31
|
+
`$chain.init($args)` where { $chain <: r"st\s*\.\s*useState\s*\([\s\S]*" },
|
|
32
|
+
`$chain.value($args)` where { $chain <: r"st\s*\.\s*expose\s*\([\s\S]*" },
|
|
33
|
+
// Computed access (`st["use"].x()`, `st.use[key]()`) at any step, which would take a snippet per combination.
|
|
34
|
+
JsCallExpression(callee = $callee) where {
|
|
35
|
+
$callee <: or {
|
|
36
|
+
r"st\s*(?:\.\s*use|\[\s*[\x27\x22`]use[\x27\x22`]\s*\])\s*(?:\.\s*[\w$]+|\[[^\]]*\])",
|
|
37
|
+
r"st\s*(?:\.\s*slice|\[\s*[\x27\x22`]slice[\x27\x22`]\s*\])\s*(?:\.\s*[\w$]+|\[[^\]]*\])\s*(?:\.\s*use|\[\s*[\x27\x22`]use[\x27\x22`]\s*\])\s*(?:\.\s*[\w$]+|\[[^\]]*\])",
|
|
38
|
+
r"st\s*\[\s*[\x27\x22`](?:sel|ref)[\x27\x22`]\s*\]"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
pattern function_like() {
|
|
45
|
+
or {
|
|
46
|
+
JsArrowFunctionExpression(),
|
|
47
|
+
JsFunctionExpression(),
|
|
48
|
+
JsFunctionDeclaration(),
|
|
49
|
+
JsFunctionExportDefaultDeclaration(),
|
|
50
|
+
JsMethodObjectMember(),
|
|
51
|
+
JsGetterObjectMember(),
|
|
52
|
+
JsSetterObjectMember(),
|
|
53
|
+
JsMethodClassMember(),
|
|
54
|
+
JsGetterClassMember(),
|
|
55
|
+
JsSetterClassMember(),
|
|
56
|
+
JsConstructorClassMember()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// An IIFE runs where it stands, so the position around it still decides whether its hooks run.
|
|
61
|
+
pattern function_boundary() {
|
|
62
|
+
function_like() as $boundary where {
|
|
63
|
+
not $boundary <: within JsCallExpression(callee = $iifeCallee) where {
|
|
64
|
+
$iifeCallee <: or { $boundary, JsParenthesizedExpression(expression = $boundary) }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
pattern callback_slot() {
|
|
70
|
+
or { JsCallArguments(), JsxExpressionAttributeValue(), JsxExpressionChild() }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pattern passed_by_name($name) {
|
|
74
|
+
or {
|
|
75
|
+
JsCallArguments(args = [..., $arg where { $arg <: $name }, ...]),
|
|
76
|
+
JsxExpressionAttributeValue(expression = $arg) where { $arg <: $name },
|
|
77
|
+
JsxExpressionChild(expression = $arg) where { $arg <: $name }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
JsCallExpression() as $call where {
|
|
82
|
+
$call <: st_hook_call(),
|
|
83
|
+
or {
|
|
84
|
+
$call <: within JsLogicalExpression(right = $right) where { $right <: contains $call } until function_boundary(),
|
|
85
|
+
// The operator token is not a field GritQL can read; `[^=]*` makes the first `=` in the text the operator's.
|
|
86
|
+
$call <: within JsAssignmentExpression(right = $right) as $assignment where {
|
|
87
|
+
$assignment <: r"[^=]*(?:\|\||&&|\?\?)=[\s\S]*",
|
|
88
|
+
$right <: contains $call
|
|
89
|
+
} until function_boundary(),
|
|
90
|
+
$call <: within JsConditionalExpression(consequent = $then, alternate = $else) where {
|
|
91
|
+
or { $then <: contains $call, $else <: contains $call }
|
|
92
|
+
} until function_boundary(),
|
|
93
|
+
$call <: within JsIfStatement(consequent = $then, else_clause = $else) where {
|
|
94
|
+
or { $then <: contains $call, $else <: contains $call }
|
|
95
|
+
} until function_boundary(),
|
|
96
|
+
$call <: within JsForStatement(initializer = $init) where { not $init <: contains $call } until function_boundary(),
|
|
97
|
+
$call <: within JsForOfStatement(body = $body) where { $body <: contains $call } until function_boundary(),
|
|
98
|
+
$call <: within JsForInStatement(body = $body) where { $body <: contains $call } until function_boundary(),
|
|
99
|
+
$call <: within or {
|
|
100
|
+
JsCaseClause(),
|
|
101
|
+
JsDefaultClause(),
|
|
102
|
+
JsWhileStatement(),
|
|
103
|
+
JsDoWhileStatement(),
|
|
104
|
+
JsCatchClause()
|
|
105
|
+
} until function_boundary(),
|
|
106
|
+
$call <: within or {
|
|
107
|
+
JsFormalParameter(initializer = $default),
|
|
108
|
+
JsObjectBindingPatternShorthandProperty(init = $default),
|
|
109
|
+
JsObjectBindingPatternProperty(init = $default),
|
|
110
|
+
JsArrayBindingPatternElement(init = $default),
|
|
111
|
+
JsObjectAssignmentPatternShorthandProperty(init = $default),
|
|
112
|
+
JsObjectAssignmentPatternProperty(init = $default),
|
|
113
|
+
JsArrayAssignmentPatternElement(init = $default)
|
|
114
|
+
} where { $default <: contains $call } until function_boundary(),
|
|
115
|
+
// A `?.` ahead of the callee's first `(` or `[` sits on its own chain, so a nullish link skips the arguments.
|
|
116
|
+
$call <: within JsCallExpression(callee = $callee, arguments = $arguments) as $outerCall where {
|
|
117
|
+
$arguments <: contains $call,
|
|
118
|
+
or { $outerCall <: `$optionalCallee?.($optionalArgs)`, $callee <: r"[^\[\(]*\?\.[\s\S]*" }
|
|
119
|
+
} until function_boundary(),
|
|
120
|
+
and {
|
|
121
|
+
$call <: within function_boundary() as $nearest until function_boundary(),
|
|
122
|
+
or {
|
|
123
|
+
// The callback must be handed over inside another function: a component passed to `memo()` /
|
|
124
|
+
// `forwardRef()` at module level is not one.
|
|
125
|
+
$nearest <: within callback_slot() as $slot where { $slot <: within function_boundary() } until function_boundary(),
|
|
126
|
+
and {
|
|
127
|
+
or {
|
|
128
|
+
$nearest <: JsFunctionDeclaration(id = $name),
|
|
129
|
+
$nearest <: within JsVariableDeclarator(id = $name, initializer = JsInitializerClause(expression = $value)) where {
|
|
130
|
+
$value <: $nearest
|
|
131
|
+
} until function_boundary()
|
|
132
|
+
},
|
|
133
|
+
$name <: r"[a-z_$][\w$]*",
|
|
134
|
+
$nearest <: within or { JsFunctionBody(), JsModule() } as $scope where {
|
|
135
|
+
$scope <: contains passed_by_name($name) as $handover where { $handover <: within function_boundary() }
|
|
136
|
+
} until or { JsFunctionBody(), JsModule() }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
register_diagnostic(
|
|
142
|
+
span = $call,
|
|
143
|
+
message = "This st hook does not run on every render — it sits behind a condition, in a loop, or in a nested function. React matches hooks by call order, so the hooks after it shift by one slot and a later one reads another hook's state; production React does not check, and the screen crashes somewhere else. Read each st.use.* into its own const at the top of the component, then combine the values: const uploading = st.use.xUploading(); const formUploading = st.use.xFormUploading(); const busy = uploading || !!formUploading.voice. For a list, move the hook into a child component rendered per item.",
|
|
144
|
+
severity = "error"
|
|
145
|
+
)
|
|
146
|
+
}
|