@akanjs/devkit 3.0.0-alpha.8 → 3.0.0-alpha.80

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.
Files changed (174) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/agentsIndex.test.ts +10 -0
  5. package/agentsIndex.ts +47 -1
  6. package/aiEditor.ts +1 -1
  7. package/akanApp/BackendImportGraph.test.ts +120 -0
  8. package/akanApp/BackendImportGraph.ts +167 -0
  9. package/akanApp/akanApp.host.test.ts +16 -678
  10. package/akanApp/akanApp.host.ts +65 -562
  11. package/akanApp/devHostPolicy.test.ts +542 -0
  12. package/akanApp/devHostPolicy.ts +412 -0
  13. package/akanApp/index.ts +2 -0
  14. package/akanConfig/akanConfig.test.ts +182 -14
  15. package/akanConfig/akanConfig.ts +132 -47
  16. package/akanConfig/types.ts +8 -0
  17. package/akanContext.ts +70 -14
  18. package/akanMcpContract.ts +25 -4
  19. package/applicationBuildRunner.test.ts +1 -1
  20. package/applicationBuildRunner.ts +45 -21
  21. package/artifact/implicitRootLayout.test.ts +67 -0
  22. package/artifact/implicitRootLayout.ts +27 -7
  23. package/artifact/routeSeedIndex.test.ts +1 -0
  24. package/biome.base.json +340 -0
  25. package/biomeBase.ts +9 -0
  26. package/capacitorApp.ts +10 -0
  27. package/cloud/globalConfig.ts +9 -2
  28. package/commandDecorators/command.ts +30 -6
  29. package/commandDecorators/commandBuilder.ts +20 -5
  30. package/commandDecorators/commandDecorators.test.ts +115 -3
  31. package/dependencyScanner.test.ts +99 -0
  32. package/dependencyScanner.ts +27 -24
  33. package/devkitUtils.test.ts +0 -104
  34. package/executors.test.ts +132 -5
  35. package/executors.ts +89 -14
  36. package/fileEditor.ts +19 -19
  37. package/fleetConfig.ts +73 -0
  38. package/fleetGuard.test.ts +121 -0
  39. package/fleetGuard.ts +157 -0
  40. package/fleetSpoke.test.ts +390 -0
  41. package/fleetSpoke.ts +719 -0
  42. package/formSetterScanner.test.ts +80 -0
  43. package/formSetterScanner.ts +92 -0
  44. package/frontendBuild/autoImportSync.test.ts +58 -0
  45. package/frontendBuild/autoImportSync.ts +4 -0
  46. package/frontendBuild/buildRouteClient.test.ts +47 -24
  47. package/frontendBuild/clientBuildTypes.ts +4 -0
  48. package/frontendBuild/clientEntriesBundler.ts +4 -1
  49. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  50. package/frontendBuild/csrArtifactBuilder.ts +116 -84
  51. package/frontendBuild/cssCompiler.ts +123 -12
  52. package/frontendBuild/cssImportResolver.ts +8 -7
  53. package/frontendBuild/fontOptimizer.ts +37 -18
  54. package/frontendBuild/fontPruner.test.ts +220 -0
  55. package/frontendBuild/fontPruner.ts +206 -0
  56. package/frontendBuild/frontendBuild.test.ts +178 -10
  57. package/frontendBuild/hmrWatcher.ts +1 -1
  58. package/frontendBuild/index.ts +1 -1
  59. package/frontendBuild/pagesBundleBuilder.ts +3 -3
  60. package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
  61. package/frontendBuild/routeClientBuilder.ts +12 -5
  62. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  63. package/frontendBuild/styleContract.ts +14 -20
  64. package/frontendBuild/themeValidator.ts +22 -17
  65. package/frontendBuild/vendorSpecifiers.ts +1 -0
  66. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  67. package/incrementalBuilder/devWatchBatch.ts +6 -2
  68. package/incrementalBuilder/incrementalBuilder.host.ts +1 -1
  69. package/incrementalBuilder/incrementalBuilder.proc.ts +8 -4
  70. package/index.ts +0 -5
  71. package/integration/devStabilityHarness.ts +2 -10
  72. package/libSource.test.ts +109 -0
  73. package/libSource.ts +126 -0
  74. package/lint/__fixtures__/README.md +40 -0
  75. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  76. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  77. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  78. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  79. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  80. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  81. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  82. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  83. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  84. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  85. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  86. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  87. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  88. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  89. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  90. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  91. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  92. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  93. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  94. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  95. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  96. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  97. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  98. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  99. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  100. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  101. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  102. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  103. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  104. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  105. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  106. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  107. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  108. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  109. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  110. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  111. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  112. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  113. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  114. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  115. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  116. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  117. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  118. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  119. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  120. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  121. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  122. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  123. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  124. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  125. package/lint/gritRules.test.ts +178 -0
  126. package/lint/no-arbitrary-color.grit +1 -1
  127. package/lint/no-async-component-in-ui.grit +35 -0
  128. package/lint/no-bang-comment-in-client.grit +23 -10
  129. package/lint/no-daisyui-legacy-class.grit +26 -9
  130. package/lint/no-deprecated-log-level.grit +17 -0
  131. package/lint/no-import-client-in-server.grit +48 -0
  132. package/lint/no-import-server-in-client.grit +45 -0
  133. package/lint/no-init-fetch-in-client.grit +47 -0
  134. package/lint/no-inline-color.grit +10 -8
  135. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  136. package/lint/no-model-type-in-util-zone.grit +58 -0
  137. package/lint/no-raw-palette-class.grit +3 -3
  138. package/lint/no-unpublished-form-setter.grit +41 -0
  139. package/lint/non-scalar-props-restricted.grit +16 -7
  140. package/linter.test.ts +80 -0
  141. package/linter.ts +99 -25
  142. package/package.json +6 -6
  143. package/prompter.ts +9 -4
  144. package/qualityScanner.test.ts +116 -0
  145. package/qualityScanner.ts +89 -21
  146. package/recipeScanner.ts +4 -1
  147. package/repoIdentity.ts +42 -0
  148. package/scanInfo.ts +32 -26
  149. package/semver.test.ts +26 -0
  150. package/semver.ts +31 -0
  151. package/slicePlanner.test.ts +151 -0
  152. package/slicePlanner.ts +157 -0
  153. package/ssrScanner.test.ts +301 -0
  154. package/transforms/asyncDefaultExportDetector.ts +103 -0
  155. package/transforms/barrelImportsPlugin.ts +1 -1
  156. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  157. package/transforms/transforms.test.ts +5 -5
  158. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  159. package/tsconfig.json +6 -2
  160. package/typeChecker.ts +1 -1
  161. package/types.ts +1 -0
  162. package/ui/ScrollList.tsx +6 -8
  163. package/uploadRelease.ts +2 -2
  164. package/workflow/executor.test.ts +146 -0
  165. package/workspaceLayout.test.ts +56 -4
  166. package/workspaceLayout.ts +49 -4
  167. package/builder.ts +0 -164
  168. package/extractDeps.ts +0 -86
  169. package/frontendBuild/styleGuard.test.ts +0 -165
  170. package/frontendBuild/styleGuard.ts +0 -322
  171. package/getCredentials.ts +0 -19
  172. package/getModelFileData.ts +0 -62
  173. package/src/capacitorApp.ts +0 -282
  174. package/streamAi.ts +0 -45
