@akanjs/devkit 3.0.0-alpha.12 → 3.0.0-alpha.14

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/executors.test.ts CHANGED
@@ -760,6 +760,29 @@ describe("Workspace and app executor environment contracts", () => {
760
760
  expect(offsetMinimalStart.env.AKAN_PUBLIC_CLIENT_PORT).toBe("8286");
761
761
  expect(offsetMinimalStart.env.AKAN_PUBLIC_SERVER_PORT).toBe("8286");
762
762
  });
763
+
764
+ test("pins the start command to development so an ambient NODE_ENV cannot pick the production router", async () => {
765
+ const root = await makeTempRoot();
766
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
767
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
768
+ process.env.AKAN_PUBLIC_ENV = "local";
769
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
770
+ await mkdir(path.join(root, "apps/ambient"), { recursive: true });
771
+ await writeFile(path.join(root, "apps/ambient/akan.config.ts"), "export default {};\n");
772
+
773
+ const originalNodeEnv = process.env.NODE_ENV;
774
+ process.env.NODE_ENV = "production";
775
+ try {
776
+ const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
777
+ const started = await AppExecutor.from(workspace, "ambient").prepareCommand("start");
778
+ expect(started.env.NODE_ENV).toBe("development");
779
+ // The builder runs in this process and bakes NODE_ENV into the dev bundles it emits.
780
+ expect(process.env.NODE_ENV).toBe("development");
781
+ } finally {
782
+ if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
783
+ else process.env.NODE_ENV = originalNodeEnv;
784
+ }
785
+ });
763
786
  });
764
787
 
765
788
  describe("PkgExecutor package generation", () => {
package/executors.ts CHANGED
@@ -1364,6 +1364,12 @@ export class AppExecutor extends SysExecutor {
1364
1364
  this.cp("public", `${this.dist.cwdPath}/public`, { dereference: true }),
1365
1365
  ]);
1366
1366
  } else await this.removeDir(".akan");
1367
+ //? `akan start` is the dev server, and it must say so rather than inherit an answer. Bun auto-loads the
1368
+ //? workspace `.env`, so NODE_ENV=production can reach this process without ever being exported in a shell —
1369
+ //? and a dev server that believes it is production serves from the production route cache, whose every
1370
+ //? route throws because a dev artifact carries no routes manifest. Written into `process.env` and not only
1371
+ //? into the child env because the builder runs here and bakes NODE_ENV into the bundles it emits.
1372
+ if (type === "start") process.env.NODE_ENV = "development";
1367
1373
  const devPort = type === "start" ? (await this.getDevPort()).toString() : undefined;
