@akanjs/devkit 3.0.0-alpha.70 → 3.0.0-alpha.72
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/akanApp/BackendImportGraph.test.ts +120 -0
- package/akanApp/BackendImportGraph.ts +167 -0
- package/akanApp/akanApp.host.test.ts +16 -678
- package/akanApp/akanApp.host.ts +65 -562
- package/akanApp/devHostPolicy.test.ts +542 -0
- package/akanApp/devHostPolicy.ts +412 -0
- package/akanApp/index.ts +2 -0
- package/akanConfig/akanConfig.test.ts +1 -0
- package/akanConfig/akanConfig.ts +4 -0
- package/akanContext.ts +17 -3
- package/akanMcpContract.ts +25 -4
- package/artifact/routeSeedIndex.test.ts +1 -0
- package/biome.base.json +4 -0
- package/capacitorApp.ts +10 -0
- package/cloud/globalConfig.ts +9 -2
- package/commandDecorators/command.ts +30 -6
- package/commandDecorators/commandBuilder.ts +20 -5
- package/commandDecorators/commandDecorators.test.ts +115 -3
- package/dependencyScanner.test.ts +99 -0
- package/dependencyScanner.ts +27 -24
- package/devkitUtils.test.ts +0 -104
- package/executors.test.ts +45 -0
- package/executors.ts +29 -5
- package/fileEditor.ts +19 -19
- package/frontendBuild/autoImportSync.test.ts +58 -0
- package/frontendBuild/autoImportSync.ts +4 -0
- package/frontendBuild/buildRouteClient.test.ts +19 -22
- package/frontendBuild/clientEntryDiscovery.ts +2 -2
- package/frontendBuild/fontOptimizer.ts +37 -18
- package/frontendBuild/index.ts +0 -1
- package/frontendBuild/styleContract.ts +14 -20
- package/frontendBuild/themeValidator.ts +22 -17
- package/incrementalBuilder/devWatchBatch.ts +6 -2
- package/incrementalBuilder/incrementalBuilder.proc.ts +6 -2
- package/index.ts +0 -5
- package/lint/__fixtures__/README.md +40 -0
- package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
- package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
- package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
- package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
- package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
- package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
- package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
- package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
- package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
- package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
- package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
- package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
- package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
- package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
- package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
- package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
- package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
- package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
- package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
- package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
- package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
- package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
- package/lint/gritRules.test.ts +178 -0
- package/lint/no-arbitrary-color.grit +1 -1
- package/lint/no-bang-comment-in-client.grit +23 -10
- package/lint/no-daisyui-legacy-class.grit +2 -2
- package/lint/no-deprecated-log-level.grit +17 -0
- package/lint/no-inline-color.grit +10 -8
- package/lint/no-interpolated-arbitrary-class.grit +3 -3
- package/lint/no-raw-palette-class.grit +3 -3
- package/lint/non-scalar-props-restricted.grit +16 -7
- package/linter.test.ts +80 -0
- package/linter.ts +82 -13
- package/package.json +3 -3
- package/prompter.ts +9 -4
- package/qualityScanner.test.ts +64 -0
- package/qualityScanner.ts +43 -3
- package/recipeScanner.ts +4 -1
- package/scanInfo.ts +3 -3
- package/ssrScanner.test.ts +301 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/transforms.test.ts +5 -5
- package/transforms/tsconfigPackageResolver.test.ts +230 -0
- package/tsconfig.json +5 -1
- package/typeChecker.ts +1 -1
- package/types.ts +1 -0
- package/ui/ScrollList.tsx +6 -8
- package/uploadRelease.ts +2 -2
- package/workflow/executor.test.ts +146 -0
- package/builder.ts +0 -164
- package/extractDeps.ts +0 -86
- package/frontendBuild/styleGuard.test.ts +0 -180
- package/frontendBuild/styleGuard.ts +0 -339
- package/getCredentials.ts +0 -17
- package/getModelFileData.ts +0 -62
- package/src/capacitorApp.ts +0 -282
- package/streamAi.ts +0 -45
package/executors.ts
CHANGED
|
@@ -690,7 +690,30 @@ export class Executor {
|
|
|
690
690
|
Object.entries(options.dict ?? {}).map(([key, value]) => [capitalize(key), capitalize(value)]),
|
|
691
691
|
),
|
|
692
692
|
};
|
|
693
|
-
|
|
693
|
+
const fileContents = await this._applyTemplate({ ...options, dict });
|
|
694
|
+
await this.#formatAppliedTemplate(fileContents);
|
|
695
|
+
return fileContents;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* A template emits identifiers it cannot sort. `import { fetch, Task, usePage }` is correctly ordered for
|
|
700
|
+
* a model named Task and wrong for one named Zoo, and `organizeImports` fails `biome check` — so a
|
|
701
|
+
* scaffold that is not formatted on the way out is red for most model names, whatever the template says.
|
|
702
|
+
*
|
|
703
|
+
* Best-effort: `create-akan-workspace` scaffolds before `bun install`, so there is no local Biome binary
|
|
704
|
+
* and often no config above the target yet. An unformatted file is a lint fix; a failed scaffold is not.
|
|
705
|
+
*/
|
|
706
|
+
async #formatAppliedTemplate(fileContents: FileContent[]) {
|
|
707
|
+
const filePaths = fileContents
|
|
708
|
+
.map((fileContent) => fileContent.filePath)
|
|
709
|
+
.filter((filePath) => filePath.endsWith(".ts") || filePath.endsWith(".tsx"));
|
|
710
|
+
if (filePaths.length === 0) return;
|
|
711
|
+
try {
|
|
712
|
+
const { fixed } = await this.getLinter().fixFiles(filePaths);
|
|
713
|
+
if (fixed.length > 0) this.logger.verbose(`Formatted ${fixed.length} scaffolded file(s)`);
|
|
714
|
+
} catch (err) {
|
|
715
|
+
this.logger.verbose(`Skipped formatting scaffolded files: ${err instanceof Error ? err.message : String(err)}`);
|
|
716
|
+
}
|
|
694
717
|
}
|
|
695
718
|
// Async so `typescript` (~65MB resident) is loaded only by the commands that actually typecheck,
|
|
696
719
|
// not by every process that imports an executor. `typeCheckAsync` below runs in a subprocess and
|
|
@@ -913,9 +936,10 @@ export class WorkspaceExecutor extends Executor {
|
|
|
913
936
|
return await getDirs(basePath);
|
|
914
937
|
}
|
|
915
938
|
async commit(message: string, { init = false, add = true }: { init?: boolean; add?: boolean } = {}) {
|
|
916
|
-
if (init) await this.
|
|
917
|
-
if (add) await this.
|
|
918
|
-
|
|
939
|
+
if (init) await this.spawn("git", ["init", "--quiet"]);
|
|
940
|
+
if (add) await this.spawn("git", ["add", "."]);
|
|
941
|
+
// Argument vector, not a shell string: a message carrying a double quote breaks the interpolated form.
|
|
942
|
+
await this.spawn("git", ["commit", "--quiet", "-m", message]);
|
|
919
943
|
}
|
|
920
944
|
async #getDirHasFile(basePath: string, targetFilename: string) {
|
|
921
945
|
const AVOID_DIRS = ["node_modules", "dist", "public", "webkit"];
|
|
@@ -1369,7 +1393,7 @@ export class AppExecutor extends SysExecutor {
|
|
|
1369
1393
|
//* generated against and typechecked. Drop the cache so the build phases re-read it without them.
|
|
1370
1394
|
this.#excludeDevOnlyPages = true;
|
|
1371
1395
|
this.#pageKeys = null;
|
|
1372
|
-
|
|
1396
|
+
await this.dist.removeDir(this.dist.cwdPath);
|
|
1373
1397
|
await Promise.all([this.dist.mkdir("private"), this.dist.mkdir("public")]);
|
|
1374
1398
|
//* Lib assets are symlinks in the app dir (see syncAssets). dist is the docker build context and the
|
|
1375
1399
|
//* release tarball root, neither of which follows a link out of itself, so materialize them here.
|
package/fileEditor.ts
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
export class FileEditor {
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
#filePath: string;
|
|
3
|
+
#content: string;
|
|
4
4
|
|
|
5
5
|
private constructor(filePath: string, content: string) {
|
|
6
|
-
this
|
|
7
|
-
this
|
|
6
|
+
this.#filePath = filePath;
|
|
7
|
+
this.#content = content;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
static async create(filePath: string): Promise<FileEditor> {
|
|
11
11
|
try {
|
|
12
12
|
const content = await Bun.file(filePath).text();
|
|
13
13
|
return new FileEditor(filePath, content);
|
|
14
|
-
} catch (
|
|
14
|
+
} catch (_error) {
|
|
15
15
|
throw new Error(`Failed to read file: ${filePath}`);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
find(pattern: string | RegExp): number {
|
|
20
|
-
const lines = this
|
|
20
|
+
const lines = this.#content.split("\n");
|
|
21
21
|
const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
|
|
22
22
|
|
|
23
23
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -30,7 +30,7 @@ export class FileEditor {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
findAll(pattern: string | RegExp): number[] {
|
|
33
|
-
const lines = this
|
|
33
|
+
const lines = this.#content.split("\n");
|
|
34
34
|
const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
|
|
35
35
|
const matches: number[] = [];
|
|
36
36
|
|
|
@@ -50,9 +50,9 @@ export class FileEditor {
|
|
|
50
50
|
throw new Error(`Pattern not found: ${pattern}`);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
const lines = this
|
|
53
|
+
const lines = this.#content.split("\n");
|
|
54
54
|
lines.splice(lineIndex + 1, 0, data);
|
|
55
|
-
this
|
|
55
|
+
this.#content = lines.join("\n");
|
|
56
56
|
|
|
57
57
|
return this;
|
|
58
58
|
}
|
|
@@ -64,43 +64,43 @@ export class FileEditor {
|
|
|
64
64
|
throw new Error(`Pattern not found: ${pattern}`);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
const lines = this
|
|
67
|
+
const lines = this.#content.split("\n");
|
|
68
68
|
lines.splice(lineIndex, 0, data);
|
|
69
|
-
this
|
|
69
|
+
this.#content = lines.join("\n");
|
|
70
70
|
|
|
71
71
|
return this;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
replace(pattern: string | RegExp, replacement: string): this {
|
|
75
75
|
const regex = typeof pattern === "string" ? new RegExp(pattern, "g") : pattern;
|
|
76
|
-
this
|
|
76
|
+
this.#content = this.#content.replace(regex, replacement);
|
|
77
77
|
return this;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
append(data: string): this {
|
|
81
|
-
this
|
|
81
|
+
this.#content += `\n${data}`;
|
|
82
82
|
return this;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
prepend(data: string): this {
|
|
86
|
-
this
|
|
86
|
+
this.#content = `${data}\n${this.#content}`;
|
|
87
87
|
return this;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
async save(): Promise<void> {
|
|
91
91
|
try {
|
|
92
|
-
await Bun.write(this
|
|
93
|
-
} catch (
|
|
94
|
-
throw new Error(`Failed to save file: ${this
|
|
92
|
+
await Bun.write(this.#filePath, this.#content);
|
|
93
|
+
} catch (_error) {
|
|
94
|
+
throw new Error(`Failed to save file: ${this.#filePath}`);
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
getContent(): string {
|
|
99
|
-
return this
|
|
99
|
+
return this.#content;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
setContent(content: string): this {
|
|
103
|
-
this
|
|
103
|
+
this.#content = content;
|
|
104
104
|
return this;
|
|
105
105
|
}
|
|
106
106
|
}
|
|
@@ -355,3 +355,61 @@ describe("AutoImportSync.syncForBatch", () => {
|
|
|
355
355
|
expect(await readFile(indexFile, "utf8")).not.toContain("@libs/shared/client");
|
|
356
356
|
});
|
|
357
357
|
});
|
|
358
|
+
|
|
359
|
+
describe("AutoImportSync — scope", () => {
|
|
360
|
+
const writeAt = async (root: string, rel: string, source: string) => {
|
|
361
|
+
const abs = path.join(root, rel);
|
|
362
|
+
await mkdir(path.dirname(abs), { recursive: true });
|
|
363
|
+
await writeFile(abs, source);
|
|
364
|
+
return abs;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// `lib/__lib/` is generated and gitignored: `akan sync` rewrites it, which is also what makes the
|
|
368
|
+
// watcher hand it to the syncer, so an edit there is churn that the next sync discards.
|
|
369
|
+
test("leaves the generated lib/__lib stubs alone and still syncs lib/__scalar", async () => {
|
|
370
|
+
const root = await makeTempRoot();
|
|
371
|
+
const needsImport = "export class A {\n count = field(Int);\n}\n";
|
|
372
|
+
const generated = await writeAt(root, "libs/shared/lib/__lib/lib.constant.ts", needsImport);
|
|
373
|
+
const scalar = await writeAt(root, "libs/shared/lib/__scalar/money/money.constant.ts", needsImport);
|
|
374
|
+
|
|
375
|
+
const result = await new AutoImportSync({ workspaceRoot: root }).syncForBatch([generated, scalar]);
|
|
376
|
+
|
|
377
|
+
expect(result.errors).toEqual([]);
|
|
378
|
+
expect(result.changedFiles).toEqual([scalar]);
|
|
379
|
+
expect(await readFile(generated, "utf8")).toBe(needsImport);
|
|
380
|
+
expect(await readFile(scalar, "utf8")).toContain('import { Int } from "akanjs/base";');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// The transform is pure and the write is conditional on a difference, so a second pass over its own
|
|
384
|
+
// output writes nothing — without that the watcher would re-fire on the syncer's own edit.
|
|
385
|
+
test("a second pass over its own output changes nothing", async () => {
|
|
386
|
+
const root = await makeTempRoot();
|
|
387
|
+
const file = await writeAt(
|
|
388
|
+
root,
|
|
389
|
+
"libs/shared/lib/task/task.constant.ts",
|
|
390
|
+
"export class A {\n count = field(Int);\n}\n",
|
|
391
|
+
);
|
|
392
|
+
const sync = new AutoImportSync({ workspaceRoot: root });
|
|
393
|
+
|
|
394
|
+
expect((await sync.syncForBatch([file])).changedFiles).toEqual([file]);
|
|
395
|
+
const afterFirst = await readFile(file, "utf8");
|
|
396
|
+
expect((await sync.syncForBatch([file])).changedFiles).toEqual([]);
|
|
397
|
+
expect(await readFile(file, "utf8")).toBe(afterFirst);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// One try/catch per file: a file that cannot be read is reported and the rest of the batch still runs.
|
|
401
|
+
test("a failing file is reported without abandoning the batch", async () => {
|
|
402
|
+
const root = await makeTempRoot();
|
|
403
|
+
const missing = path.join(root, "libs/shared/lib/task/gone.constant.ts");
|
|
404
|
+
const good = await writeAt(
|
|
405
|
+
root,
|
|
406
|
+
"libs/shared/lib/task/task.constant.ts",
|
|
407
|
+
"export class A {\n count = field(Int);\n}\n",
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
const result = await new AutoImportSync({ workspaceRoot: root }).syncForBatch([missing, good]);
|
|
411
|
+
|
|
412
|
+
expect(result.changedFiles).toEqual([good]);
|
|
413
|
+
expect(result.errors).toEqual([]);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
@@ -250,6 +250,10 @@ export class AutoImportSync {
|
|
|
250
250
|
const parts = rel.split(path.sep).filter(Boolean);
|
|
251
251
|
const [scope, project, facet] = parts;
|
|
252
252
|
if ((scope !== "apps" && scope !== "libs") || !project || !facet) return null;
|
|
253
|
+
//* `lib/__lib/` holds the generated per-lib re-export stubs — gitignored, and rewritten by every
|
|
254
|
+
//* `akan sync`, which is also what makes the watcher report them. An edit there is churn at best and
|
|
255
|
+
//* silently discarded at worst. `lib/__scalar/` is real source and stays in scope.
|
|
256
|
+
if (facet === "lib" && parts[3] === "__lib") return null;
|
|
253
257
|
const role = roleFor(facet, base);
|
|
254
258
|
if (!role) return null;
|
|
255
259
|
|
|
@@ -159,28 +159,25 @@ describe("route client store bootstrap", () => {
|
|
|
159
159
|
await write(uiEntry, 'export { ClientPathWrapper } from "./System/Client";\n');
|
|
160
160
|
await write(clientEntry, '"use client";\nexport const ClientPathWrapper = () => null;\n');
|
|
161
161
|
|
|
162
|
-
const discovery = new GraphClientEntryDiscovery(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return null;
|
|
182
|
-
},
|
|
183
|
-
);
|
|
162
|
+
const discovery = new GraphClientEntryDiscovery({ barrelImports: ["akanjs/ui"] }, async (specifier) => {
|
|
163
|
+
if (specifier === "akanjs/ui") {
|
|
164
|
+
return {
|
|
165
|
+
pkgName: "akanjs/ui",
|
|
166
|
+
entryFile: uiEntry,
|
|
167
|
+
pkgDir: path.dirname(uiEntry),
|
|
168
|
+
preserveFilePath: true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (specifier === "akanjs/ui/System/Client.tsx") {
|
|
172
|
+
return {
|
|
173
|
+
pkgName: "akanjs/ui/System/Client.tsx",
|
|
174
|
+
entryFile: clientEntry,
|
|
175
|
+
pkgDir: path.dirname(clientEntry),
|
|
176
|
+
preserveFilePath: true,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
});
|
|
184
181
|
|
|
185
182
|
expect(await discovery.discover([seed])).toEqual([clientEntry]);
|
|
186
183
|
});
|
|
@@ -34,7 +34,7 @@ const shouldSkipNodeModule = (absPath: string) => NODE_MODULES_RE.test(absPath)
|
|
|
34
34
|
* the traversal matches the module graph the bundler will actually see.
|
|
35
35
|
*/
|
|
36
36
|
export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
37
|
-
#akanConfig: AkanConfig
|
|
37
|
+
#akanConfig: Pick<AkanConfig, "barrelImports">;
|
|
38
38
|
#resolvePackage: PackageResolver;
|
|
39
39
|
#analyzer: BarrelAnalyzer;
|
|
40
40
|
#tsTranspiler = new Bun.Transpiler({ loader: "tsx" });
|
|
@@ -57,7 +57,7 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
|
|
|
57
57
|
#unresolvedPaths = new Set<string>();
|
|
58
58
|
#unresolvedSpecifiers = new Set<string>();
|
|
59
59
|
|
|
60
|
-
constructor(akanConfig: AkanConfig, resolvePackage: PackageResolver) {
|
|
60
|
+
constructor(akanConfig: Pick<AkanConfig, "barrelImports">, resolvePackage: PackageResolver) {
|
|
61
61
|
this.#akanConfig = akanConfig;
|
|
62
62
|
this.#resolvePackage = resolvePackage;
|
|
63
63
|
this.#analyzer = new BarrelAnalyzer({ resolvePackage });
|
|
@@ -43,7 +43,9 @@ export class FontOptimizer {
|
|
|
43
43
|
#woff2Ready: Promise<void> | null = null;
|
|
44
44
|
|
|
45
45
|
static #ksX1001Text: string | null = null;
|
|
46
|
-
|
|
46
|
+
// 2: the key moved to sha256 over a deterministically ordered `auto` text, so v1 entries cannot be
|
|
47
|
+
// compared against and their subsets were built from `page`/`ui` only.
|
|
48
|
+
static readonly #cacheVersion = 2;
|
|
47
49
|
|
|
48
50
|
constructor(app: App, command: FontOptimizerCommand = "start") {
|
|
49
51
|
this.#app = app;
|
|
@@ -96,9 +98,18 @@ export class FontOptimizer {
|
|
|
96
98
|
}
|
|
97
99
|
// `auto` derives the subset from app source text, which no font config hash can capture.
|
|
98
100
|
if (this.#getFontSubsets(font).includes("auto"))
|
|
99
|
-
sources.push({ autoSubsetText: this.#
|
|
101
|
+
sources.push({ autoSubsetText: this.#cacheDigest(await this.#collectAutoSubsetText()) });
|
|
100
102
|
}
|
|
101
|
-
return this.#
|
|
103
|
+
return this.#cacheDigest({ version: FontOptimizer.#cacheVersion, fonts, sources });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Cache keys get a cryptographic digest, not the 32-bit FNV `#hashFontConfig` computes for filenames:
|
|
108
|
+
* a collision there serves a stale subset as if it were current, while a filename only has to be short
|
|
109
|
+
* and stable.
|
|
110
|
+
*/
|
|
111
|
+
#cacheDigest(value: unknown) {
|
|
112
|
+
return new Bun.CryptoHasher("sha256").update(this.#stableStringify(value)).digest("hex");
|
|
102
113
|
}
|
|
103
114
|
|
|
104
115
|
async #fileStamp(filePath: string): Promise<{ mtimeMs: number; size: number } | null> {
|
|
@@ -387,26 +398,34 @@ export class FontOptimizer {
|
|
|
387
398
|
return "";
|
|
388
399
|
}
|
|
389
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Every source that can put a glyph on screen, concatenated in a **stable** order.
|
|
403
|
+
*
|
|
404
|
+
* The order is load-bearing even though a glyph set is not: `#buildCacheKey` hashes this string, so
|
|
405
|
+
* reading the roots concurrently and pushing as each file resolved made the key depend on i/o
|
|
406
|
+
* scheduling — measured 8 distinct keys over 8 runs against unchanged sources, which means the cache
|
|
407
|
+
* never hit and every build re-subset the fonts.
|
|
408
|
+
*
|
|
409
|
+
* `lib` is in the roots because that is where user-facing text actually lives: a dictionary's
|
|
410
|
+
* `[en, ko]` pairs are the Korean in the app, and a subset built from `page` and `ui` alone renders
|
|
411
|
+
* them as tofu — while hashing the same partial text also stopped a new label from invalidating.
|
|
412
|
+
*/
|
|
390
413
|
async #collectAutoSubsetText() {
|
|
391
414
|
//* Synced lib pages hold app-visible text too, and a glob never crosses the symlink that mounts them.
|
|
392
415
|
const libPageRoots = (await this.#app.getPageRoots()).filter((root) => root.keyPrefix).map((root) => root.dir);
|
|
393
|
-
const roots = [...["page", "ui"].map((dir) => path.join(this.#app.cwdPath, dir)), ...libPageRoots];
|
|
416
|
+
const roots = [...["page", "ui", "lib"].map((dir) => path.join(this.#app.cwdPath, dir)), ...libPageRoots];
|
|
394
417
|
const glob = new Bun.Glob("**/*.{ts,tsx,js,jsx,html,md}");
|
|
395
418
|
const parts: string[] = [];
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
parts.push(await Bun.file(filePath).text());
|
|
407
|
-
}
|
|
408
|
-
}),
|
|
409
|
-
);
|
|
419
|
+
for (const root of [...new Set(roots)].sort()) {
|
|
420
|
+
const isDir = await stat(root).then(
|
|
421
|
+
(entry) => entry.isDirectory(),
|
|
422
|
+
() => false,
|
|
423
|
+
);
|
|
424
|
+
if (!isDir) continue;
|
|
425
|
+
const filePaths: string[] = [];
|
|
426
|
+
for await (const filePath of glob.scan({ cwd: root, absolute: true })) filePaths.push(filePath);
|
|
427
|
+
for (const filePath of filePaths.sort()) parts.push(await Bun.file(filePath).text());
|
|
428
|
+
}
|
|
410
429
|
return parts.join("");
|
|
411
430
|
}
|
|
412
431
|
|
package/frontendBuild/index.ts
CHANGED
|
@@ -20,7 +20,6 @@ export * from "./routesManifestArtifactSerializer";
|
|
|
20
20
|
export * from "./sourceMtimeIndex";
|
|
21
21
|
export * from "./ssrBaseArtifactBuilder";
|
|
22
22
|
export * from "./styleContract";
|
|
23
|
-
export * from "./styleGuard";
|
|
24
23
|
export * from "./themeValidator";
|
|
25
24
|
export * from "./vendorSpecifiers";
|
|
26
25
|
export * from "./watchRootResolver";
|
|
@@ -1,29 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Reports the style contract that `akan lint` enforces, in the shape build/dev/lint share.
|
|
3
|
+
*
|
|
4
|
+
* Only contrast lives here. The vocabulary closure — raw palette classes, arbitrary colors, dropped
|
|
5
|
+
* daisyUI slots, inline color literals, interpolated arbitrary values — is enforced by the grit plugins
|
|
6
|
+
* in `lint/*.grit` during the biome run, so it is not re-scanned. Contrast cannot be a lint rule at all:
|
|
7
|
+
* it is arithmetic over resolved token *values*, which no syntactic pattern can reach.
|
|
5
8
|
*/
|
|
6
|
-
import type { StyleGuardViolation } from "./styleGuard";
|
|
7
9
|
import type { ThemeContrastViolation } from "./themeValidator";
|
|
8
10
|
|
|
9
11
|
export interface StyleContractViolations {
|
|
10
|
-
style: StyleGuardViolation[];
|
|
11
12
|
theme: ThemeContrastViolation[];
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
export const countBlocking = (
|
|
15
|
-
v.style.filter((s) => s.severity === "error").length + v.theme.length;
|
|
15
|
+
export const countBlocking = (violations: StyleContractViolations): number => violations.theme.length;
|
|
16
16
|
|
|
17
|
-
export const formatStyleContract = (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
for (const t of v.theme) {
|
|
25
|
-
lines.push(` [error] contrast ${t.scope} ${t.pair} = ${t.ratio}:1 (min ${t.threshold}:1)`);
|
|
26
|
-
lines.push(` → ${t.suggestion}`);
|
|
27
|
-
}
|
|
28
|
-
return lines.join("\n");
|
|
29
|
-
};
|
|
17
|
+
export const formatStyleContract = (violations: StyleContractViolations): string =>
|
|
18
|
+
violations.theme
|
|
19
|
+
.flatMap((theme) => [
|
|
20
|
+
` [error] contrast ${theme.scope} ${theme.pair} = ${theme.ratio}:1 (min ${theme.threshold}:1)`,
|
|
21
|
+
` → ${theme.suggestion}`,
|
|
22
|
+
])
|
|
23
|
+
.join("\n");
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* WCAG contrast checking over the semantic token pairs. No dependencies, pure functions, and no akanjs
|
|
3
|
+
* runtime import — it has to run inside the lint path.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
5
|
+
* The `--x` / `--x-foreground` pairing is what makes the check possible at all: it says which two values
|
|
6
|
+
* are going to end up on top of each other. That is the guard against a generated palette shipping a site
|
|
7
|
+
* nobody can read.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Thresholds (WCAG 2.1):
|
|
10
|
+
* - body and primary surfaces (background, primary, secondary, accent, neutral, card, popover): 4.5:1,
|
|
11
|
+
* the AA floor for normal text
|
|
12
|
+
* - status and secondary pairs (info, success, warning, destructive, open, muted): 3:1, the AA floor for
|
|
13
|
+
* UI components and large text
|
|
14
|
+
* The shipped light/dark palette in styles.css clears all of them.
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
export interface ThemeContrastViolation {
|
|
@@ -42,13 +46,13 @@ const PAIRS: PairDef[] = [
|
|
|
42
46
|
{ base: "open", fg: "open-foreground", threshold: 3 },
|
|
43
47
|
];
|
|
44
48
|
|
|
45
|
-
//
|
|
49
|
+
// Only these scopes are paired up; tokens scoped to something else (`.campaign-x`, …) are left alone.
|
|
46
50
|
const THEME_SCOPES = new Set([":root", '[data-theme="dark"]', '[data-theme="light"]']);
|
|
47
51
|
|
|
48
52
|
export type ThemeTokensByScope = Record<string, Record<string, string>>;
|
|
49
53
|
|
|
50
54
|
export class ThemeValidator {
|
|
51
|
-
/**
|
|
55
|
+
/** Extracts tokens from the css text and checks every scope it recognizes. */
|
|
52
56
|
validate(css: string): ThemeContrastViolation[] {
|
|
53
57
|
const tokensByScope = ThemeValidator.parseThemeTokens(css);
|
|
54
58
|
const violations: ThemeContrastViolation[] = [];
|
|
@@ -67,7 +71,7 @@ export class ThemeValidator {
|
|
|
67
71
|
if (!bg || !front) continue;
|
|
68
72
|
const bgRgb = ThemeValidator.parseHex(bg);
|
|
69
73
|
const fgRgb = ThemeValidator.parseHex(front);
|
|
70
|
-
if (!bgRgb || !fgRgb) continue; // var()
|
|
74
|
+
if (!bgRgb || !fgRgb) continue; // a var() or non-hex value has no ratio to compute
|
|
71
75
|
const ratio = ThemeValidator.contrastRatio(bgRgb, fgRgb);
|
|
72
76
|
if (ratio >= threshold) continue;
|
|
73
77
|
violations.push({
|
|
@@ -77,20 +81,21 @@ export class ThemeValidator {
|
|
|
77
81
|
foreground: front,
|
|
78
82
|
ratio: Math.round(ratio * 100) / 100,
|
|
79
83
|
threshold,
|
|
80
|
-
suggestion:
|
|
84
|
+
suggestion: `In ${scope}, --${base} (${bg}) against --${fg} (${front}) is ${ratio.toFixed(2)}:1, under the ${threshold}:1 minimum. Lighten or darken one of them until it clears.`,
|
|
81
85
|
});
|
|
82
86
|
}
|
|
83
87
|
return violations;
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
/**
|
|
87
|
-
* `:root` / `[data-theme="…"]`
|
|
88
|
-
* (`:root, [data-theme="dark"] { … }`)
|
|
89
|
-
*
|
|
91
|
+
* Reads `--token: value` out of `:root` / `[data-theme="…"]` blocks. A grouped selector
|
|
92
|
+
* (`:root, [data-theme="dark"] { … }`) distributes the same tokens to each selector, and a scope that
|
|
93
|
+
* appears twice keeps the later value — so passing framework css first and the app's second reflects the
|
|
94
|
+
* app's overrides.
|
|
90
95
|
*/
|
|
91
96
|
static parseThemeTokens(css: string): ThemeTokensByScope {
|
|
92
97
|
const result: ThemeTokensByScope = {};
|
|
93
|
-
//
|
|
98
|
+
// Flat rule blocks only; an at-rule (@theme, @keyframes) carries `@` in the selector and is skipped.
|
|
94
99
|
const blockRe = /(?:^|})\s*([^{}@]+?)\s*\{([^{}]*)\}/g;
|
|
95
100
|
for (const block of css.matchAll(blockRe)) {
|
|
96
101
|
const selectors = block[1].split(",").map((s) => s.trim());
|
|
@@ -109,11 +114,11 @@ export class ThemeValidator {
|
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
static #normalizeScope(selector: string): string {
|
|
112
|
-
//
|
|
117
|
+
// Quote normalization: [data-theme=dark] / [data-theme='dark'] -> [data-theme="dark"]
|
|
113
118
|
return selector.replace(/\[data-theme=['"]?([\w-]+)['"]?\]/g, '[data-theme="$1"]').trim();
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
/** #rgb / #rgba / #rrggbb / #rrggbbaa
|
|
121
|
+
/** #rgb / #rgba / #rrggbb / #rrggbbaa -> [r,g,b], alpha ignored. Null for anything not hex. */
|
|
117
122
|
static parseHex(value: string): [number, number, number] | null {
|
|
118
123
|
const v = value.trim();
|
|
119
124
|
if (!v.startsWith("#")) return null;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BuilderEvent, ChangeBatch } from "akanjs/server";
|
|
1
|
+
import type { BuilderEvent, ChangeBatch, DevChangePlan } from "akanjs/server";
|
|
2
2
|
import type { DevChangePlanner, GeneratedIndexSyncResult } from "../frontendBuild";
|
|
3
3
|
|
|
4
4
|
export interface PrepareDevWatchBatchOptions {
|
|
@@ -12,6 +12,8 @@ export interface PreparedDevWatchBatch {
|
|
|
12
12
|
files: string[];
|
|
13
13
|
kinds: ("code" | "css" | "config")[];
|
|
14
14
|
expandedBatch: ChangeBatch;
|
|
15
|
+
/** The same plan the event carries, where `devPlan` is optional — read it here, not off the event. */
|
|
16
|
+
devPlan: DevChangePlan;
|
|
15
17
|
event: Extract<BuilderEvent, { type: "invalidate" }>;
|
|
16
18
|
hasSyncErrors: boolean;
|
|
17
19
|
}
|
|
@@ -35,13 +37,15 @@ export const prepareDevWatchBatch = ({
|
|
|
35
37
|
});
|
|
36
38
|
|
|
37
39
|
if (indexSync.errors.length > 0 && !devPlan.actions.includes("report-error")) {
|
|
38
|
-
|
|
40
|
+
const withReport: DevChangePlan["actions"] = [...devPlan.actions, "report-error"];
|
|
41
|
+
devPlan.actions = withReport.sort();
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
return {
|
|
42
45
|
files,
|
|
43
46
|
kinds,
|
|
44
47
|
expandedBatch,
|
|
48
|
+
devPlan,
|
|
45
49
|
event: { type: "invalidate", kinds, files, generation, devPlan },
|
|
46
50
|
hasSyncErrors: indexSync.errors.length > 0,
|
|
47
51
|
};
|
|
@@ -186,6 +186,11 @@ class IncrementalBuilder {
|
|
|
186
186
|
this.#shuttingDown = true;
|
|
187
187
|
const started = Date.now();
|
|
188
188
|
this.#logger.debug(`shutdown requested (${reason}); draining ${this.#inFlight} work item(s)`);
|
|
189
|
+
// Stopped before the drain, or a save landing mid-drain would enqueue a batch behind the queue tail
|
|
190
|
+
// this method already awaited — and `process.exit(0)` would cut that batch off partway through
|
|
191
|
+
// writing its artifacts. The replacement rebuilds every artifact from its boot build anyway, so the
|
|
192
|
+
// batch is not lost; a half-written one would be.
|
|
193
|
+
this.#watcher?.stop();
|
|
189
194
|
if (this.#cssRebuildTimer) {
|
|
190
195
|
// Only reachable if a css batch landed between the idle report and this request: the fresh
|
|
191
196
|
// boot build recompiles css from scratch anyway, so dropping the debounce loses nothing.
|
|
@@ -303,13 +308,12 @@ class IncrementalBuilder {
|
|
|
303
308
|
//* them to the watcher so its verification scan does not read them back as a user edit and spend a
|
|
304
309
|
//* second generation rebuilding identical content.
|
|
305
310
|
await this.#watcher?.absorb([...autoImport.changedFiles, ...indexSync.changedFiles]);
|
|
306
|
-
const { files, kinds, expandedBatch, event, hasSyncErrors } = prepareDevWatchBatch({
|
|
311
|
+
const { files, kinds, expandedBatch, devPlan, event, hasSyncErrors } = prepareDevWatchBatch({
|
|
307
312
|
generation,
|
|
308
313
|
batch,
|
|
309
314
|
indexSync,
|
|
310
315
|
changePlanner: this.#changePlanner,
|
|
311
316
|
});
|
|
312
|
-
const devPlan = event.devPlan;
|
|
313
317
|
this.#logger.verbose(
|
|
314
318
|
`[hmr] batch generation=${generation} kinds=${kinds.join(",")} files=${files.length} generated=${indexSync.changedFiles.length} roles=${devPlan.roles.join(",") || "(none)"} actions=${devPlan.actions.join(",") || "(none)"}`,
|
|
315
319
|
);
|
package/index.ts
CHANGED
|
@@ -22,19 +22,15 @@ export type * from "./applicationBuildRunner";
|
|
|
22
22
|
export type * from "./applicationReleasePackager";
|
|
23
23
|
export type * from "./applicationTestPreload";
|
|
24
24
|
export type * from "./artifact";
|
|
25
|
-
export type * from "./builder";
|
|
26
25
|
export type * from "./capacitorApp";
|
|
27
26
|
export type * from "./cloud";
|
|
28
27
|
export type * from "./commandDecorators";
|
|
29
28
|
export type * from "./createTunnel";
|
|
30
29
|
export type * from "./dependencyScanner";
|
|
31
30
|
export type * from "./executors";
|
|
32
|
-
export type * from "./extractDeps";
|
|
33
31
|
export type * from "./fileSys";
|
|
34
32
|
export type * from "./frontendBuild";
|
|
35
|
-
export type * from "./getCredentials";
|
|
36
33
|
export type * from "./getDirname";
|
|
37
|
-
export type * from "./getModelFileData";
|
|
38
34
|
export type * from "./getRelatedCnsts";
|
|
39
35
|
export type * from "./guideline";
|
|
40
36
|
export type * from "./incrementalBuilder";
|
|
@@ -46,7 +42,6 @@ export type * from "./recipeScanner";
|
|
|
46
42
|
export type * from "./scanInfo";
|
|
47
43
|
export type * from "./selectModel";
|
|
48
44
|
export type * from "./spinner";
|
|
49
|
-
export type * from "./streamAi";
|
|
50
45
|
export type * from "./transforms";
|
|
51
46
|
export type * from "./typeChecker";
|
|
52
47
|
export type * from "./types";
|