@@ -1,14 +1,18 @@
1
1
  import { mkdir, rm, unlink } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type { BaseBuildArtifact } from "akanjs/server";
4
- import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
4
+ import { type PageEntry, resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
5
5
  import type { App } from "../commandDecorators";
6
+ import { getPageKeyBasePath } from "./cssCompiler";
7
+ import { PagesBundleBuilder } from "./pagesBundleBuilder";
6
8
  import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
7
9
 
8
10
  export interface BuildCsrArtifactResult {
9
11
  outputDir: string;
10
12
  }
11
13
 
14
+ type CssAsset = NonNullable<BaseBuildArtifact["cssAssets"]>[string];
15
+
12
16
  export class CsrArtifactBuilder {
13
17
  #app: App;
14
18
  #command: "build" | "start";
@@ -29,31 +33,39 @@ export class CsrArtifactBuilder {
29
33
 
30
34
  const pageEntries = await resolveSsrPageEntriesForApp(this.#app, pageKeys);
31
35
  const akanConfig = await this.#app.getConfig();
32
- const artifact = await this.#loadCsrArtifact();
33
- const csrBasePaths = [...akanConfig.basePaths];
34
- const htmlEntries = csrBasePaths.length > 0 ? csrBasePaths : ["index"];
36
+ const cssAssets = await this.#loadCssAssets();
37
+ const basePaths = [...akanConfig.basePaths];
38
+ const htmlBasePaths = basePaths.length > 0 ? basePaths : [""];
35
39
  await rm(this.#outputDir, { recursive: true, force: true });
36
- await mkdir(path.join(this.#app.cwdPath, ".akan/generated/csr"), { recursive: true });
37
- const generatedHtmlFiles = Object.fromEntries(htmlEntries.map((basePath) => this.#createHtmlFile(basePath)));
40
+ await mkdir(this.#generatedDir, { recursive: true });
41
+ const generatedFiles = Object.fromEntries(
42
+ (
43
+ await Promise.all(
44
+ htmlBasePaths.map(async (basePath) => [
45
+ this.#createHtmlFile(basePath),
46
+ await this.#createEntryFile(
47
+ basePath,
48
+ CsrArtifactBuilder.pageEntriesForBasePath(pageEntries, basePath, basePaths),
49
+ ),
50
+ ]),
51
+ )
52
+ ).flat(),
53
+ );
38
54
 
39
55
  const result = await Bun.build({
40
56
  target: "browser",
41
- entrypoints: Object.keys(generatedHtmlFiles),
42
- files: {
43
- ...generatedHtmlFiles,
44
- [`${this.#app.cwdPath}/.akan/generated/csr/csr.tsx`]: `
45
- import { bootCsr } from "akanjs/webkit";
46
- ${PagesEntrySourceGenerator.generateStatic(pageEntries)}
47
- void bootCsr(pages);
48
- `,
49
- },
50
- root: `${this.#app.cwdPath}/.akan/generated/csr`,
57
+ entrypoints: htmlBasePaths.map((basePath) => this.#generatedPath(CsrArtifactBuilder.htmlFilename(basePath))),
58
+ files: generatedFiles,
59
+ root: this.#generatedDir,
51
60
  outdir: this.#outputDir,
52
61
  splitting: false,
53
62
  minify: true,
54
63
  env: "AKAN_PUBLIC_*",
55
64
  define: this.#define(),
56
65
  optimizeImports: akanConfig.optimizeImports,
66
+ // The base artifact's compiled sheet is the only stylesheet, as it is for SSR: a raw `.css` reached through
67
+ // the route graph is Tailwind source, and every root layout stylesheet in the graph would land in every HTML.
68
+ plugins: [PagesBundleBuilder.createCssStubPlugin()],
57
69
  });
58
70
 
59
71
  if (!result.success) {
@@ -61,11 +73,32 @@ void bootCsr(pages);
61
73
  throw new Error(`[csr-build] failed${logs ? `\n${logs}` : ""}`);
62
74
  }
63
75
 
64
- await this.#inlineCsrArtifacts(artifact.cssAssets ?? {});
76
+ await this.#inlineCsrArtifacts(cssAssets);
65
77
  this.#app.verbose(`[csr-build] output -> ${this.#outputDir}`);
66
78
  return { outputDir: this.#outputDir };
67
79
  }
68
80
 
81
+ /** The routes one basePath's HTML boots: its own plus every route outside any basePath, matching `bootCsr`. */
82
+ static pageEntriesForBasePath(pageEntries: PageEntry[], basePath: string, basePaths: string[]): PageEntry[] {
83
+ return pageEntries.filter((entry) => {
84
+ const entryBasePath = getPageKeyBasePath(entry.key, basePaths);
85
+ return entryBasePath === null || entryBasePath === basePath;
86
+ });
87
+ }
88
+
89
+ static htmlFilename(basePath: string): string {
90
+ return `${basePath || "index"}.html`;
91
+ }
92
+
93
+ static entryFilename(basePath: string): string {
94
+ return `${basePath || "index"}.csr.tsx`;
95
+ }
96
+
97
+ static basePathOfHtml(htmlPath: string): string {
98
+ const name = path.basename(htmlPath, ".html");
99
+ return name === "index" ? "" : name;
100
+ }
101
+
69
102
  get #outputDir(): string {
70
103
  return path.join(
71
104
  this.#command === "build" ? this.#app.dist.cwdPath : this.#app.cwdPath,
@@ -73,6 +106,18 @@ void bootCsr(pages);
73
106
  );
74
107
  }
75
108
 
109
+ get #generatedDir(): string {
110
+ return path.join(this.#app.cwdPath, ".akan/generated/csr");
111
+ }
112
+
113
+ get #artifactDir(): string {
114
+ return path.join(this.#command === "build" ? this.#app.dist.cwdPath : this.#app.cwdPath, ".akan/artifact");
115
+ }
116
+
117
+ #generatedPath(filename: string): string {
118
+ return path.join(this.#generatedDir, filename);
119
+ }
120
+
76
121
  #define(): Record<string, string> {
77
122
  const nodeEnv = this.#command === "build" ? "production" : (process.env.NODE_ENV ?? "development");
78
123
  return {
@@ -84,10 +129,19 @@ void bootCsr(pages);
84
129
  };
85
130
  }
86
131
 
132
+ async #createEntryFile(basePath: string, pageEntries: PageEntry[]): Promise<readonly [string, string]> {
133
+ return [
134
+ this.#generatedPath(CsrArtifactBuilder.entryFilename(basePath)),
135
+ `import { bootCsr } from "akanjs/webkit";
136
+ ${await PagesEntrySourceGenerator.generateStatic(pageEntries)}
137
+ void bootCsr(pages);
138
+ `,
139
+ ] as const;
140
+ }
141
+
87
142
  #createHtmlFile(basePath: string): readonly [string, string] {
88
- const filename = `${basePath}.html`;
89
143
  return [
90
- `${this.#app.cwdPath}/.akan/generated/csr/${filename}`,
144
+ this.#generatedPath(CsrArtifactBuilder.htmlFilename(basePath)),
91
145
  `<!doctype html>
92
146
  <html lang="${this.#lang}">
93
147
  <head>
@@ -98,41 +152,40 @@ void bootCsr(pages);
98
152
  </head>
99
153
  <body>
100
154
  <div id="root"></div>
101
- <script type="module" src="./csr.tsx"></script>
155
+ <script type="module" src="./${CsrArtifactBuilder.entryFilename(basePath)}"></script>
102
156
  </body>
103
157
  </html>
104
158
  `,
105
159
  ] as const;
106
160
  }
107
161
 
108
- async #loadCsrArtifact(): Promise<Pick<BaseBuildArtifact, "cssAssets">> {
109
- const artifactDir = path.join(
110
- this.#command === "build" ? this.#app.dist.cwdPath : this.#app.cwdPath,
111
- ".akan/artifact",
112
- );
113
- const artifactFile = Bun.file(path.join(artifactDir, "base-artifact.json"));
114
- if (!(await artifactFile.exists())) return { cssAssets: {} };
115
- const artifact = (await artifactFile.json()) as Pick<BaseBuildArtifact, "cssAssets">;
116
- return { cssAssets: artifact.cssAssets ?? {} };
162
+ async #loadCssAssets(): Promise<Record<string, CssAsset>> {
163
+ const artifactFile = Bun.file(path.join(this.#artifactDir, "base-artifact.json"));
164
+ if (!(await artifactFile.exists())) return {};
165
+ const artifact = (await artifactFile.json()) as Partial<Pick<BaseBuildArtifact, "cssAssets">>;
166
+ return artifact.cssAssets ?? {};
117
167
  }
118
168
 
119
- async #inlineCsrArtifacts(cssAssets: Record<string, { cssUrl: string; cssRelPath: string }>): Promise<void> {
169
+ async #inlineCsrArtifacts(cssAssets: Record<string, CssAsset>): Promise<void> {
120
170
  const jsFiles = new Set<string>();
121
- const cssFiles = new Set<string>();
122
171
  for (const htmlPath of await this.#htmlOutputPaths()) {
123
172
  const htmlFile = Bun.file(htmlPath);
124
173
  if (!(await htmlFile.exists())) continue;
125
- const basePath = path.basename(htmlPath, ".html") === "index" ? "" : path.basename(htmlPath, ".html");
126
- const inlined = await this.#inlineHtmlAssets(await htmlFile.text(), htmlPath, cssAssets[basePath]);
174
+ const basePath = CsrArtifactBuilder.basePathOfHtml(htmlPath);
175
+ const cssAsset = cssAssets[basePath];
176
+ if (!cssAsset) {
177
+ this.#app.logger.warn(
178
+ `[csr-build] base-artifact.json has no compiled stylesheet for ${basePath || "root"}; ${path.basename(htmlPath)} ships without CSS`,
179
+ );
180
+ }
181
+ const inlined = await this.#inlineHtmlAssets(await htmlFile.text(), htmlPath, cssAsset);
127
182
  for (const filePath of inlined.jsFiles) jsFiles.add(filePath);
128
- for (const filePath of inlined.cssFiles) cssFiles.add(filePath);
129
183
  await Bun.write(htmlPath, inlined.html);
130
184
  }
131
185
  for (const filePath of jsFiles) await unlink(filePath).catch(() => undefined);
132
- for (const filePath of cssFiles) await unlink(filePath).catch(() => undefined);
133
- const remainingJs = await this.#listOutputFiles((filePath) => filePath.endsWith(".js"));
134
- const remainingCss = await this.#listOutputFiles((filePath) => filePath.endsWith(".css"));
135
- const remainingAssets = [...remainingJs, ...remainingCss];
186
+ const remainingAssets = await this.#listOutputFiles(
187
+ (filePath) => filePath.endsWith(".js") || filePath.endsWith(".css"),
188
+ );
136
189
  if (remainingAssets.length > 0) {
137
190
  throw new Error(`[csr-build] expected single-file HTML, but CSR assets remain:\n${remainingAssets.join("\n")}`);
138
191
  }
@@ -141,44 +194,20 @@ void bootCsr(pages);
141
194
  async #inlineHtmlAssets(
142
195
  html: string,
143
196
  htmlPath: string,
144
- cssAsset?: { cssUrl: string; cssRelPath: string },
145
- ): Promise<{ html: string; jsFiles: string[]; cssFiles: string[] }> {
197
+ cssAsset?: CssAsset,
198
+ ): Promise<{ html: string; jsFiles: string[] }> {
199
+ let next = html;
200
+ if (cssAsset) {
201
+ const css = await Bun.file(path.join(this.#artifactDir, cssAsset.cssRelPath)).text();
202
+ next = CsrArtifactBuilder.injectBeforeHeadEnd(next, CsrArtifactBuilder.createInlineStyle(css));
203
+ }
146
204
  const jsFiles: string[] = [];
147
- const cssFiles = CsrArtifactBuilder.collectStylesheetHrefs(html).map((href) =>
148
- CsrArtifactBuilder.resolveHtmlAssetPath(htmlPath, href),
149
- );
150
- let next = CsrArtifactBuilder.stripBundledStylesheetLinks(html);
151
205
  next = await CsrArtifactBuilder.replaceModuleScriptSrc(next, async (src) => {
152
206
  const jsPath = CsrArtifactBuilder.resolveHtmlAssetPath(htmlPath, src);
153
207
  jsFiles.push(jsPath);
154
208
  return await Bun.file(jsPath).text();
155
209
  });
156
- const bundledCss = (
157
- await Promise.all(
158
- cssFiles.map((cssFile) =>
159
- Bun.file(cssFile)
160
- .text()
161
- .catch(() => ""),
162
- ),
163
- )
164
- )
165
- .filter(Boolean)
166
- .join("\n");
167
- if (bundledCss) {
168
- const style = CsrArtifactBuilder.createInlineStyle(bundledCss);
169
- if (!next.includes(style)) next = CsrArtifactBuilder.injectBeforeHeadEnd(next, style);
170
- }
171
- if (cssAsset) {
172
- const cssPath = path.join(
173
- this.#command === "build" ? this.#app.dist.cwdPath : this.#app.cwdPath,
174
- ".akan/artifact",
175
- cssAsset.cssRelPath,
176
- );
177
- const css = await Bun.file(cssPath).text();
178
- const style = CsrArtifactBuilder.createInlineStyle(css);
179
- if (!next.includes(style)) next = CsrArtifactBuilder.injectBeforeHeadEnd(next, style);
180
- }
181
- return { html: next, jsFiles, cssFiles };
210
+ return { html: next, jsFiles };
182
211
  }
183
212
 
184
213
  async #htmlOutputPaths(): Promise<string[]> {
@@ -194,23 +223,26 @@ void bootCsr(pages);
194
223
  return files.sort();
195
224
  }
196
225
 
226
+ /**
227
+ * Bun's HTML bundler hoists the module script into `<head>`, so once that script is inline its source is part
228
+ * of the text being searched — and a React bundle contains `<body` and `</head>` as strings. Positions are
229
+ * taken on a copy with script, style and comment bodies blanked, and the snippet always lands after whatever
230
+ * was injected before it: prepending would reverse the cascade order the caller chose.
231
+ */
197
232
  static injectBeforeHeadEnd(html: string, snippet: string): string {
198
- const matches = [...html.matchAll(/<\/head\s*>/gi)];
199
- const bodyStart = html.search(/<body(?:\s|>)/i);
200
- const headEnd = matches
201
- .filter((match) => match.index !== undefined && (bodyStart === -1 || match.index < bodyStart))
202
- .at(-1);
203
- if (!headEnd || headEnd.index === undefined) return `${snippet}\n${html}`;
204
- return `${html.slice(0, headEnd.index)}${snippet}\n${html.slice(headEnd.index)}`;
205
- }
206
-
207
- static stripBundledStylesheetLinks(html: string): string {
208
- return html.replace(/<link\b(?=[^>]*\brel=["']stylesheet["'])[^>]*>\s*/gi, "");
233
+ const scannable = CsrArtifactBuilder.blankEmbeddedContent(html);
234
+ const headEnd = scannable.search(/<\/head\s*>/i);
235
+ if (headEnd !== -1) return `${html.slice(0, headEnd)}${snippet}\n${html.slice(headEnd)}`;
236
+ const bodyStart = scannable.search(/<body(?:\s|>)/i);
237
+ if (bodyStart !== -1) return `${html.slice(0, bodyStart)}${snippet}\n${html.slice(bodyStart)}`;
238
+ return `${html}\n${snippet}`;
209
239
  }
210
240
 
211
- static collectStylesheetHrefs(html: string): string[] {
212
- const linkRe = /<link\b(?=[^>]*\brel=["']stylesheet["'])(?=[^>]*\bhref=["']([^"']+)["'])[^>]*>/gi;
213
- return [...html.matchAll(linkRe)].map((match) => match[1]).filter((href): href is string => !!href);
241
+ static blankEmbeddedContent(html: string): string {
242
+ return html.replace(
243
+ /<script\b[^>]*>[\s\S]*?<\/script\s*>|<style\b[^>]*>[\s\S]*?<\/style\s*>|<!--[\s\S]*?-->/gi,
244
+ (match) => " ".repeat(match.length),
245
+ );
214
246
  }
215
247
 
216
248
  static createInlineStyle(css: string): string {
@@ -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
- this.#cssText = await this.compileCss(cssPaths, sourcePaths);
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.compileCss(cssPaths, sourcePaths);
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.compileCss(cssPaths, sourcePaths);
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
- return { cssPaths: [...cssFiles], sourcePaths: [...sourceFiles] };
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
- if (cssPaths.length === 0) return "";
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
- parts.push(entry.compiler.build(candidates));
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("/")) return path.resolve(fromBase, id);
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,7 +430,18 @@ export function isIgnoredNodeModuleSource(filePath: string): boolean {
330
430
  return NODE_MODULES_RE.test(filePath) && !AKANJS_NODE_MODULE_RE.test(filePath);
331
431
  }
332
432
 
333
- function getPageKeyBasePath(pageKey: string, basePaths: string[]): string | null {
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
+
444
+ export function getPageKeyBasePath(pageKey: string, basePaths: string[]): string | null {
334
445
  const normalized = pageKey.split(path.sep).join("/").replace(/^\.\//, "");
335
446
  const segments = normalized.split("/");
336
447
  const firstPublicSegment = segments.find((segment) => segment !== "[lang]" && !/^\(.+\)$/.test(segment));
@@ -106,14 +106,15 @@ export class CssImportResolver {
106
106
  const pkg = await Bun.file(pkgPath).json();
107
107
  const subpath = id === pkgName ? "." : `.${id.slice(pkgName.length)}`;
108
108
  const exportValue = pkg.exports?.[subpath];
109
- const styleEntry =
110
- (typeof exportValue === "string"
109
+ const exportedEntry =
110
+ typeof exportValue === "string"
111
111
  ? exportValue
112
- : exportValue?.style || exportValue?.import || exportValue?.default) ||
113
- pkg.exports?.["."]?.style ||
114
- pkg.style ||
115
- "index.css";
116
- return await this.#firstExisting(path.resolve(pkgDir, styleEntry));
112
+ : exportValue?.style || exportValue?.import || exportValue?.default;
113
+ if (exportedEntry) return await this.#firstExisting(path.resolve(pkgDir, exportedEntry));
114
+ //* A subpath names a file inside the package, so it resolves literally. Falling back to the package's own
115
+ //* style entry here would load a different stylesheet than the author asked for and report success.
116
+ if (subpath !== ".") return await this.#firstExisting(path.resolve(pkgDir, subpath));
117
+ return await this.#firstExisting(path.resolve(pkgDir, pkg.exports?.["."]?.style || pkg.style || "index.css"));
117
118
  } catch {
118
119
  return null;
119
120
  }
@@ -43,7 +43,9 @@ export class FontOptimizer {
43
43
  #woff2Ready: Promise<void> | null = null;
44
44
 
45
45
  static #ksX1001Text: string | null = null;
46
- static readonly #cacheVersion = 1;
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.#hashFontConfig(await this.#collectAutoSubsetText()) });
101
+ sources.push({ autoSubsetText: this.#cacheDigest(await this.#collectAutoSubsetText()) });
100
102
  }
101
- return this.#hashFontConfig({ version: FontOptimizer.#cacheVersion, fonts, sources });
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
- await Promise.all(
397
- roots.map(async (root) => {
398
- if (
399
- !(await stat(root).then(
400
- (entry) => entry.isDirectory(),
401
- () => false,
402
- ))
403
- )
404
- return;
405
- for await (const filePath of glob.scan({ cwd: root, absolute: true })) {
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