@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
@@ -9,6 +9,7 @@ export * from "./cssImportResolver";
9
9
  export * from "./devChangePlanner";
10
10
  export * from "./devGeneratedIndexSync";
11
11
  export * from "./fontOptimizer";
12
+ export * from "./fontPruner";
12
13
  export * from "./hmrChangeClassifier";
13
14
  export * from "./hmrWatcher";
14
15
  export * from "./pagesBundleBuilder";
@@ -19,7 +20,6 @@ export * from "./routesManifestArtifactSerializer";
19
20
  export * from "./sourceMtimeIndex";
20
21
  export * from "./ssrBaseArtifactBuilder";
21
22
  export * from "./styleContract";
22
- export * from "./styleGuard";
23
23
  export * from "./themeValidator";
24
24
  export * from "./vendorSpecifiers";
25
25
  export * from "./watchRootResolver";
@@ -64,7 +64,7 @@ export class PagesBundleBuilder {
64
64
  define: this.#define(),
65
65
  plugins: [
66
66
  PagesBundleBuilder.createPagesEntryPlugin(entrySource),
67
- PagesBundleBuilder.createServerCssStubPlugin(),
67
+ PagesBundleBuilder.createCssStubPlugin(),
68
68
  PagesBundleBuilder.createServerUseClientFetchPlugin(),
69
69
  await createExternalizeFrameworkPlugin({ app: this.#app, extra: akanConfig.externalLibs }),
70
70
  akanConfig.barrelImports.length > 0
@@ -137,9 +137,9 @@ export class PagesBundleBuilder {
137
137
  };
138
138
  }
139
139
 
140
- static createServerCssStubPlugin(): BunPlugin {
140
+ static createCssStubPlugin(): BunPlugin {
141
141
  return {
142
- name: "akan-server-css-stub",
142
+ name: "akan-css-stub",
143
143
  setup(build) {
144
144
  build.onLoad({ filter: /\.css$/ }, () => ({
145
145
  contents: "",
@@ -1,7 +1,6 @@
1
- import fs from "node:fs";
2
1
  import path from "node:path";
3
- import ts from "typescript";
4
2
  import type { PageEntry } from "../artifact/implicitRootLayout";
3
+ import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
5
4
 
6
5
  export class PagesEntrySourceGenerator {
7
6
  #pageEntries: PageEntry[];
@@ -22,101 +21,25 @@ export class PagesEntrySourceGenerator {
22
21
  return `export const pages = {\n${lines.join("\n")}\n};\n`;
23
22
  }
24
23
 
25
- static generateStatic(pageEntries: PageEntry[]): string {
26
- return new PagesEntrySourceGenerator(pageEntries).generateStatic();
24
+ static async generateStatic(pageEntries: PageEntry[]): Promise<string> {
25
+ return await new PagesEntrySourceGenerator(pageEntries).generateStatic();
27
26
  }
28
27
 
29
- generateStatic(): string {
28
+ async generateStatic(): Promise<string> {
30
29
  const imports = this.#pageEntries.map(({ moduleAbsPath }, index) => {
31
30
  const specifier = PagesEntrySourceGenerator.#toImportSpecifier(moduleAbsPath);
32
31
  return `import * as page${index} from ${JSON.stringify(specifier)};`;
33
32
  });
34
- const entries = this.#pageEntries.map(({ key, moduleAbsPath }, index) => {
35
- const isAsyncDefault = PagesEntrySourceGenerator.#hasAsyncDefaultExport(moduleAbsPath);
36
- return ` ${JSON.stringify(key)}: { loader: async () => page${index}, isAsyncDefault: ${isAsyncDefault} },`;
37
- });
33
+ const entries = await Promise.all(
34
+ this.#pageEntries.map(async ({ key, moduleAbsPath }, index) => {
35
+ const isAsyncDefault = await AsyncDefaultExportDetector.detect(moduleAbsPath);
36
+ return ` ${JSON.stringify(key)}: { loader: async () => page${index}, isAsyncDefault: ${isAsyncDefault} },`;
37
+ }),
38
+ );
38
39
  return `${imports.join("\n")}\nexport const pages = {\n${entries.join("\n")}\n};\n`;
39
40
  }
41
+
40
42
  static #toImportSpecifier(moduleAbsPath: string): string {
41
43
  return path.resolve(moduleAbsPath).split(path.sep).join("/");
42
44
  }
43
-
44
- static #hasAsyncDefaultExport(moduleAbsPath: string): boolean {
45
- try {
46
- const source = fs.readFileSync(path.resolve(moduleAbsPath), "utf8");
47
- const sourceFile = ts.createSourceFile(
48
- moduleAbsPath,
49
- source,
50
- ts.ScriptTarget.Latest,
51
- true,
52
- PagesEntrySourceGenerator.#scriptKind(moduleAbsPath),
53
- );
54
- return PagesEntrySourceGenerator.#sourceFileHasAsyncDefaultExport(sourceFile);
55
- } catch {
56
- return false;
57
- }
58
- }
59
-
60
- static #sourceFileHasAsyncDefaultExport(sourceFile: ts.SourceFile): boolean {
61
- const asyncBindings = new Map<string, boolean>();
62
- let defaultIdentifier: string | null = null;
63
-
64
- for (const statement of sourceFile.statements) {
65
- if (ts.isFunctionDeclaration(statement)) {
66
- if (PagesEntrySourceGenerator.#hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) {
67
- return PagesEntrySourceGenerator.#hasModifier(statement, ts.SyntaxKind.AsyncKeyword);
68
- }
69
- if (statement.name) {
70
- asyncBindings.set(
71
- statement.name.text,
72
- PagesEntrySourceGenerator.#hasModifier(statement, ts.SyntaxKind.AsyncKeyword),
73
- );
74
- }
75
- continue;
76
- }
77
-
78
- if (ts.isVariableStatement(statement)) {
79
- for (const declaration of statement.declarationList.declarations) {
80
- if (!ts.isIdentifier(declaration.name)) continue;
81
- asyncBindings.set(
82
- declaration.name.text,
83
- PagesEntrySourceGenerator.#isAsyncFunctionExpression(declaration.initializer),
84
- );
85
- }
86
- continue;
87
- }
88
-
89
- if (ts.isExportAssignment(statement)) {
90
- if (PagesEntrySourceGenerator.#isAsyncFunctionExpression(statement.expression)) return true;
91
- if (ts.isIdentifier(statement.expression)) defaultIdentifier = statement.expression.text;
92
- continue;
93
- }
94
-
95
- if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
96
- const exportClause = statement.exportClause;
97
- for (const specifier of exportClause.elements) {
98
- if (specifier.name.text !== "default") continue;
99
- defaultIdentifier = specifier.propertyName?.text ?? specifier.name.text;
100
- }
101
- }
102
- }
103
-
104
- return defaultIdentifier ? asyncBindings.get(defaultIdentifier) === true : false;
105
- }
106
-
107
- static #hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean {
108
- return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) ?? false);
109
- }
110
-
111
- static #isAsyncFunctionExpression(node?: ts.Expression): boolean {
112
- return Boolean(
113
- node &&
114
- (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
115
- PagesEntrySourceGenerator.#hasModifier(node, ts.SyntaxKind.AsyncKeyword),
116
- );
117
- }
118
-
119
- static #scriptKind(moduleAbsPath: string): ts.ScriptKind {
120
- return moduleAbsPath.endsWith(".tsx") || moduleAbsPath.endsWith(".jsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
121
- }
122
45
  }