1368
1374
  const env = this.getCommandEnv({
1369
1375
  AKAN_COMMAND_TYPE: type,
@@ -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" });
@@ -48,8 +54,11 @@ export class CssCompiler {
48
54
  #resolvedSpecifierCache = new Map<string, Promise<string | null>>();
49
55
  /** Every stylesheet this compile reached, entry points and `@import` targets alike. */
50
56
  #discoveredCssPaths = new Set<string>();
51
- /** `@import` targets of the compile in progress, by path, so the output can be checked against them. */
52
- #importedStylesheets = new Map<string, 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[]> = {};
53
62
 
54
63
  #fileExists(absPath: string): Promise<boolean> {
55
64
  let cached = this.#fileExistsCache.get(absPath);
@@ -81,8 +90,11 @@ export class CssCompiler {
81
90
  async getCss({ refresh }: { refresh?: boolean } = {}) {
82
91
  if (this.#cssText !== null && !refresh) return this.#cssText;
83
92
  this.#discoveredCssPaths.clear();
93
+ this.importedStylesheetsByBasePath = {};
84
94
  const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh });
85
- this.#cssText = await this.compileCss(cssPaths, sourcePaths);
95
+ const { css, imported } = await this.#compileWithImports(cssPaths, sourcePaths);
96
+ this.#cssText = css;
97
+ this.importedStylesheetsByBasePath = { "": imported };
86
98
  await this.#warnUnreachableStylesheets();
87
99
  return this.#cssText;
88
100
  }
@@ -90,6 +102,7 @@ export class CssCompiler {
90
102
  async getCssByBasePath({ refresh }: { refresh?: boolean } = {}): Promise<Record<string, string>> {
91
103
  if (this.#cssTextByBasePath !== null && !refresh) return this.#cssTextByBasePath;
92
104
  this.#discoveredCssPaths.clear();
105
+ this.importedStylesheetsByBasePath = {};
93
106
  const akanConfig = await this.#app.getConfig({ refresh });
94
107
  const pageKeys = await this.#app.getPageKeys({ refresh });
95
108
  const basePaths = [...akanConfig.basePaths];
@@ -99,7 +112,8 @@ export class CssCompiler {
99
112
  if (rootPageKeys.length === 0) return ["", ""] as const;
100
113
  const started = Date.now();
101
114
  const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh, pageKeys: rootPageKeys });
102
- const css = await this.compileCss(cssPaths, sourcePaths);
115
+ const { css, imported } = await this.#compileWithImports(cssPaths, sourcePaths);
116
+ this.importedStylesheetsByBasePath[""] = imported;
103
117
  this.#logger.verbose(
104
118
  `css base=root paths=${cssPaths.length} sources=${sourcePaths.length} in ${Date.now() - started}ms`,
105
119
  );
@@ -110,7 +124,8 @@ export class CssCompiler {
110
124
  if (basePathPageKeys.length === 0) return [basePath, ""] as const;
111
125
  const started = Date.now();
112
126
  const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh, pageKeys: basePathPageKeys });
113
- const css = await this.compileCss(cssPaths, sourcePaths);
127
+ const { css, imported } = await this.#compileWithImports(cssPaths, sourcePaths);
128
+ this.importedStylesheetsByBasePath[basePath] = imported;
114
129
  this.#logger.verbose(
115
130
  `css base=${basePath} paths=${cssPaths.length} sources=${sourcePaths.length} in ${Date.now() - started}ms`,
116
131
  );
@@ -233,20 +248,32 @@ export class CssCompiler {
233
248
  return tokenPaths.filter((tokensPath): tokensPath is string => !!tokensPath);
234
249
  }
235
250
  async compileCss(cssPaths: string[], sourcePaths: string[]): Promise<string> {
236
- 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: [] };
237
264
 
238
- this.#importedStylesheets.clear();
239
265
  const compileStarted = Date.now();
240
266
  const compilers = await Promise.all(
241
267
  cssPaths.map(async (cssPath) => {
242
268
  const css = await Bun.file(cssPath).text();
243
269
  const base = path.dirname(cssPath);
270
+ const imported = new Map<string, string>();
244
271
  const compiler = await compile(css, {
245
272
  base,
246
- loadStylesheet: (id, fromBase) => this.#loadStylesheet(id, fromBase),
273
+ loadStylesheet: (id, fromBase) => this.#loadStylesheet(id, fromBase, imported),
247
274
  loadModule: (id, fromBase) => this.#loadModule(id, fromBase),
248
275
  });
249
- return { cssPath, compiler };
276
+ return { cssPath, compiler, imported };
250
277
  }),
251
278
  );
252
279
 
@@ -261,39 +288,31 @@ export class CssCompiler {
261
288
  `css candidates scanned count=${candidates.length} sources=${sourcePaths.length} dirs=${sourceDirs.size} in ${Date.now() - scanStarted}ms`,
262
289
  );
263
290
  const parts: string[] = [];
291
+ const imported: ImportedStylesheet[] = [];
264
292
  for (const entry of compilers) {
265
293
  if (!entry) continue;
266
- 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
+ }
267
304
  }
268
305
  this.#logger.verbose(
269
306
  `css compiled paths=${cssPaths.length} candidates=${candidates.length} in ${Date.now() - compileStarted}ms`,
270
307
  );
271
- const css = parts.join("\n");
272
- this.#warnDroppedImports(css);
273
- return css;
274
- }
275
-
276
- /**
277
- * An `@import` can resolve, be read, and still contribute nothing — the one failure the existence check
278
- * cannot see, and the one that reads as an unstyled component rather than as an error. Custom properties
279
- * declared outside `@theme` are the tell: those pass through verbatim, so a stylesheet whose every
280
- * declaration is missing from the build did not make it in.
281
- */
282
- #warnDroppedImports(css: string) {
283
- for (const [cssPath, content] of this.#importedStylesheets) {
284
- const names = declaredCustomProperties(content);
285
- if (names.length === 0 || names.some((name) => css.includes(`${name}:`))) continue;
286
- this.#logger.warn(
287
- `css @import ${cssPath} was loaded but none of its ${names.length} declaration(s) reached the compiled CSS`,
288
- );
289
- }
308
+ return { css: parts.join("\n"), imported };
290
309
  }
291
310
 
292
- async #loadStylesheet(id: string, fromBase: string) {
311
+ async #loadStylesheet(id: string, fromBase: string, imported?: Map<string, string>) {
293
312
  const p = await this.#resolveCssImport(id, fromBase);
294
313
  this.#discoveredCssPaths.add(p);
295
314
  const content = await Bun.file(p).text();
296
- this.#importedStylesheets.set(p, content);
315
+ imported?.set(p, content);
297
316
  this.#logger.verbose(`css import "${id}" from ${fromBase} -> ${p} (${content.length} bytes)`);
298
317
  return { path: p, base: path.dirname(p), content };
299
318
  }
@@ -470,6 +470,28 @@ describe("CssCompiler", () => {
470
470
  );
471
471
  });
472
472
 
