@akanjs/devkit 3.0.0-alpha.1 → 3.0.0-alpha.100
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 +15 -0
- package/DEV_RUNTIME_KNOBS.md +34 -0
- 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/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 +113 -578
- package/akanApp/devHostPolicy.test.ts +542 -0
- package/akanApp/devHostPolicy.ts +442 -0
- package/akanApp/index.ts +2 -0
- package/akanConfig/akanConfig.test.ts +182 -14
- package/akanConfig/akanConfig.ts +143 -47
- package/akanConfig/types.ts +9 -0
- package/akanContext.ts +70 -42
- package/akanMcpContract.ts +25 -4
- package/appSelectionMemory.ts +28 -0
- package/applicationBuildRunner.test.ts +37 -2
- package/applicationBuildRunner.ts +53 -24
- package/artifact/implicitRootLayout.test.ts +67 -0
- package/artifact/implicitRootLayout.ts +27 -7
- package/artifact/routeSeedIndex.test.ts +1 -0
- package/biome.base.json +340 -0
- package/biomeBase.ts +9 -0
- package/capacitorApp.test.ts +8 -0
- package/capacitorApp.ts +122 -18
- package/cloud/globalConfig.ts +9 -2
- package/codegenLock.test.ts +137 -0
- package/codegenLock.ts +136 -0
- package/commandDecorators/argMeta.ts +9 -1
- package/commandDecorators/command.ts +94 -7
- package/commandDecorators/commandBuilder.ts +20 -5
- package/commandDecorators/commandDecorators.test.ts +115 -3
- package/commandDecorators/helpFormatter.ts +4 -0
- package/dependencyScanner.test.ts +99 -0
- package/dependencyScanner.ts +27 -24
- package/devkitUtils.test.ts +0 -104
- package/executors.test.ts +137 -5
- package/executors.ts +116 -26
- package/fileEditor.ts +19 -19
- package/fileSys.ts +19 -1
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/frontendBuild/allRoutesBuilder.ts +27 -8
- package/frontendBuild/autoImportSync.test.ts +58 -0
- package/frontendBuild/autoImportSync.ts +7 -2
- package/frontendBuild/buildRouteClient.test.ts +47 -24
- package/frontendBuild/clientBuildTypes.ts +4 -0
- package/frontendBuild/clientEntriesBundler.ts +4 -1
- package/frontendBuild/clientEntryDiscovery.ts +2 -2
- package/frontendBuild/csrArtifactBuilder.ts +116 -84
- package/frontendBuild/cssCandidateCache.test.ts +38 -0
- package/frontendBuild/cssCandidateCache.ts +15 -3
- package/frontendBuild/cssCompiler.ts +123 -12
- package/frontendBuild/cssImportResolver.ts +8 -7
- package/frontendBuild/devGeneratedIndexSync.ts +3 -2
- package/frontendBuild/fontOptimizer.ts +37 -18
- package/frontendBuild/fontPruner.test.ts +220 -0
- package/frontendBuild/fontPruner.ts +206 -0
- package/frontendBuild/frontendBuild.test.ts +178 -10
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/index.ts +1 -1
- package/frontendBuild/pagesBundleBuilder.ts +3 -3
- package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
- package/frontendBuild/precompressArtifacts.ts +35 -7
- package/frontendBuild/routeClientBuilder.ts +26 -7
- package/frontendBuild/sourceMtimeIndex.ts +1 -1
- package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
- package/frontendBuild/styleContract.ts +14 -20
- package/frontendBuild/themeValidator.ts +22 -17
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/frontendBuild/watchRootResolver.test.ts +129 -0
- package/frontendBuild/watchRootResolver.ts +66 -1
- package/incrementalBuilder/devWatchBatch.test.ts +18 -20
- package/incrementalBuilder/devWatchBatch.ts +6 -2
- package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
- package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
- package/index.ts +0 -7
- package/integration/devStabilityHarness.ts +2 -10
- package/libSource.test.ts +112 -0
- package/libSource.ts +126 -0
- 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-async-component-in-ui.grit +35 -0
- package/lint/no-bang-comment-in-client.grit +33 -0
- package/lint/no-daisyui-legacy-class.grit +26 -9
- package/lint/no-deprecated-log-level.grit +17 -0
- package/lint/no-import-client-in-server.grit +48 -0
- package/lint/no-import-server-in-client.grit +45 -0
- package/lint/no-init-fetch-in-client.grit +47 -0
- package/lint/no-inline-color.grit +10 -8
- package/lint/no-interpolated-arbitrary-class.grit +3 -3
- package/lint/no-model-type-in-util-zone.grit +58 -0
- package/lint/no-raw-palette-class.grit +3 -3
- package/lint/no-return-in-store-action.grit +35 -0
- package/lint/no-unpublished-form-setter.grit +41 -0
- package/lint/non-scalar-props-restricted.grit +16 -7
- package/linter.test.ts +80 -0
- package/linter.ts +99 -25
- package/package.json +6 -8
- package/prompter.ts +9 -4
- package/qualityScanner.test.ts +130 -0
- package/qualityScanner.ts +91 -62
- package/recipeScanner.ts +4 -1
- package/repoIdentity.ts +42 -0
- package/routeSourceValidator.test.ts +41 -0
- package/routeSourceValidator.ts +2 -44
- package/scanInfo.ts +32 -67
- package/semver.test.ts +26 -0
- package/semver.ts +31 -0
- package/slicePlanner.test.ts +182 -0
- package/slicePlanner.ts +235 -0
- package/ssrScanner.test.ts +301 -0
- package/subspace.test.ts +418 -0
- package/subspace.ts +723 -0
- package/subspaceConfig.ts +76 -0
- package/transforms/asyncDefaultExportDetector.ts +103 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/externalizeFrameworkPlugin.ts +0 -1
- package/transforms/transforms.test.ts +5 -5
- package/transforms/tsconfigPackageResolver.test.ts +230 -0
- package/tsconfig.json +6 -2
- package/typeChecker.ts +1 -1
- package/types.ts +1 -0
- package/uploadRelease.ts +2 -2
- package/workflow/executor.test.ts +146 -0
- package/workspaceLayout.test.ts +78 -0
- package/workspaceLayout.ts +106 -0
- package/builder.ts +0 -164
- package/extractDeps.ts +0 -86
- package/frontendBuild/styleGuard.test.ts +0 -165
- package/frontendBuild/styleGuard.ts +0 -322
- package/getCredentials.ts +0 -19
- package/getModelFileData.ts +0 -62
- package/src/capacitorApp.ts +0 -282
- package/streamAi.ts +0 -45
- package/ui/MultiScrollList.tsx +0 -242
- package/ui/ScrollList.tsx +0 -107
- package/ui/index.ts +0 -2
- package/useStdoutDimensions.ts +0 -20
|
@@ -1,26 +1,41 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import zlib from "node:zlib";
|
|
3
4
|
import type { App } from "../commandDecorators";
|
|
4
5
|
|
|
5
6
|
const COMPRESSIBLE_EXTS = new Set([".css", ".html", ".js", ".json", ".svg"]);
|
|
6
7
|
const MIN_COMPRESS_BYTES = 1024;
|
|
8
|
+
/**
|
|
9
|
+
* Quality 11 is roughly 100x slower than gzip, so it is spent only where it pays: there is one CSS asset
|
|
10
|
+
* per basePath and it is the largest single file the app ships, worth ~20% over gzip for ~0.2s. Raising the
|
|
11
|
+
* several hundred JS chunks from 9 to 11 costs ~12s of build time to save a few hundred KB, so they stay at 9.
|
|
12
|
+
*/
|
|
13
|
+
const BROTLI_QUALITY_BY_EXT = { ".css": 11 } as const;
|
|
14
|
+
const DEFAULT_BROTLI_QUALITY = 9;
|
|
15
|
+
const GZIP_LEVEL = 9;
|
|
7
16
|
|
|
8
17
|
export interface PrecompressArtifactsResult {
|
|
9
18
|
files: number;
|
|
10
19
|
inputBytes: number;
|
|
11
20
|
outputBytes: number;
|
|
21
|
+
brotliBytes: number;
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
export async function precompressArtifacts(app: App): Promise<PrecompressArtifactsResult> {
|
|
15
|
-
|
|
16
|
-
|
|
25
|
+
//* styles too: WebRouter serves both prefixes through the same sidecar-aware #fileResponse, and the
|
|
26
|
+
//* one CSS asset per basePath is the largest uncompressed payload the app ships.
|
|
27
|
+
const roots = [
|
|
28
|
+
path.join(app.dist.cwdPath, ".akan/artifact/client"),
|
|
29
|
+
path.join(app.dist.cwdPath, ".akan/artifact/styles"),
|
|
30
|
+
];
|
|
31
|
+
const result: PrecompressArtifactsResult = { files: 0, inputBytes: 0, outputBytes: 0, brotliBytes: 0 };
|
|
17
32
|
|
|
18
33
|
await Promise.all(roots.map((root) => precompressRoot(root, result)));
|
|
19
34
|
if (result.files > 0) {
|
|
20
35
|
app.verbose(
|
|
21
|
-
`[precompress] wrote ${result.files}
|
|
36
|
+
`[precompress] wrote ${result.files} sidecars (${formatBytes(result.inputBytes)} -> gzip ${formatBytes(
|
|
22
37
|
result.outputBytes,
|
|
23
|
-
)})`,
|
|
38
|
+
)} / br ${formatBytes(result.brotliBytes)})`,
|
|
24
39
|
);
|
|
25
40
|
}
|
|
26
41
|
return result;
|
|
@@ -32,16 +47,29 @@ async function precompressRoot(root: string, result: PrecompressArtifactsResult)
|
|
|
32
47
|
for await (const filePath of glob.scan({ cwd: root, absolute: true })) {
|
|
33
48
|
if (!(await shouldPrecompress(filePath))) continue;
|
|
34
49
|
const bytes = await Bun.file(filePath).bytes();
|
|
35
|
-
const
|
|
36
|
-
|
|
50
|
+
const buffer = toArrayBuffer(bytes);
|
|
51
|
+
const gz = Bun.gzipSync(buffer, { level: GZIP_LEVEL });
|
|
52
|
+
const br = brotliCompress(buffer, path.extname(filePath).toLowerCase());
|
|
53
|
+
await Promise.all([Bun.write(`${filePath}.gz`, gz), Bun.write(`${filePath}.br`, br)]);
|
|
37
54
|
result.files += 1;
|
|
38
55
|
result.inputBytes += bytes.byteLength;
|
|
39
56
|
result.outputBytes += gz.byteLength;
|
|
57
|
+
result.brotliBytes += br.byteLength;
|
|
40
58
|
}
|
|
41
59
|
}
|
|
42
60
|
|
|
61
|
+
function brotliCompress(buffer: ArrayBuffer, ext: string): Buffer {
|
|
62
|
+
const quality = BROTLI_QUALITY_BY_EXT[ext as keyof typeof BROTLI_QUALITY_BY_EXT] ?? DEFAULT_BROTLI_QUALITY;
|
|
63
|
+
return zlib.brotliCompressSync(buffer, {
|
|
64
|
+
params: {
|
|
65
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: quality,
|
|
66
|
+
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: buffer.byteLength,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
43
71
|
async function shouldPrecompress(filePath: string): Promise<boolean> {
|
|
44
|
-
if (filePath.endsWith(".gz")) return false;
|
|
72
|
+
if (filePath.endsWith(".gz") || filePath.endsWith(".br")) return false;
|
|
45
73
|
if (!COMPRESSIBLE_EXTS.has(path.extname(filePath).toLowerCase())) return false;
|
|
46
74
|
const file = Bun.file(filePath);
|
|
47
75
|
if (!(await file.exists())) return false;
|
|
@@ -4,7 +4,7 @@ import type { BaseBuildArtifact, ClientManifest, SsrManifest } from "akanjs/serv
|
|
|
4
4
|
import type { App } from "../commandDecorators";
|
|
5
5
|
import { createBarrelImportsPlugin } from "../transforms/barrelImportsPlugin";
|
|
6
6
|
import { toClientReferencePath } from "../transforms/rscUseClientTransform";
|
|
7
|
-
import type { ClientEntryDiscovery } from "./clientBuildTypes";
|
|
7
|
+
import type { ClientBundleTarget, ClientEntryDiscovery } from "./clientBuildTypes";
|
|
8
8
|
import { ClientEntriesBundler } from "./clientEntriesBundler";
|
|
9
9
|
import { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
|
|
10
10
|
import { VENDOR_SPECIFIERS } from "./vendorSpecifiers";
|
|
@@ -33,6 +33,15 @@ export interface BuildRouteClientOptions {
|
|
|
33
33
|
routeId?: string;
|
|
34
34
|
command?: "build" | "start";
|
|
35
35
|
discovery?: ClientEntryDiscovery;
|
|
36
|
+
/**
|
|
37
|
+
* Client entries resolved by the caller, which skips discovery and bundles exactly this list.
|
|
38
|
+
*
|
|
39
|
+
* `AllRoutesBuilder` passes every route's entries at once so they share one `Bun.build`. Chunk
|
|
40
|
+
* splitting is scoped to a single invocation, so a dependency reachable from entries spread across
|
|
41
|
+
* several invocations is emitted once per invocation — mermaid landed in `apps/akan` four times that
|
|
42
|
+
* way. Dev keeps one build per route and leaves this unset.
|
|
43
|
+
*/
|
|
44
|
+
entries?: string[];
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
export interface BuildRouteClientResult {
|
|
@@ -61,6 +70,7 @@ export class RouteClientBuilder {
|
|
|
61
70
|
#knownEntries: Set<string>;
|
|
62
71
|
#command: "build" | "start";
|
|
63
72
|
#discovery?: ClientEntryDiscovery;
|
|
73
|
+
#entries?: string[];
|
|
64
74
|
|
|
65
75
|
constructor(options: BuildRouteClientOptions) {
|
|
66
76
|
this.#app = options.app;
|
|
@@ -68,11 +78,13 @@ export class RouteClientBuilder {
|
|
|
68
78
|
this.#knownEntries = options.knownEntries ?? new Set<string>();
|
|
69
79
|
this.#command = options.command ?? "start";
|
|
70
80
|
this.#discovery = options.discovery;
|
|
81
|
+
this.#entries = options.entries;
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
async build(): Promise<BuildRouteClientResult> {
|
|
74
|
-
const
|
|
75
|
-
|
|
85
|
+
const discovered =
|
|
86
|
+
this.#entries ??
|
|
87
|
+
(await (this.#discovery ?? (await GraphClientEntryDiscovery.create(this.#app))).discover(this.#seeds));
|
|
76
88
|
const entries = discovered.filter((e) => !this.#knownEntries.has(e));
|
|
77
89
|
if (entries.length === 0) return this.#emptyResult(discovered);
|
|
78
90
|
|
|
@@ -151,12 +163,11 @@ export class RouteClientBuilder {
|
|
|
151
163
|
}
|
|
152
164
|
|
|
153
165
|
async #buildSsrBundle(bootstrapEntries: BootstrapEntries) {
|
|
154
|
-
const externalOptions = RouteClientBuilder.resolveSsrClientExternalOptions(this.#command);
|
|
155
166
|
return new ClientEntriesBundler({
|
|
156
167
|
app: this.#app,
|
|
157
168
|
entries: bootstrapEntries.buildEntries,
|
|
158
169
|
plugins: [await createBarrelImportsPlugin(this.#app)],
|
|
159
|
-
...
|
|
170
|
+
...RouteClientBuilder.resolveSsrClientBundleOptions(this.#command),
|
|
160
171
|
outputSubdir: "client-ssr",
|
|
161
172
|
command: this.#command,
|
|
162
173
|
}).bundle();
|
|
@@ -227,20 +238,28 @@ export class RouteClientBuilder {
|
|
|
227
238
|
return { [Bun.resolveSync("akanjs/fetch", serverEntry)]: "akanjs/fetch" };
|
|
228
239
|
}
|
|
229
240
|
|
|
230
|
-
|
|
241
|
+
/**
|
|
242
|
+
* `target: "bun"` is load-bearing: these chunks are `await import()`-ed by the SSR renderer, so a dependency
|
|
243
|
+
* resolved through its `browser` export condition can touch `document` at module scope and throw mid-render,
|
|
244
|
+
* degrading the whole document to client rendering. Bun's `conditions` only adds to the target's defaults —
|
|
245
|
+
* `browser` still wins — so the target itself has to say server.
|
|
246
|
+
*/
|
|
247
|
+
static resolveSsrClientBundleOptions(command: "build" | "start"): {
|
|
248
|
+
target: ClientBundleTarget;
|
|
231
249
|
external: readonly string[];
|
|
232
250
|
externalSubpaths?: readonly string[];
|
|
233
251
|
externalAliases?: Record<string, string>;
|
|
234
252
|
} {
|
|
235
253
|
if (command === "start") {
|
|
236
254
|
return {
|
|
255
|
+
target: "bun",
|
|
237
256
|
external: SSR_CLIENT_EXTERNALS,
|
|
238
257
|
externalSubpaths: ["akanjs/fetch"],
|
|
239
258
|
externalAliases: RouteClientBuilder.resolveSsrClientRuntimeAliases(),
|
|
240
259
|
};
|
|
241
260
|
}
|
|
242
261
|
|
|
243
|
-
return { external: SSR_CLIENT_ALIAS_EXTERNALS };
|
|
262
|
+
return { target: "bun", external: SSR_CLIENT_ALIAS_EXTERNALS };
|
|
244
263
|
}
|
|
245
264
|
|
|
246
265
|
static resolveAkanServerEntry(): string {
|
|
@@ -377,7 +377,7 @@ export class SourceMtimeIndex {
|
|
|
377
377
|
return !name || name.startsWith(".") || name === "node_modules";
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
-
/** `WatchRootResolver` can return `apps/<app>/page` alongside `apps
|
|
380
|
+
/** `WatchRootResolver` can return `apps/<app>/page` alongside `apps/<app>`; walking both doubles the work. */
|
|
381
381
|
static #pruneNestedRoots(roots: string[]): string[] {
|
|
382
382
|
const resolved = [...new Set(roots.map((root) => path.resolve(root)))].sort();
|
|
383
383
|
return resolved.filter(
|
|
@@ -5,7 +5,7 @@ import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
|
5
5
|
import { computeRouteSeedIndex, type RouteSeedIndex, saveRouteSeedIndex } from "../artifact/routeSeedIndex";
|
|
6
6
|
import type { App } from "../commandDecorators";
|
|
7
7
|
import { ClientEntriesBundler } from "./clientEntriesBundler";
|
|
8
|
-
import { CssCompiler } from "./cssCompiler";
|
|
8
|
+
import { CssCompiler, type ImportedStylesheet } from "./cssCompiler";
|
|
9
9
|
import { FontOptimizer } from "./fontOptimizer";
|
|
10
10
|
import { PagesBundleBuilder } from "./pagesBundleBuilder";
|
|
11
11
|
import { RouteClientBuilder } from "./routeClientBuilder";
|
|
@@ -77,6 +77,7 @@ export class SsrBaseArtifactBuilder {
|
|
|
77
77
|
branches: [...akanConfig.branches],
|
|
78
78
|
i18n: akanConfig.i18n,
|
|
79
79
|
imageConfig: akanConfig.images,
|
|
80
|
+
web: akanConfig.web,
|
|
80
81
|
deepLinkAssociations: Object.values(akanConfig.mobile.targets)
|
|
81
82
|
.filter((target) => (target.deepLinks?.domains?.length ?? 0) > 0)
|
|
82
83
|
.map((target) => ({
|
|
@@ -110,7 +111,7 @@ export class SsrBaseArtifactBuilder {
|
|
|
110
111
|
const ssrBundle = await new ClientEntriesBundler({
|
|
111
112
|
app: this.#app,
|
|
112
113
|
entries: [rscSegmentOutletEntry],
|
|
113
|
-
...RouteClientBuilder.
|
|
114
|
+
...RouteClientBuilder.resolveSsrClientBundleOptions(this.#command),
|
|
114
115
|
outputSubdir: "client-ssr",
|
|
115
116
|
command: this.#command,
|
|
116
117
|
}).bundle();
|
|
@@ -192,7 +193,7 @@ export class SsrBaseArtifactBuilder {
|
|
|
192
193
|
Object.entries(cssByBasePath).flatMap(([basePath, baseCssText]) => {
|
|
193
194
|
const cssText = [baseCssText, optimizedFonts.css].filter(Boolean).join("\n");
|
|
194
195
|
if (!cssText) return [];
|
|
195
|
-
return [this.#writeCssAsset(basePath, cssText)];
|
|
196
|
+
return [this.#writeCssAsset(basePath, cssText, cssCompiler.importedStylesheetsByBasePath[basePath] ?? [])];
|
|
196
197
|
}),
|
|
197
198
|
),
|
|
198
199
|
);
|
|
@@ -201,7 +202,7 @@ export class SsrBaseArtifactBuilder {
|
|
|
201
202
|
return { cssCompiler, optimizedFonts, cssAssets };
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
async #writeCssAsset(basePath: string, cssText: string) {
|
|
205
|
+
async #writeCssAsset(basePath: string, cssText: string, imported: ImportedStylesheet[]) {
|
|
205
206
|
const cssAssetName = basePath || "root";
|
|
206
207
|
const preparedCssText = await prepareCssAsset(this.#command, basePath, cssText);
|
|
207
208
|
const cssHash = Bun.hash(`${basePath}\n${preparedCssText}`).toString(36);
|
|
@@ -210,7 +211,23 @@ export class SsrBaseArtifactBuilder {
|
|
|
210
211
|
`/_akan/styles/${cssAssetName}-${cssHash}.css`,
|
|
211
212
|
];
|
|
212
213
|
await Bun.write(path.join(this.#absArtifactDir, cssRelPath), preparedCssText);
|
|
214
|
+
SsrBaseArtifactBuilder.#warnDroppedImports(this.#app, cssRelPath, preparedCssText, imported);
|
|
213
215
|
this.#app.verbose(`[base-artifact] wrote ${preparedCssText.length} bytes of CSS for ${basePath} -> ${cssRelPath}`);
|
|
214
216
|
return [basePath, { cssUrl, cssRelPath }] as const;
|
|
215
217
|
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Checked against the file written here rather than against the compiled text, because this is the stylesheet
|
|
221
|
+
* `base-artifact.json` points at and therefore the only one an SSR render serves. A declaration can survive
|
|
222
|
+
* the compile and still be missing from the asset — a build that ships CSS to the CSR bundle and not to the
|
|
223
|
+
* server is indistinguishable, in the browser, from a theme that was never written.
|
|
224
|
+
*/
|
|
225
|
+
static #warnDroppedImports(app: App, cssRelPath: string, css: string, imported: ImportedStylesheet[]) {
|
|
226
|
+
for (const { cssPath, declaredNames } of imported) {
|
|
227
|
+
if (declaredNames.length === 0 || declaredNames.some((name) => css.includes(`${name}:`))) continue;
|
|
228
|
+
app.logger.warn(
|
|
229
|
+
`[base-artifact] @import ${cssPath} declares ${declaredNames.length} custom propert${declaredNames.length === 1 ? "y" : "ies"} and none of them are in ${cssRelPath}`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
216
233
|
}
|
|
@@ -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;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { App } from "../commandDecorators";
|
|
6
|
+
import { WatchRootResolver } from "./watchRootResolver";
|
|
7
|
+
|
|
8
|
+
const roots: string[] = [];
|
|
9
|
+
|
|
10
|
+
const makeWorkspace = async (dirs: string[]) => {
|
|
11
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-watch-roots-"));
|
|
12
|
+
roots.push(root);
|
|
13
|
+
await Promise.all(dirs.map((dir) => mkdir(path.join(root, dir), { recursive: true })));
|
|
14
|
+
return root;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const writeManifest = (workspaceRoot: string, scope: string, libDeps: unknown) =>
|
|
18
|
+
writeFile(
|
|
19
|
+
path.join(workspaceRoot, scope, scope.startsWith("apps/") ? "akan.app.json" : "akan.lib.json"),
|
|
20
|
+
JSON.stringify({ libDeps }),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
interface AppStub {
|
|
24
|
+
paths?: Record<string, string[]>;
|
|
25
|
+
libDeps?: string[] | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const makeApp = (workspaceRoot: string, name: string, { paths = {}, libDeps = null }: AppStub = {}) =>
|
|
29
|
+
({
|
|
30
|
+
cwdPath: path.join(workspaceRoot, "apps", name),
|
|
31
|
+
workspace: { workspaceRoot },
|
|
32
|
+
getTsConfig: async () => ({ compilerOptions: { paths } }),
|
|
33
|
+
getScanInfo: () => (libDeps ? { type: "app", libDeps } : null),
|
|
34
|
+
}) as unknown as App;
|
|
35
|
+
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("WatchRootResolver", () => {
|
|
41
|
+
test("narrows the apps container to the app being served", async () => {
|
|
42
|
+
const workspaceRoot = await makeWorkspace(["apps/app1/page", "apps/app2/page", "libs/util"]);
|
|
43
|
+
const resolved = await new WatchRootResolver(
|
|
44
|
+
makeApp(workspaceRoot, "app1", { paths: { "@apps/*": ["./apps/*"], "@libs/*": ["./libs/*"] } }),
|
|
45
|
+
).resolve();
|
|
46
|
+
|
|
47
|
+
expect(resolved).toContain(path.join(workspaceRoot, "apps/app1"));
|
|
48
|
+
expect(resolved).not.toContain(path.join(workspaceRoot, "apps"));
|
|
49
|
+
expect(resolved).not.toContain(path.join(workspaceRoot, "apps/app2"));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("narrows the libs container to the app's own dependencies", async () => {
|
|
53
|
+
const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared"]);
|
|
54
|
+
const resolved = await new WatchRootResolver(
|
|
55
|
+
makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] }, libDeps: ["util"] }),
|
|
56
|
+
).resolve();
|
|
57
|
+
|
|
58
|
+
expect(resolved).toContain(path.join(workspaceRoot, "libs/util"));
|
|
59
|
+
expect(resolved).not.toContain(path.join(workspaceRoot, "libs"));
|
|
60
|
+
expect(resolved).not.toContain(path.join(workspaceRoot, "libs/shared"));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("drops the libs container entirely when the app depends on no lib", async () => {
|
|
64
|
+
const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util"]);
|
|
65
|
+
await writeManifest(workspaceRoot, "apps/app1", []);
|
|
66
|
+
const resolved = await new WatchRootResolver(
|
|
67
|
+
makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
|
|
68
|
+
).resolve();
|
|
69
|
+
|
|
70
|
+
expect(resolved).toEqual([path.join(workspaceRoot, "apps/app1/page")]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("takes the transitive closure from the synced manifests when nothing scanned in-process", async () => {
|
|
74
|
+
const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared", "libs/unused"]);
|
|
75
|
+
await writeManifest(workspaceRoot, "apps/app1", ["shared"]);
|
|
76
|
+
await writeManifest(workspaceRoot, "libs/shared", ["util"]);
|
|
77
|
+
await writeManifest(workspaceRoot, "libs/util", []);
|
|
78
|
+
await writeManifest(workspaceRoot, "libs/unused", []);
|
|
79
|
+
const resolved = await new WatchRootResolver(
|
|
80
|
+
makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
|
|
81
|
+
).resolve();
|
|
82
|
+
|
|
83
|
+
expect(resolved).toContain(path.join(workspaceRoot, "libs/shared"));
|
|
84
|
+
expect(resolved).toContain(path.join(workspaceRoot, "libs/util"));
|
|
85
|
+
expect(resolved).not.toContain(path.join(workspaceRoot, "libs/unused"));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("keeps the libs container whole when the app manifest is missing", async () => {
|
|
89
|
+
const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared"]);
|
|
90
|
+
const resolved = await new WatchRootResolver(
|
|
91
|
+
makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
|
|
92
|
+
).resolve();
|
|
93
|
+
|
|
94
|
+
expect(resolved).toContain(path.join(workspaceRoot, "libs"));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("keeps the libs container whole when a dependency lib is unsynced", async () => {
|
|
98
|
+
const workspaceRoot = await makeWorkspace(["apps/app1/page", "libs/util", "libs/shared"]);
|
|
99
|
+
await writeManifest(workspaceRoot, "apps/app1", ["shared"]);
|
|
100
|
+
const resolved = await new WatchRootResolver(
|
|
101
|
+
makeApp(workspaceRoot, "app1", { paths: { "@libs/*": ["./libs/*"] } }),
|
|
102
|
+
).resolve();
|
|
103
|
+
|
|
104
|
+
expect(resolved).toContain(path.join(workspaceRoot, "libs"));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("resolves a package alias to its own directory, filename and glob stripped", async () => {
|
|
108
|
+
const workspaceRoot = await makeWorkspace(["apps/app1/page", "pkgs/akanjs/base"]);
|
|
109
|
+
const resolved = await new WatchRootResolver(
|
|
110
|
+
makeApp(workspaceRoot, "app1", {
|
|
111
|
+
paths: {
|
|
112
|
+
akanjs: ["./pkgs/akanjs/index.ts"],
|
|
113
|
+
"akanjs/*": ["./pkgs/akanjs/*"],
|
|
114
|
+
missing: ["./pkgs/nothing/index.ts"],
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
).resolve();
|
|
118
|
+
|
|
119
|
+
expect(resolved).toContain(path.join(workspaceRoot, "pkgs/akanjs"));
|
|
120
|
+
expect(resolved).not.toContain(path.join(workspaceRoot, "pkgs/nothing"));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("always watches the app's page tree", async () => {
|
|
124
|
+
const workspaceRoot = await makeWorkspace(["apps/app1/page"]);
|
|
125
|
+
const resolved = await new WatchRootResolver(makeApp(workspaceRoot, "app1")).resolve();
|
|
126
|
+
|
|
127
|
+
expect(resolved).toEqual([path.join(workspaceRoot, "apps/app1/page")]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import type { App } from "../commandDecorators";
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The directories one app's dev watcher follows, resolved from its tsconfig `paths`.
|
|
8
|
+
*
|
|
9
|
+
* Two aliases strip down to a workspace container rather than a package, and taking either verbatim puts
|
|
10
|
+
* code the app never imports under the watcher — where a save rebuilds, restarts and reloads it for
|
|
11
|
+
* nothing, and both builders rewrite the same generated barrels:
|
|
12
|
+
*
|
|
13
|
+
* - `@apps/*` becomes the `apps/` container. Apps are leaves of the workspace graph — never one another's
|
|
14
|
+
* dependencies — so it is replaced by this app's own directory.
|
|
15
|
+
* - `@libs/*` becomes the `libs/` container. A lib is a dependency only if the app reaches it, so it is
|
|
16
|
+
* replaced by the app's own lib dependencies, transitively. The set is resolved once, at watcher
|
|
17
|
+
* install: a lib that becomes a dependency mid-session needs a fresh `akan start` anyway, because the
|
|
18
|
+
* import that made it one also needs a `sync`.
|
|
19
|
+
*
|
|
20
|
+
* Failing to resolve that set keeps the container whole, because a wrong narrowing is a dev server that
|
|
21
|
+
* silently ignores edits.
|
|
22
|
+
*/
|
|
5
23
|
export class WatchRootResolver {
|
|
6
24
|
#app: App;
|
|
7
25
|
|
|
@@ -11,6 +29,10 @@ export class WatchRootResolver {
|
|
|
11
29
|
|
|
12
30
|
async resolve(): Promise<string[]> {
|
|
13
31
|
const tsconfig = await this.#app.getTsConfig();
|
|
32
|
+
const appDir = path.resolve(this.#app.cwdPath);
|
|
33
|
+
const appsContainer = path.dirname(appDir);
|
|
34
|
+
const libsContainer = path.resolve(this.#app.workspace.workspaceRoot, "libs");
|
|
35
|
+
const libRoots = await this.#resolveLibRoots(libsContainer);
|
|
14
36
|
const set = new Set<string>();
|
|
15
37
|
set.add(path.resolve(`${this.#app.cwdPath}/page`));
|
|
16
38
|
for (const targets of Object.values(tsconfig.compilerOptions.paths ?? {})) {
|
|
@@ -20,9 +42,52 @@ export class WatchRootResolver {
|
|
|
20
42
|
// Strip the trailing filename and glob so we watch the package root dir.
|
|
21
43
|
const cleaned = target.replace(/\/?\*+.*$/, "").replace(/\/[^/]+\.[^/]+$/, "");
|
|
22
44
|
const resolved = path.resolve(this.#app.workspace.workspaceRoot, cleaned);
|
|
23
|
-
if (
|
|
45
|
+
if (resolved === libsContainer && libRoots) {
|
|
46
|
+
for (const root of libRoots) set.add(root);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const root = resolved === appsContainer ? appDir : resolved;
|
|
50
|
+
if (fs.existsSync(root)) set.add(root);
|
|
24
51
|
}
|
|
25
52
|
}
|
|
26
53
|
return [...set];
|
|
27
54
|
}
|
|
55
|
+
|
|
56
|
+
async #resolveLibRoots(libsContainer: string): Promise<string[] | null> {
|
|
57
|
+
const libDeps = await this.#resolveLibDeps(libsContainer);
|
|
58
|
+
if (!libDeps) return null;
|
|
59
|
+
return libDeps.map((name) => path.join(libsContainer, name)).filter((dir) => fs.existsSync(dir));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async #resolveLibDeps(libsContainer: string): Promise<string[] | null> {
|
|
63
|
+
const scanInfo = this.#app.getScanInfo({ allowEmpty: true });
|
|
64
|
+
// Already transitive, and present whenever this runs in a process that scanned. The builder and the
|
|
65
|
+
// idle watcher run in processes that did not, so they take the manifests `scan` wrote instead.
|
|
66
|
+
if (scanInfo?.type === "app") return scanInfo.libDeps;
|
|
67
|
+
const direct = await WatchRootResolver.#readManifestLibDeps(path.join(this.#app.cwdPath, "akan.app.json"));
|
|
68
|
+
if (!direct) return null;
|
|
69
|
+
const closure = new Set<string>();
|
|
70
|
+
const queue = [...direct];
|
|
71
|
+
while (queue.length > 0) {
|
|
72
|
+
const name = queue.shift();
|
|
73
|
+
if (!name || closure.has(name)) continue;
|
|
74
|
+
closure.add(name);
|
|
75
|
+
const nested = await WatchRootResolver.#readManifestLibDeps(path.join(libsContainer, name, "akan.lib.json"));
|
|
76
|
+
// An unsynced lib's own dependencies are unknowable, so no root can be ruled out.
|
|
77
|
+
if (!nested) return null;
|
|
78
|
+
queue.push(...nested);
|
|
79
|
+
}
|
|
80
|
+
return [...closure];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static async #readManifestLibDeps(manifestPath: string): Promise<string[] | null> {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(await readFile(manifestPath, "utf8")) as { libDeps?: unknown };
|
|
86
|
+
if (!Array.isArray(parsed.libDeps)) return null;
|
|
87
|
+
return parsed.libDeps.filter((name): name is string => typeof name === "string" && name.length > 0);
|
|
88
|
+
} catch {
|
|
89
|
+
// Missing before the first sync, and unparseable while sync is mid-write.
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
28
93
|
}
|