@@ -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";
@@ -163,12 +163,11 @@ export class RouteClientBuilder {
163
163
  }
164
164
 
165
165
  async #buildSsrBundle(bootstrapEntries: BootstrapEntries) {
166
- const externalOptions = RouteClientBuilder.resolveSsrClientExternalOptions(this.#command);
167
166
  return new ClientEntriesBundler({
168
167
  app: this.#app,
169
168
  entries: bootstrapEntries.buildEntries,
170
169
  plugins: [await createBarrelImportsPlugin(this.#app)],
171
- ...externalOptions,
170
+ ...RouteClientBuilder.resolveSsrClientBundleOptions(this.#command),
172
171
  outputSubdir: "client-ssr",
173
172
  command: this.#command,
174
173
  }).bundle();
@@ -239,20 +238,28 @@ export class RouteClientBuilder {
239
238
  return { [Bun.resolveSync("akanjs/fetch", serverEntry)]: "akanjs/fetch" };
240
239
  }
241
240
 
242
- static resolveSsrClientExternalOptions(command: "build" | "start"): {
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;
243
249
  external: readonly string[];
244
250
  externalSubpaths?: readonly string[];
245
251
  externalAliases?: Record<string, string>;
246
252
  } {
247
253
  if (command === "start") {
248
254
  return {
255
+ target: "bun",
249
256
  external: SSR_CLIENT_EXTERNALS,
250
257
  externalSubpaths: ["akanjs/fetch"],
251
258
  externalAliases: RouteClientBuilder.resolveSsrClientRuntimeAliases(),
252
259
  };
253
260
  }
254
261
 
255
- return { external: SSR_CLIENT_ALIAS_EXTERNALS };
262
+ return { target: "bun", external: SSR_CLIENT_ALIAS_EXTERNALS };
256
263
  }
257
264
 
258
265
  static resolveAkanServerEntry(): string {
@@ -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.resolveSsrClientExternalOptions(this.#command),
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
- * styleGuard + themeValidator 결과를 배선(build/dev/lint)이 공유하는 포맷으로 정리한다.
3
- * severity 사다리: style 위반은 severity==="error" 인 것만, theme 위반은 전부 차단(build/CI) 대상.
4
- * dev 결과를 경고로만 출력한다.
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 = (v: StyleContractViolations): number =>
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 = (v: StyleContractViolations): string => {
18
- const lines: string[] = [];
19
- for (const s of v.style) {
20
- lines.push(` [${s.severity}] ${s.rule} ${s.path}:${s.line}`);
21
- lines.push(` ${s.snippet}`);
22
- lines.push(` → ${s.suggestion}`);
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
- * themeValidator 시맨틱 토큰 페어의 WCAG 콘트라스트 자동 검사 (의존성 0).
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
- * 토큰 구조(`--x` `--x-foreground`)라서 가능해진 검사. AI 생성한 팔레트가 "안 읽히는 사이트"가
5
- * 되는 것을 원천 차단한다. akanjs 런타임을 import 하지 않는다 순수 함수만.
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
- * 임계값(WCAG 2.1):
8
- * - 본문/주요 표면 페어(background·primary·secondary·accent·neutral·card·popover): 4.5:1 (AA normal text)
9
- * - 상태/보조 페어(info·success·warning·destructive·open·muted): 3:1 (UI 컴포넌트 / 큰 텍스트 / 보조 표면)
10
- * 현행 styles.css 기본 팔레트(light/dark) 임계값을 모두 통과한다.
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
- // 검사 대상 스코프. 셀렉터(.campaign-x 스코프 토큰 등) 페어 검사에서 제외.
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
- /** CSS 문자열에서 토큰을 추출해 알려진 테마 스코프 전체를 검사한다. */
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()/비-hex 값은 검사 불가 건너뜀
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: `${scope} --${base}(${bg}) --${fg}(${front}) 대비가 ${ratio.toFixed(2)}:1 최소 ${threshold}:1 미만입니다. 한쪽을 밝게/어둡게 조정해 대비를 확보하세요.`,
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="…"]` 블록에서 `--token: value` 를 파싱한다. 그룹 셀렉터
88
- * (`:root, [data-theme="dark"] { … }`) 셀렉터에 동일 토큰을 분배. 동일 스코프 재등장 나중 값이 이긴다
89
- * (프레임워크-먼저 / 앱-나중 순서로 넘기면 override 반영됨).
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
- // 중첩 없는 단순 규칙 블록만 매칭(@theme/@keyframes at-rule 셀렉터에 @ 포함이라 제외).
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
- // 따옴표 정규화: [data-theme=dark] / [data-theme='dark'] [data-theme="dark"]
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 [r,g,b] (alpha 무시). 비-hex null. */
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;
@@ -11,6 +11,7 @@ export const VENDOR_SPECIFIERS = [
11
11
  "akanjs/base",
12
12
  "akanjs/common",
13
13
  "akanjs/constant",
14
+ "akanjs/fetch",
14
15
  ] as const;
15
16
 
16
17
  export type VendorSpecifier = (typeof VENDOR_SPECIFIERS)[number];
@@ -22,27 +22,25 @@ describe("prepareDevWatchBatch", () => {
22
22
  expect(prepared.event.devPlan?.files).toEqual(prepared.files);
23
23
  });
24
24
 
25
- test.each([
26
- "common",
27
- "srvkit",
28
- "ui",
29
- "webkit",
30
- ])("keeps %s facet add/delete generated index in the same generation", (facet) => {
31
- const root = "/repo";
32
- const changedFile = `${root}/libs/shared/${facet}/tmpExample.ts`;
33
- const generatedIndex = `${root}/libs/shared/${facet}/index.ts`;
34
- const prepared = prepareDevWatchBatch({
35
- generation: 20,
36
- batch: { files: [changedFile], kinds: new Set(["code"]) },
37
- indexSync: { changedFiles: [generatedIndex], errors: [] },
38
- changePlanner: new DevChangePlanner({ workspaceRoot: root }),
39
- });
25
+ test.each(["common", "srvkit", "ui", "webkit"])(
26
+ "keeps %s facet add/delete generated index in the same generation",
27
+ (facet) => {
28
+ const root = "/repo";
29
+ const changedFile = `${root}/libs/shared/${facet}/tmpExample.ts`;
30
+ const generatedIndex = `${root}/libs/shared/${facet}/index.ts`;
31
+ const prepared = prepareDevWatchBatch({
32
+ generation: 20,
33
+ batch: { files: [changedFile], kinds: new Set(["code"]) },
34
+ indexSync: { changedFiles: [generatedIndex], errors: [] },
35
+ changePlanner: new DevChangePlanner({ workspaceRoot: root }),
36
+ });
40
37
 
41
- expect(new Set(prepared.files)).toEqual(new Set([changedFile, generatedIndex]));
42
- expect(prepared.event.devPlan?.generatedFiles).toEqual([generatedIndex]);
43
- expect(prepared.event.devPlan?.roles).toContain("barrel");
44
- expect(prepared.event.devPlan?.actions).toContain("sync-generated");
45
- });
38
+ expect(new Set(prepared.files)).toEqual(new Set([changedFile, generatedIndex]));
39
+ expect(prepared.event.devPlan?.generatedFiles).toEqual([generatedIndex]);
40
+ expect(prepared.event.devPlan?.roles).toContain("barrel");
41
+ expect(prepared.event.devPlan?.actions).toContain("sync-generated");
42
+ },
43
+ );
46
44
 
47
45
  test("marks failed generated index sync as an error generation", () => {
48
46
  const root = "/repo";
@@ -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
- devPlan.actions = [...devPlan.actions, "report-error"].sort();
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
  };
@@ -200,7 +200,7 @@ export class IncrementalBuilderHost {
200
200
  // one at a time, into the dev error page a recycle is supposed to be invisible to. `ready` the
201
201
  // field is deliberately untouched: `onExit` reads it to tell a planned exit from a boot failure.
202
202
  this.#status = "recycling";
203
- this.logger.info(`recycling builder pid=${proc.pid} (${reason})`);
203
+ this.logger.debug(`recycling builder pid=${proc.pid} (${reason})`);
204
204
  this.#recycleTimer = setTimeout(() => {
205
205
  this.#recycleTimer = null;
206
206
  if (this.#proc !== proc) return;
@@ -185,7 +185,12 @@ class IncrementalBuilder {
185
185
  if (this.#shuttingDown) return;
186
186
  this.#shuttingDown = true;
187
187
  const started = Date.now();
188
- this.#logger.info(`shutdown requested (${reason}); draining ${this.#inFlight} work item(s)`);
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.
@@ -200,7 +205,7 @@ class IncrementalBuilder {
200
205
  // flushed — a `css-updated` relayed milliseconds before this line would be dropped with no error
201
206
  // anywhere, leaving the backend serving the previous bundle. See `BuilderChannel`.
202
207
  const flushed = await BuilderChannel.drain();
203
- this.#logger.info(
208
+ this.#logger.debug(
204
209
  `drained in ${Date.now() - started}ms${flushed ? ` after flushing ${flushed} ipc write(s)` : ""}; exiting for recycle`,
205
210
  );
206
211
  process.exit(0);
@@ -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";
@@ -118,20 +118,12 @@ export default config;
118
118
  ),
119
119
  this.writeFile(
120
120
  "env/env.client.ts",
121
- `import { getEnv } from "akanjs/base";
122
-
123
- export const env = {
124
- ...getEnv(),
125
- } as const;
121
+ `export const env = {} as const;
126
122
  `,
127
123
  ),
128
124
  this.writeFile(
129
125
  "env/env.server.ts",
130
- `import { getEnv } from "akanjs/base";
131
-
132
- export const env = {
133
- ...getEnv(),
134
- } as const;
126
+ `export const env = {} as const;
135
127
  `,
136
128
  ),
137
129
  this.writeFile(