473
+ test("reports every @import per base path so the written asset can be checked against it", async () => {
474
+ const root = await makeTempRoot();
475
+ const appDir = path.join(root, "apps/demo");
476
+ await write(path.join(appDir, "page/_index.tsx"), 'import "./styles.css";\nexport default () => null;\n');
477
+ await write(path.join(appDir, "page/styles.css"), '@import "../../../libs/shared/ui/brand.css";\n');
478
+ await write(path.join(root, "libs/shared/ui/brand.css"), ":root { --kakao: #fee500; --naver: #1ec800; }\n");
479
+
480
+ const compiler = new CssCompiler({
481
+ workspace: { workspaceRoot: root },
482
+ cwdPath: appDir,
483
+ getPageKeys: async () => ["./_index.tsx"],
484
+ getConfig: async () => ({ barrelImports: [], basePaths: [] }),
485
+ getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
486
+ } as never);
487
+ const cssByBasePath = await compiler.getCssByBasePath();
488
+
489
+ expect(cssByBasePath[""]).toContain("--kakao");
490
+ expect(compiler.importedStylesheetsByBasePath[""]).toEqual([
491
+ { cssPath: path.join(root, "libs/shared/ui/brand.css"), declaredNames: ["--kakao", "--naver"] },
492
+ ]);
493
+ });
494
+
473
495
  test("compiles lib-owned tokens ahead of the app stylesheets that may override them", async () => {
474
496
  const root = await makeTempRoot();
475
497
  const appDir = path.join(root, "apps/demo");
@@ -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";
@@ -192,7 +192,7 @@ export class SsrBaseArtifactBuilder {
192
192
  Object.entries(cssByBasePath).flatMap(([basePath, baseCssText]) => {
193
193
  const cssText = [baseCssText, optimizedFonts.css].filter(Boolean).join("\n");
194
194
  if (!cssText) return [];
195
- return [this.#writeCssAsset(basePath, cssText)];
195
+ return [this.#writeCssAsset(basePath, cssText, cssCompiler.importedStylesheetsByBasePath[basePath] ?? [])];
196
196
  }),
197
197
  ),
198
198
  );
@@ -201,7 +201,7 @@ export class SsrBaseArtifactBuilder {
201
201
  return { cssCompiler, optimizedFonts, cssAssets };
202
202
  }
203
203
 
204
- async #writeCssAsset(basePath: string, cssText: string) {
204
+ async #writeCssAsset(basePath: string, cssText: string, imported: ImportedStylesheet[]) {
205
205
  const cssAssetName = basePath || "root";
206
206
  const preparedCssText = await prepareCssAsset(this.#command, basePath, cssText);
207
207
  const cssHash = Bun.hash(`${basePath}\n${preparedCssText}`).toString(36);
@@ -210,7 +210,23 @@ export class SsrBaseArtifactBuilder {
210
210
  `/_akan/styles/${cssAssetName}-${cssHash}.css`,
211
211
  ];
212
212
  await Bun.write(path.join(this.#absArtifactDir, cssRelPath), preparedCssText);
213
+ SsrBaseArtifactBuilder.#warnDroppedImports(this.#app, cssRelPath, preparedCssText, imported);
213
214
  this.#app.verbose(`[base-artifact] wrote ${preparedCssText.length} bytes of CSS for ${basePath} -> ${cssRelPath}`);
214
215
  return [basePath, { cssUrl, cssRelPath }] as const;
215
216
  }
217
+
218
+ /**
219
+ * Checked against the file written here rather than against the compiled text, because this is the stylesheet
220
+ * `base-artifact.json` points at and therefore the only one an SSR render serves. A declaration can survive
221
+ * the compile and still be missing from the asset — a build that ships CSS to the CSR bundle and not to the
222
+ * server is indistinguishable, in the browser, from a theme that was never written.
223
+ */
224
+ static #warnDroppedImports(app: App, cssRelPath: string, css: string, imported: ImportedStylesheet[]) {
225
+ for (const { cssPath, declaredNames } of imported) {
226
+ if (declaredNames.length === 0 || declaredNames.some((name) => css.includes(`${name}:`))) continue;
227
+ app.logger.warn(
228
+ `[base-artifact] @import ${cssPath} declares ${declaredNames.length} custom propert${declaredNames.length === 1 ? "y" : "ies"} and none of them are in ${cssRelPath}`,
229
+ );
230
+ }
231
+ }
216
232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-alpha.12",
3
+ "version": "3.0.0-alpha.14",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -45,7 +45,7 @@
45
45
  "@langchain/openai": "^1.4.6",
46
46
  "@tailwindcss/node": "^4.3.0",
47
47
  "@trapezedev/project": "^7.1.4",
48
- "akanjs": "3.0.0-alpha.12",
48
+ "akanjs": "3.0.0-alpha.14",
49
49
  "chalk": "^5.6.2",
50
50
  "commander": "^14.0.3",
51
51
  "dayjs": "^1.11.20",