@akanjs/devkit 3.0.0-alpha.5 → 3.0.0-alpha.50
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/README.ko.md +1 -1
- package/README.md +1 -1
- package/agentsIndex.test.ts +10 -0
- package/agentsIndex.ts +47 -1
- package/aiEditor.ts +1 -1
- package/akanConfig/akanConfig.test.ts +47 -10
- package/akanConfig/akanConfig.ts +23 -16
- package/akanContext.ts +53 -11
- package/applicationBuildRunner.test.ts +1 -1
- package/applicationBuildRunner.ts +0 -1
- package/biome.base.json +321 -0
- package/biomeBase.ts +9 -0
- package/capacitorApp.test.ts +8 -0
- package/capacitorApp.ts +112 -18
- package/executors.test.ts +29 -5
- package/executors.ts +23 -5
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/frontendBuild/buildRouteClient.test.ts +28 -2
- package/frontendBuild/clientBuildTypes.ts +4 -0
- package/frontendBuild/clientEntriesBundler.ts +4 -1
- package/frontendBuild/cssCandidateCache.test.ts +38 -0
- package/frontendBuild/cssCandidateCache.ts +15 -3
- package/frontendBuild/cssCompiler.ts +122 -11
- package/frontendBuild/cssImportResolver.ts +8 -7
- package/frontendBuild/frontendBuild.test.ts +88 -1
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/routeClientBuilder.ts +12 -5
- package/frontendBuild/ssrBaseArtifactBuilder.ts +20 -4
- package/frontendBuild/styleGuard.test.ts +15 -0
- package/frontendBuild/styleGuard.ts +17 -0
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/getCredentials.ts +1 -3
- package/incrementalBuilder/devWatchBatch.test.ts +18 -20
- package/incrementalBuilder/incrementalBuilder.host.ts +1 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +2 -2
- package/integration/devStabilityHarness.ts +1 -5
- package/lint/no-daisyui-legacy-class.grit +26 -9
- package/lint/no-import-client-in-server.grit +48 -0
- package/lint/no-import-server-in-client.grit +45 -0
- package/lint/no-model-type-in-util-zone.grit +58 -0
- package/lint/no-return-in-store-action.grit +35 -0
- package/lint/no-unpublished-form-setter.grit +41 -0
- package/linter.ts +17 -12
- package/package.json +4 -4
- package/qualityScanner.test.ts +52 -0
- package/qualityScanner.ts +48 -37
- package/repoIdentity.ts +42 -0
- package/routeSourceValidator.test.ts +41 -0
- package/routeSourceValidator.ts +2 -44
- package/scanInfo.ts +21 -20
- package/transforms/externalizeFrameworkPlugin.ts +0 -1
- package/workspaceLayout.test.ts +56 -4
- package/workspaceLayout.ts +49 -4
package/executors.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { pathToFileURL } from "node:url";
|
|
|
23
23
|
import type { AkanPlugin, AkanSyncContext, PluginRuntimeContext } from "akanjs";
|
|
24
24
|
import {
|
|
25
25
|
capitalize,
|
|
26
|
+
getPageSourceFileViolation,
|
|
26
27
|
isRouteSourceFile,
|
|
27
28
|
Logger,
|
|
28
29
|
parseRouteModuleKey,
|
|
@@ -42,6 +43,7 @@ import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from
|
|
|
42
43
|
import { FileSys } from "./fileSys";
|
|
43
44
|
import { getDirname } from "./getDirname";
|
|
44
45
|
import { Linter } from "./linter";
|
|
46
|
+
import { resolveRepoName } from "./repoIdentity";
|
|
45
47
|
import { AppInfo, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
|
|
46
48
|
import { Spinner } from "./spinner";
|
|
47
49
|
// Type-only: the implementation is loaded on demand in `getTypeChecker` to keep `typescript` out of
|
|
@@ -232,7 +234,7 @@ export class Executor {
|
|
|
232
234
|
);
|
|
233
235
|
});
|
|
234
236
|
proc.on("exit", (code, signal) => {
|
|
235
|
-
if (
|
|
237
|
+
if (code || signal)
|
|
236
238
|
reject(
|
|
237
239
|
new CommandExecutionError({
|
|
238
240
|
command,
|
|
@@ -341,7 +343,7 @@ export class Executor {
|
|
|
341
343
|
);
|
|
342
344
|
});
|
|
343
345
|
proc.on("exit", (code, signal) => {
|
|
344
|
-
if (
|
|
346
|
+
if (code || signal)
|
|
345
347
|
reject(
|
|
346
348
|
new CommandExecutionError({
|
|
347
349
|
command: modulePath,
|
|
@@ -788,7 +790,7 @@ export class WorkspaceExecutor extends Executor {
|
|
|
788
790
|
static #execs = new Map<string, WorkspaceExecutor>();
|
|
789
791
|
static fromRoot({
|
|
790
792
|
workspaceRoot = process.cwd(),
|
|
791
|
-
repoName =
|
|
793
|
+
repoName = resolveRepoName(workspaceRoot),
|
|
792
794
|
}: {
|
|
793
795
|
workspaceRoot?: string;
|
|
794
796
|
repoName?: string;
|
|
@@ -1005,6 +1007,16 @@ export class SysExecutor extends Executor {
|
|
|
1005
1007
|
const path = this.type === "app" ? `apps/${this.name}/lib` : `libs/${this.name}/lib`;
|
|
1006
1008
|
return await this.workspace.getDirInModule(path, this.name);
|
|
1007
1009
|
}
|
|
1010
|
+
async getPageConventionViolations(): Promise<{ relativePath: string; reason: string }[]> {
|
|
1011
|
+
if (!(await this.exists("page"))) return [];
|
|
1012
|
+
const violations: { relativePath: string; reason: string }[] = [];
|
|
1013
|
+
for (const entry of await this.getAllFiles("**/*", { cwd: this.getPath("page") })) {
|
|
1014
|
+
const relativePath = entry.split(path.sep).join("/");
|
|
1015
|
+
const reason = getPageSourceFileViolation(relativePath);
|
|
1016
|
+
if (reason) violations.push({ relativePath: `page/${relativePath}`, reason });
|
|
1017
|
+
}
|
|
1018
|
+
return violations;
|
|
1019
|
+
}
|
|
1008
1020
|
|
|
1009
1021
|
#scanInfo: AppInfo | LibInfo | null = null;
|
|
1010
1022
|
hasScanInfo() {
|
|
@@ -1363,6 +1375,12 @@ export class AppExecutor extends SysExecutor {
|
|
|
1363
1375
|
this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true }),
|
|
1364
1376
|
]);
|
|
1365
1377
|
} else await this.removeDir(".akan");
|
|
1378
|
+
//? `akan start` is the dev server, and it must say so rather than inherit an answer. Bun auto-loads the
|
|
1379
|
+
//? workspace `.env`, so NODE_ENV=production can reach this process without ever being exported in a shell —
|
|
1380
|
+
//? and a dev server that believes it is production serves from the production route cache, whose every
|
|
1381
|
+
//? route throws because a dev artifact carries no routes manifest. Written into `process.env` and not only
|
|
1382
|
+
//? into the child env because the builder runs here and bakes NODE_ENV into the bundles it emits.
|
|
1383
|
+
if (type === "start") process.env.NODE_ENV = "development";
|
|
1366
1384
|
const devPort = type === "start" ? (await this.getDevPort()).toString() : undefined;
|
|
1367
1385
|
const env = this.getCommandEnv({
|
|
1368
1386
|
AKAN_COMMAND_TYPE: type,
|
|
@@ -1700,7 +1718,7 @@ export class LibExecutor extends SysExecutor {
|
|
|
1700
1718
|
#akanConfig: AkanLibConfig | null = null;
|
|
1701
1719
|
override async getConfig({ refresh }: { refresh?: boolean } = {}) {
|
|
1702
1720
|
if (this.#akanConfig && !refresh) return this.#akanConfig;
|
|
1703
|
-
this.#akanConfig = await AkanLibConfig.from(this);
|
|
1721
|
+
this.#akanConfig = await AkanLibConfig.from(this, { bustImportCache: refresh });
|
|
1704
1722
|
return this.#akanConfig;
|
|
1705
1723
|
}
|
|
1706
1724
|
}
|
|
@@ -1806,7 +1824,7 @@ export class PkgExecutor extends Executor {
|
|
|
1806
1824
|
default: "./index.ts",
|
|
1807
1825
|
},
|
|
1808
1826
|
},
|
|
1809
|
-
engines: { bun: ">=1.
|
|
1827
|
+
engines: { bun: ">=1.4.0" },
|
|
1810
1828
|
...dependencyMaps,
|
|
1811
1829
|
};
|
|
1812
1830
|
await Promise.all([this.dist.writeJson("package.json", distPkgJson), this.writeJson("package.json", distPkgJson)]);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { FormSetterScanner } from "./formSetterScanner";
|
|
4
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
5
|
+
|
|
6
|
+
const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
7
|
+
file,
|
|
8
|
+
absolutePath: `/tmp/${file}`,
|
|
9
|
+
content,
|
|
10
|
+
sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const scan = (content: string) =>
|
|
14
|
+
new FormSetterScanner().scan([fileOf("apps/demo/lib/task/Task.Template.tsx", content)]);
|
|
15
|
+
|
|
16
|
+
describe("FormSetterScanner", () => {
|
|
17
|
+
test("a setter passed by reference publishes, so nothing is reported", () => {
|
|
18
|
+
expect(
|
|
19
|
+
scan(`export const General = () => (
|
|
20
|
+
<Field.Text value={taskForm.title} onChange={st.do.setTitleOnTask} />
|
|
21
|
+
);
|
|
22
|
+
`),
|
|
23
|
+
).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("every wrapper shape is counted once per field, whatever the wrapper was for", () => {
|
|
27
|
+
const warnings = scan(`export const General = () => (
|
|
28
|
+
<>
|
|
29
|
+
<Field.Text onChange={st.do.setTitleOnTask} />
|
|
30
|
+
<Field.ToggleSelect onChange={(type) => { st.do.setTypeOnTask(type); }} />
|
|
31
|
+
<Field.Phone onChange={(phone) => st.do.setPhoneOnTask(formatPhone(phone))} />
|
|
32
|
+
<Field.Parent
|
|
33
|
+
onChange={(project) => {
|
|
34
|
+
st.do.setProjectOnTask(project);
|
|
35
|
+
if (project) st.do.addMembersOnTask(project.members ?? []);
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
</>
|
|
39
|
+
);
|
|
40
|
+
`);
|
|
41
|
+
|
|
42
|
+
expect(warnings).toHaveLength(1);
|
|
43
|
+
expect(warnings[0]?.rule).toBe("akan.agent.unpublished-form-setter");
|
|
44
|
+
expect(warnings[0]?.scope).toBe("agent");
|
|
45
|
+
expect(warnings[0]?.message).toContain("setPhoneOnTask, setProjectOnTask, setTypeOnTask");
|
|
46
|
+
expect(warnings[0]?.locations).toHaveLength(3);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a nested path write is unannotatable by design and is not counted", () => {
|
|
50
|
+
expect(
|
|
51
|
+
scan(`export const Rows = ({ idx }: { idx: number }) => (
|
|
52
|
+
<Field.Text onChange={(title) => st.do.writeOnTask(\`payments.\${idx}.title\`, title)} />
|
|
53
|
+
);
|
|
54
|
+
`),
|
|
55
|
+
).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a zero-parameter handler is a button setting a constant, not a form control", () => {
|
|
59
|
+
expect(
|
|
60
|
+
scan(`export const Actions = () => (
|
|
61
|
+
<Button onClick={() => st.do.setStatusOnTask("done")}>Done</Button>
|
|
62
|
+
);
|
|
63
|
+
`),
|
|
64
|
+
).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a non-setter action behind a wrapper is somebody else's rule", () => {
|
|
68
|
+
expect(
|
|
69
|
+
scan(`export const Filters = () => (
|
|
70
|
+
<Select onChange={(ids) => st.do.setQueryArgsOfTaskInSelf(ids)} />
|
|
71
|
+
);
|
|
72
|
+
`),
|
|
73
|
+
).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("only .tsx is scanned", () => {
|
|
77
|
+
const content = `export const set = (v: string) => <X onChange={(t) => st.do.setTitleOnTask(t)} />;\n`;
|
|
78
|
+
expect(new FormSetterScanner().scan([fileOf("apps/demo/lib/task/task.store.ts", content)])).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The inventory of model fields a screen writes but does not publish.
|
|
6
|
+
*
|
|
7
|
+
* A control handed `onChange={st.do.setTitleOnTask}` **by reference** names the field it writes, so it emits
|
|
8
|
+
* `data-akan-action` and `useFieldTool` publishes the field to the in-page agent. Any wrapper around that setter is
|
|
9
|
+
* an anonymous closure carrying neither, and the field goes quiet — for the agent, for an E2E selector, and for the
|
|
10
|
+
* accessibility tree.
|
|
11
|
+
*
|
|
12
|
+
* `no-unpublished-form-setter.grit` is the per-line enforcement, and it fires only on a pure forwarding wrapper,
|
|
13
|
+
* because every other shape has a legitimate reading and a lint error would be wrong. This is the other half: the
|
|
14
|
+
* per-file count of fields that ended up unreachable whatever the reason, which is the number worth watching. A
|
|
15
|
+
* warning rather than an error for the same reason — the remedy depends on why the wrapper is there.
|
|
16
|
+
*
|
|
17
|
+
* Only a handler that takes a parameter is counted. A zero-parameter handler (`onClick={() => st.do.setStatusOnUser
|
|
18
|
+
* ("active")}`) is a button setting a constant, not a form control, and its remedy is an `st.tool` beside it.
|
|
19
|
+
*/
|
|
20
|
+
export class FormSetterScanner {
|
|
21
|
+
static #fieldSetter = /^set[A-Za-z0-9_$]*On[A-Za-z0-9_$]*$/;
|
|
22
|
+
|
|
23
|
+
scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
24
|
+
return sourceFiles.filter((sourceFile) => sourceFile.file.endsWith(".tsx")).flatMap((f) => this.#scanFile(f));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#scanFile({ file, sourceFile }: SourceFileInfo): QualityWarning[] {
|
|
28
|
+
const wrapped: { setter: string; line: number }[] = [];
|
|
29
|
+
const visit = (node: ts.Node) => {
|
|
30
|
+
if (ts.isJsxAttribute(node)) {
|
|
31
|
+
const setter = FormSetterScanner.#wrappedSetterOf(node);
|
|
32
|
+
if (setter) wrapped.push({ setter, line: FormSetterScanner.#lineOf(sourceFile, node) });
|
|
33
|
+
}
|
|
34
|
+
ts.forEachChild(node, visit);
|
|
35
|
+
};
|
|
36
|
+
visit(sourceFile);
|
|
37
|
+
if (!wrapped.length) return [];
|
|
38
|
+
const setters = [...new Set(wrapped.map((entry) => entry.setter))].sort();
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
rule: "akan.agent.unpublished-form-setter",
|
|
42
|
+
scope: "agent",
|
|
43
|
+
severity: "warning",
|
|
44
|
+
message:
|
|
45
|
+
setters.length === 1
|
|
46
|
+
? `${setters[0]} is reached through a wrapper, so that field publishes no agent tool and carries no data-akan-action.`
|
|
47
|
+
: `${setters.length} field setters are reached through a wrapper, so those fields publish no agent tool and carry no data-akan-action: ${setters.join(", ")}.`,
|
|
48
|
+
file,
|
|
49
|
+
line: wrapped[0]?.line,
|
|
50
|
+
locations: wrapped.map(({ line }) => ({ file, line })),
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The setter a handler prop writes behind a wrapper, or null when it is passed by reference or absent. */
|
|
56
|
+
static #wrappedSetterOf(attribute: ts.JsxAttribute): string | null {
|
|
57
|
+
if (!ts.isIdentifier(attribute.name) || !/^on[A-Z]/.test(attribute.name.text)) return null;
|
|
58
|
+
const initializer = attribute.initializer;
|
|
59
|
+
if (!initializer || !ts.isJsxExpression(initializer) || !initializer.expression) return null;
|
|
60
|
+
const handler = initializer.expression;
|
|
61
|
+
if (!ts.isArrowFunction(handler) && !ts.isFunctionExpression(handler)) return null;
|
|
62
|
+
if (!handler.parameters.length) return null;
|
|
63
|
+
return FormSetterScanner.#setterCallIn(handler.body);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static #setterCallIn(node: ts.Node): string | null {
|
|
67
|
+
let found: string | null = null;
|
|
68
|
+
const visit = (current: ts.Node) => {
|
|
69
|
+
if (found) return;
|
|
70
|
+
if (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) {
|
|
71
|
+
const { expression, name } = current.expression;
|
|
72
|
+
if (
|
|
73
|
+
ts.isPropertyAccessExpression(expression) &&
|
|
74
|
+
ts.isIdentifier(expression.expression) &&
|
|
75
|
+
expression.expression.text === "st" &&
|
|
76
|
+
expression.name.text === "do" &&
|
|
77
|
+
FormSetterScanner.#fieldSetter.test(name.text)
|
|
78
|
+
) {
|
|
79
|
+
found = name.text;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
ts.forEachChild(current, visit);
|
|
84
|
+
};
|
|
85
|
+
visit(node);
|
|
86
|
+
return found;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
static #lineOf(sourceFile: ts.SourceFile, node: ts.Node) {
|
|
90
|
+
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -82,16 +82,42 @@ describe("route client store bootstrap", () => {
|
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
test("bundles akan fetch into production SSR client chunks", () => {
|
|
85
|
-
expect(RouteClientBuilder.
|
|
85
|
+
expect(RouteClientBuilder.resolveSsrClientBundleOptions("start")).toMatchObject({
|
|
86
86
|
external: expect.arrayContaining(["akanjs/fetch"]),
|
|
87
87
|
externalSubpaths: ["akanjs/fetch"],
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
expect(RouteClientBuilder.
|
|
90
|
+
expect(RouteClientBuilder.resolveSsrClientBundleOptions("build")).toEqual({
|
|
91
|
+
target: "bun",
|
|
91
92
|
external: ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
92
93
|
});
|
|
93
94
|
});
|
|
94
95
|
|
|
96
|
+
test("targets the server so SSR client chunks never resolve a browser export condition", async () => {
|
|
97
|
+
const root = await makeTempRoot();
|
|
98
|
+
const pkgDir = path.join(root, "node_modules/dom-conditioned-pkg");
|
|
99
|
+
await write(
|
|
100
|
+
path.join(pkgDir, "package.json"),
|
|
101
|
+
JSON.stringify({
|
|
102
|
+
name: "dom-conditioned-pkg",
|
|
103
|
+
type: "module",
|
|
104
|
+
exports: { ".": { browser: "./index.dom.js", default: "./index.js" } },
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
await write(path.join(pkgDir, "index.dom.js"), 'export const element = document.createElement("i");\n');
|
|
108
|
+
await write(path.join(pkgDir, "index.js"), "export const element = null;\n");
|
|
109
|
+
const entry = path.join(root, "entry.ts");
|
|
110
|
+
await write(entry, 'export { element } from "dom-conditioned-pkg";\n');
|
|
111
|
+
|
|
112
|
+
for (const command of ["start", "build"] as const) {
|
|
113
|
+
const { target } = RouteClientBuilder.resolveSsrClientBundleOptions(command);
|
|
114
|
+
const built = await Bun.build({ entrypoints: [entry], target, format: "esm" });
|
|
115
|
+
|
|
116
|
+
expect(built.success).toBe(true);
|
|
117
|
+
expect(await built.outputs[0].text()).not.toContain("document.createElement");
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
95
121
|
test("rewrites SSR external imports to runtime aliases", () => {
|
|
96
122
|
const source = [
|
|
97
123
|
'import React, { useState } from "react";',
|
|
@@ -55,6 +55,8 @@ export interface BuildClientResult {
|
|
|
55
55
|
rscClientUrl: string;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export type ClientBundleTarget = "browser" | "bun";
|
|
59
|
+
|
|
58
60
|
export const CLIENT_BUNDLE_NAMING = {
|
|
59
61
|
entry: "[name]-[hash].[ext]",
|
|
60
62
|
chunk: "chunks/[hash].[ext]",
|
|
@@ -79,6 +81,8 @@ export interface BundleClientEntriesOptions {
|
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
export interface BundleClientEntriesInternalOptions extends BundleClientEntriesOptions {
|
|
84
|
+
/** Module-resolution target. `"bun"` for the server-executed `client-ssr` bundle, `"browser"` otherwise. */
|
|
85
|
+
target?: ClientBundleTarget;
|
|
82
86
|
external?: readonly string[];
|
|
83
87
|
externalSubpaths?: readonly string[];
|
|
84
88
|
externalAliases?: Partial<Record<string, string>>;
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type BundleClientEntriesInternalOptions,
|
|
8
8
|
type BundleClientEntriesResult,
|
|
9
9
|
CLIENT_BUNDLE_NAMING,
|
|
10
|
+
type ClientBundleTarget,
|
|
10
11
|
type ClientManifest,
|
|
11
12
|
type MetafileOutput,
|
|
12
13
|
type OpaqueEntryAliases,
|
|
@@ -25,6 +26,7 @@ export class ClientEntriesBundler {
|
|
|
25
26
|
#externalSubpaths: readonly string[];
|
|
26
27
|
#externalAliases: Partial<Record<string, string>>;
|
|
27
28
|
#command: "build" | "start";
|
|
29
|
+
#target: ClientBundleTarget;
|
|
28
30
|
#outputSubdir: string;
|
|
29
31
|
#reactFastRefresh: boolean;
|
|
30
32
|
#artifactDir: string;
|
|
@@ -47,6 +49,7 @@ export class ClientEntriesBundler {
|
|
|
47
49
|
this.#externalSubpaths = options.externalSubpaths ?? [];
|
|
48
50
|
this.#externalAliases = options.externalAliases ?? {};
|
|
49
51
|
this.#command = options.command ?? "start";
|
|
52
|
+
this.#target = options.target ?? "browser";
|
|
50
53
|
this.#outputSubdir = options.outputSubdir ?? "client";
|
|
51
54
|
this.#reactFastRefresh = options.reactFastRefresh ?? false;
|
|
52
55
|
this.#artifactDir = `${this.#command === "build" ? this.#app.dist.cwdPath : this.#app.cwdPath}/.akan/artifact`;
|
|
@@ -61,7 +64,7 @@ export class ClientEntriesBundler {
|
|
|
61
64
|
entrypoints: this.#opaqueEntries.entries,
|
|
62
65
|
outdir: this.#outdir,
|
|
63
66
|
splitting: true,
|
|
64
|
-
target:
|
|
67
|
+
target: this.#target,
|
|
65
68
|
format: "esm",
|
|
66
69
|
naming: CLIENT_BUNDLE_NAMING,
|
|
67
70
|
metafile: true,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { CssCandidateCache } from "./cssCandidateCache";
|
|
6
|
+
|
|
7
|
+
const scan = async (source: string) => {
|
|
8
|
+
const dir = await mkdtemp(path.join(tmpdir(), "css-candidate-"));
|
|
9
|
+
const file = path.join(dir, "probe.tsx");
|
|
10
|
+
await writeFile(file, source);
|
|
11
|
+
return await new CssCandidateCache(path.join(dir, "cache.json")).load().then((c) => c.candidatesFor(file));
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("CssCandidateCache token extraction", () => {
|
|
15
|
+
test("keeps plain utilities, variants and arbitrary values whole", async () => {
|
|
16
|
+
const candidates = await scan(`const c = "w-full hover:bg-muted md:w-1/2 text-[#fff]";`);
|
|
17
|
+
expect(candidates).toEqual(expect.arrayContaining(["w-full", "hover:bg-muted", "md:w-1/2", "text-[#fff]"]));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// A candidate that opens with `[` used to be torn into `_td` + `px-3`, so every descendant-variant
|
|
21
|
+
// rule in the framework compiled to nothing and its element rendered unstyled with no diagnostic.
|
|
22
|
+
test("keeps an arbitrary variant that starts with a bracket", async () => {
|
|
23
|
+
const candidates = await scan(`const c = "[&_td]:px-3 [&>*]:gap-2 [&_tbody_tr]:border-t";`);
|
|
24
|
+
expect(candidates).toEqual(expect.arrayContaining(["[&_td]:px-3", "[&>*]:gap-2", "[&_tbody_tr]:border-t"]));
|
|
25
|
+
expect(candidates).not.toContain("_td");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// A labelled tuple type looks like an arbitrary property, and compiles to a rule whose declaration is
|
|
29
|
+
// TypeScript. `optimize()` cannot parse that, and drops far more than the offending rule on its way
|
|
30
|
+
// out — every variant in the build went missing, so nothing hovered, focused or responded to a
|
|
31
|
+
// breakpoint. Tailwind writes a space in an arbitrary value as `_`, so whitespace rules the token out.
|
|
32
|
+
test("ignores a labelled tuple type that only looks like an arbitrary property", async () => {
|
|
33
|
+
const candidates = await scan(
|
|
34
|
+
`type A = [key: string, make: () => unknown][];\ntype B = [setQueryArgs: (a: Args) => Args, options?: Policy];`,
|
|
35
|
+
);
|
|
36
|
+
expect(candidates.filter((candidate) => candidate.startsWith("["))).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { stat } from "node:fs/promises";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Every identifier-ish token in a source file is a potential tailwind class. The leading alternation
|
|
5
|
+
* matters: an arbitrary variant starts with `[` (`[&_td]:px-3`, `[&>*]:gap-2`), and a pattern anchored
|
|
6
|
+
* on a word character tears it into `_td` plus `px-3` instead — both meaningless, so the rule compiles
|
|
7
|
+
* to nothing and the element silently renders unstyled.
|
|
8
|
+
*
|
|
9
|
+
* Brackets must hold no whitespace. Tailwind spells a space in an arbitrary value `_`, so a bracket
|
|
10
|
+
* group containing one is never a class — but it is exactly the shape of a labelled tuple type
|
|
11
|
+
* (`[key: string, make: () => unknown]`). Scanned as a candidate, that compiles to an arbitrary
|
|
12
|
+
* *property* rule carrying TypeScript as its declaration, which the lightningcss pass in `optimize()`
|
|
13
|
+
* cannot parse; its error recovery then drops a whole span of the stylesheet — in practice every
|
|
14
|
+
* variant rule in the build, leaving hover, focus, responsive and dark styles silently absent.
|
|
15
|
+
*/
|
|
16
|
+
const CANDIDATE_RE = /-?(?:[\w@]|\[[^\]\s]+\])[\w:/.-]*(?:\[[^\]\s]+\][\w:/.-]*)*/g;
|
|
5
17
|
|
|
6
18
|
interface CachedFile {
|
|
7
19
|
mtimeMs: number;
|
|
@@ -27,7 +39,7 @@ interface CacheFile {
|
|
|
27
39
|
*/
|
|
28
40
|
export class CssCandidateCache {
|
|
29
41
|
/** Bump when the token regex or the entry shape changes, so stale extractions are not reused. */
|
|
30
|
-
static readonly #version =
|
|
42
|
+
static readonly #version = 3;
|
|
31
43
|
readonly #path: string;
|
|
32
44
|
readonly #entries = new Map<string, CachedFile>();
|
|
33
45
|
#dirty = false;
|
|
@@ -17,6 +17,12 @@ interface CssDiscovery {
|
|
|
17
17
|
sourcePaths: string[];
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** One `@import` target and the custom properties it declares, which is what proves it arrived downstream. */
|
|
21
|
+
export interface ImportedStylesheet {
|
|
22
|
+
cssPath: string;
|
|
23
|
+
declaredNames: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
20
26
|
export class CssCompiler {
|
|
21
27
|
#logger = new Logger("CssCompiler");
|
|
22
28
|
#transpiler = new Bun.Transpiler({ loader: "tsx" });
|
|
@@ -46,6 +52,13 @@ export class CssCompiler {
|
|
|
46
52
|
#fileExistsCache = new Map<string, Promise<boolean>>();
|
|
47
53
|
#resolvedFileCache = new Map<string, Promise<string | null>>();
|
|
48
54
|
#resolvedSpecifierCache = new Map<string, Promise<string | null>>();
|
|
55
|
+
/** Every stylesheet this compile reached, entry points and `@import` targets alike. */
|
|
56
|
+
#discoveredCssPaths = new Set<string>();
|
|
57
|
+
/**
|
|
58
|
+
* `@import` targets per base path, so whoever writes the asset can check the file it actually wrote — the
|
|
59
|
+
* compiled text and the written artifact are two different places a declaration can go missing.
|
|
60
|
+
*/
|
|
61
|
+
importedStylesheetsByBasePath: Record<string, ImportedStylesheet[]> = {};
|
|
49
62
|
|
|
50
63
|
#fileExists(absPath: string): Promise<boolean> {
|
|
51
64
|
let cached = this.#fileExistsCache.get(absPath);
|
|
@@ -76,13 +89,20 @@ export class CssCompiler {
|
|
|
76
89
|
}
|
|
77
90
|
async getCss({ refresh }: { refresh?: boolean } = {}) {
|
|
78
91
|
if (this.#cssText !== null && !refresh) return this.#cssText;
|
|
92
|
+
this.#discoveredCssPaths.clear();
|
|
93
|
+
this.importedStylesheetsByBasePath = {};
|
|
79
94
|
const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh });
|
|
80
|
-
|
|
95
|
+
const { css, imported } = await this.#compileWithImports(cssPaths, sourcePaths);
|
|
96
|
+
this.#cssText = css;
|
|
97
|
+
this.importedStylesheetsByBasePath = { "": imported };
|
|
98
|
+
await this.#warnUnreachableStylesheets();
|
|
81
99
|
return this.#cssText;
|
|
82
100
|
}
|
|
83
101
|
|
|
84
102
|
async getCssByBasePath({ refresh }: { refresh?: boolean } = {}): Promise<Record<string, string>> {
|
|
85
103
|
if (this.#cssTextByBasePath !== null && !refresh) return this.#cssTextByBasePath;
|
|
104
|
+
this.#discoveredCssPaths.clear();
|
|
105
|
+
this.importedStylesheetsByBasePath = {};
|
|
86
106
|
const akanConfig = await this.#app.getConfig({ refresh });
|
|
87
107
|
const pageKeys = await this.#app.getPageKeys({ refresh });
|
|
88
108
|
const basePaths = [...akanConfig.basePaths];
|
|
@@ -92,7 +112,8 @@ export class CssCompiler {
|
|
|
92
112
|
if (rootPageKeys.length === 0) return ["", ""] as const;
|
|
93
113
|
const started = Date.now();
|
|
94
114
|
const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh, pageKeys: rootPageKeys });
|
|
95
|
-
const css = await this
|
|
115
|
+
const { css, imported } = await this.#compileWithImports(cssPaths, sourcePaths);
|
|
116
|
+
this.importedStylesheetsByBasePath[""] = imported;
|
|
96
117
|
this.#logger.verbose(
|
|
97
118
|
`css base=root paths=${cssPaths.length} sources=${sourcePaths.length} in ${Date.now() - started}ms`,
|
|
98
119
|
);
|
|
@@ -103,7 +124,8 @@ export class CssCompiler {
|
|
|
103
124
|
if (basePathPageKeys.length === 0) return [basePath, ""] as const;
|
|
104
125
|
const started = Date.now();
|
|
105
126
|
const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh, pageKeys: basePathPageKeys });
|
|
106
|
-
const css = await this
|
|
127
|
+
const { css, imported } = await this.#compileWithImports(cssPaths, sourcePaths);
|
|
128
|
+
this.importedStylesheetsByBasePath[basePath] = imported;
|
|
107
129
|
this.#logger.verbose(
|
|
108
130
|
`css base=${basePath} paths=${cssPaths.length} sources=${sourcePaths.length} in ${Date.now() - started}ms`,
|
|
109
131
|
);
|
|
@@ -111,9 +133,26 @@ export class CssCompiler {
|
|
|
111
133
|
}),
|
|
112
134
|
]);
|
|
113
135
|
this.#cssTextByBasePath = Object.fromEntries(cssEntries);
|
|
136
|
+
await this.#warnUnreachableStylesheets();
|
|
114
137
|
return this.#cssTextByBasePath;
|
|
115
138
|
}
|
|
116
139
|
|
|
140
|
+
/**
|
|
141
|
+
* A stylesheet under `page/` reaches the build only by being imported from a route source. One that nothing
|
|
142
|
+
* imports compiles to nothing and reports success, which is indistinguishable from an empty theme — so say it
|
|
143
|
+
* out loud once per compile rather than leaving it to be noticed as unstyled elements in the browser.
|
|
144
|
+
*/
|
|
145
|
+
async #warnUnreachableStylesheets() {
|
|
146
|
+
const pageDir = path.join(this.#app.cwdPath, "page");
|
|
147
|
+
const glob = new Bun.Glob("**/*.css");
|
|
148
|
+
for await (const cssPath of glob.scan({ cwd: pageDir, absolute: true })) {
|
|
149
|
+
// `(libs)` is a link farm: the same file is discovered under its real path in `libs/`, never this one.
|
|
150
|
+
if (cssPath.includes(`${path.sep}(libs)${path.sep}`)) continue;
|
|
151
|
+
if (this.#discoveredCssPaths.has(cssPath)) continue;
|
|
152
|
+
this.#logger.warn(`css ${path.relative(this.#app.cwdPath, cssPath)} is imported by no route and never compiled`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
117
156
|
async discoverCss({ refresh }: { refresh?: boolean } = {}): Promise<string[]> {
|
|
118
157
|
const { cssPaths } = await this.discoverCssAndSources({ refresh });
|
|
119
158
|
return cssPaths;
|
|
@@ -180,22 +219,61 @@ export class CssCompiler {
|
|
|
180
219
|
}
|
|
181
220
|
}
|
|
182
221
|
|
|
183
|
-
|
|
222
|
+
const tokenPaths = await this.#libTokenStylesheets(sourceFiles);
|
|
223
|
+
const cssPaths = [...new Set([...tokenPaths, ...cssFiles])];
|
|
224
|
+
for (const cssPath of cssPaths) this.#discoveredCssPaths.add(cssPath);
|
|
225
|
+
return { cssPaths, sourcePaths: [...sourceFiles] };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* `libs/<lib>/ui/tokens.css` of every lib the page graph reached, so a lib can own the fixed colours its own
|
|
230
|
+
* components need instead of each consuming app re-declaring them. Ordered ahead of the app's stylesheets:
|
|
231
|
+
* the app is the last word on any variable both declare.
|
|
232
|
+
*/
|
|
233
|
+
async #libTokenStylesheets(sourceFiles: Set<string>): Promise<string[]> {
|
|
234
|
+
const libsRoot = path.join(this.#app.workspace.workspaceRoot, "libs");
|
|
235
|
+
const libNames = new Set<string>();
|
|
236
|
+
for (const filePath of sourceFiles) {
|
|
237
|
+
const relPath = path.relative(libsRoot, filePath);
|
|
238
|
+
if (relPath.startsWith("..") || path.isAbsolute(relPath)) continue;
|
|
239
|
+
const [libName] = relPath.split(path.sep);
|
|
240
|
+
if (libName) libNames.add(libName);
|
|
241
|
+
}
|
|
242
|
+
const tokenPaths = await Promise.all(
|
|
243
|
+
[...libNames].sort().map(async (libName) => {
|
|
244
|
+
const tokensPath = path.join(libsRoot, libName, "ui/tokens.css");
|
|
245
|
+
return (await this.#fileExists(tokensPath)) ? tokensPath : null;
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
return tokenPaths.filter((tokensPath): tokensPath is string => !!tokensPath);
|
|
184
249
|
}
|
|
185
250
|
async compileCss(cssPaths: string[], sourcePaths: string[]): Promise<string> {
|
|
186
|
-
|
|
251
|
+
const { css } = await this.#compileWithImports(cssPaths, sourcePaths);
|
|
252
|
+
return css;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The collector is per compile rather than per compiler instance: `getCssByBasePath` compiles every base path
|
|
257
|
+
* concurrently, so instance state would mix one base path's imports into another's check.
|
|
258
|
+
*/
|
|
259
|
+
async #compileWithImports(
|
|
260
|
+
cssPaths: string[],
|
|
261
|
+
sourcePaths: string[],
|
|
262
|
+
): Promise<{ css: string; imported: ImportedStylesheet[] }> {
|
|
263
|
+
if (cssPaths.length === 0) return { css: "", imported: [] };
|
|
187
264
|
|
|
188
265
|
const compileStarted = Date.now();
|
|
189
266
|
const compilers = await Promise.all(
|
|
190
267
|
cssPaths.map(async (cssPath) => {
|
|
191
268
|
const css = await Bun.file(cssPath).text();
|
|
192
269
|
const base = path.dirname(cssPath);
|
|
270
|
+
const imported = new Map<string, string>();
|
|
193
271
|
const compiler = await compile(css, {
|
|
194
272
|
base,
|
|
195
|
-
loadStylesheet: (id, fromBase) => this.#loadStylesheet(id, fromBase),
|
|
273
|
+
loadStylesheet: (id, fromBase) => this.#loadStylesheet(id, fromBase, imported),
|
|
196
274
|
loadModule: (id, fromBase) => this.#loadModule(id, fromBase),
|
|
197
275
|
});
|
|
198
|
-
return { cssPath, compiler };
|
|
276
|
+
return { cssPath, compiler, imported };
|
|
199
277
|
}),
|
|
200
278
|
);
|
|
201
279
|
|
|
@@ -210,24 +288,46 @@ export class CssCompiler {
|
|
|
210
288
|
`css candidates scanned count=${candidates.length} sources=${sourcePaths.length} dirs=${sourceDirs.size} in ${Date.now() - scanStarted}ms`,
|
|
211
289
|
);
|
|
212
290
|
const parts: string[] = [];
|
|
291
|
+
const imported: ImportedStylesheet[] = [];
|
|
213
292
|
for (const entry of compilers) {
|
|
214
293
|
if (!entry) continue;
|
|
215
|
-
|
|
294
|
+
const part = entry.compiler.build(candidates);
|
|
295
|
+
parts.push(part);
|
|
296
|
+
for (const [cssPath, content] of entry.imported) {
|
|
297
|
+
const declaredNames = declaredCustomProperties(content);
|
|
298
|
+
imported.push({ cssPath, declaredNames });
|
|
299
|
+
if (declaredNames.length === 0 || declaredNames.some((name) => part.includes(`${name}:`))) continue;
|
|
300
|
+
this.#logger.warn(
|
|
301
|
+
`css @import ${cssPath} was loaded by ${entry.cssPath} but none of its ${declaredNames.length} declaration(s) reached the compiled CSS`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
216
304
|
}
|
|
217
305
|
this.#logger.verbose(
|
|
218
306
|
`css compiled paths=${cssPaths.length} candidates=${candidates.length} in ${Date.now() - compileStarted}ms`,
|
|
219
307
|
);
|
|
220
|
-
return parts.join("\n");
|
|
308
|
+
return { css: parts.join("\n"), imported };
|
|
221
309
|
}
|
|
222
310
|
|
|
223
|
-
async #loadStylesheet(id: string, fromBase: string) {
|
|
311
|
+
async #loadStylesheet(id: string, fromBase: string, imported?: Map<string, string>) {
|
|
224
312
|
const p = await this.#resolveCssImport(id, fromBase);
|
|
313
|
+
this.#discoveredCssPaths.add(p);
|
|
225
314
|
const content = await Bun.file(p).text();
|
|
315
|
+
imported?.set(p, content);
|
|
316
|
+
this.#logger.verbose(`css import "${id}" from ${fromBase} -> ${p} (${content.length} bytes)`);
|
|
226
317
|
return { path: p, base: path.dirname(p), content };
|
|
227
318
|
}
|
|
228
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Every specifier is verified here, path-shaped ones included. An `@import` the pipeline cannot resolve is
|
|
322
|
+
* a build error and never a no-op: the vocabulary closure means a component whose token declaration failed
|
|
323
|
+
* to load renders unstyled, which nothing downstream can distinguish from a design choice.
|
|
324
|
+
*/
|
|
229
325
|
async #resolveCssImport(id: string, fromBase: string): Promise<string> {
|
|
230
|
-
if (id.startsWith(".") || id.startsWith("/"))
|
|
326
|
+
if (id.startsWith(".") || id.startsWith("/")) {
|
|
327
|
+
const filePath = path.resolve(fromBase, id);
|
|
328
|
+
if (await this.#fileExists(filePath)) return filePath;
|
|
329
|
+
throw new Error(`[css] failed to resolve stylesheet import "${id}" from ${fromBase} (no file at ${filePath})`);
|
|
330
|
+
}
|
|
231
331
|
const resolver = await this.#getCssImportResolver();
|
|
232
332
|
const resolved = await resolver.resolve(id, fromBase);
|
|
233
333
|
if (resolved) return resolved;
|
|
@@ -330,6 +430,17 @@ export function isIgnoredNodeModuleSource(filePath: string): boolean {
|
|
|
330
430
|
return NODE_MODULES_RE.test(filePath) && !AKANJS_NODE_MODULE_RE.test(filePath);
|
|
331
431
|
}
|
|
332
432
|
|
|
433
|
+
/**
|
|
434
|
+
* `@theme` blocks are stripped first: those variables are emitted only when a utility uses one, so their
|
|
435
|
+
* absence from a build says nothing about whether the stylesheet arrived.
|
|
436
|
+
*/
|
|
437
|
+
export function declaredCustomProperties(css: string): string[] {
|
|
438
|
+
const withoutThemeBlocks = css.replace(/@theme[^{]*\{[^}]*\}/g, "");
|
|
439
|
+
return [...new Set([...withoutThemeBlocks.matchAll(/(?:^|[\s;{])(--[\w-]+)\s*:/g)].map(([, name]) => name))].filter(
|
|
440
|
+
(name): name is string => !!name,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
333
444
|
function getPageKeyBasePath(pageKey: string, basePaths: string[]): string | null {
|
|
334
445
|
const normalized = pageKey.split(path.sep).join("/").replace(/^\.\//, "");
|
|
335
446
|
const segments = normalized.split("/");
|