@akanjs/devkit 3.0.0-alpha.11 → 3.0.0-alpha.13

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.
@@ -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,6 +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>();
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[]> = {};
51
62
 
52
63
  #fileExists(absPath: string): Promise<boolean> {
53
64
  let cached = this.#fileExistsCache.get(absPath);
@@ -79,8 +90,11 @@ export class CssCompiler {
79
90
  async getCss({ refresh }: { refresh?: boolean } = {}) {
80
91
  if (this.#cssText !== null && !refresh) return this.#cssText;
81
92
  this.#discoveredCssPaths.clear();
93
+ this.importedStylesheetsByBasePath = {};
82
94
  const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh });
83
- this.#cssText = await this.compileCss(cssPaths, sourcePaths);
95
+ const { css, imported } = await this.#compileWithImports(cssPaths, sourcePaths);
96
+ this.#cssText = css;
97
+ this.importedStylesheetsByBasePath = { "": imported };
84
98
  await this.#warnUnreachableStylesheets();
85
99
  return this.#cssText;
86
100
  }
@@ -88,6 +102,7 @@ export class CssCompiler {
88
102
  async getCssByBasePath({ refresh }: { refresh?: boolean } = {}): Promise<Record<string, string>> {
89
103
  if (this.#cssTextByBasePath !== null && !refresh) return this.#cssTextByBasePath;
90
104
  this.#discoveredCssPaths.clear();
105
+ this.importedStylesheetsByBasePath = {};
91
106
  const akanConfig = await this.#app.getConfig({ refresh });
92
107
  const pageKeys = await this.#app.getPageKeys({ refresh });
93
108
  const basePaths = [...akanConfig.basePaths];
@@ -97,7 +112,8 @@ export class CssCompiler {
97
112
  if (rootPageKeys.length === 0) return ["", ""] as const;
98
113
  const started = Date.now();
99
114
  const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh, pageKeys: rootPageKeys });
100
- const css = await this.compileCss(cssPaths, sourcePaths);
115
+ const { css, imported } = await this.#compileWithImports(cssPaths, sourcePaths);
116
+ this.importedStylesheetsByBasePath[""] = imported;
101
117
  this.#logger.verbose(
102
118
  `css base=root paths=${cssPaths.length} sources=${sourcePaths.length} in ${Date.now() - started}ms`,
103
119
  );
@@ -108,7 +124,8 @@ export class CssCompiler {
108
124
  if (basePathPageKeys.length === 0) return [basePath, ""] as const;
109
125
  const started = Date.now();
110
126
  const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh, pageKeys: basePathPageKeys });
111
- const css = await this.compileCss(cssPaths, sourcePaths);
127
+ const { css, imported } = await this.#compileWithImports(cssPaths, sourcePaths);
128
+ this.importedStylesheetsByBasePath[basePath] = imported;
112
129
  this.#logger.verbose(
113
130
  `css base=${basePath} paths=${cssPaths.length} sources=${sourcePaths.length} in ${Date.now() - started}ms`,
114
131
  );
@@ -231,19 +248,32 @@ export class CssCompiler {
231
248
  return tokenPaths.filter((tokensPath): tokensPath is string => !!tokensPath);
232
249
  }
233
250
  async compileCss(cssPaths: string[], sourcePaths: string[]): Promise<string> {
234
- 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: [] };
235
264
 
236
265
  const compileStarted = Date.now();
237
266
  const compilers = await Promise.all(
238
267
  cssPaths.map(async (cssPath) => {
239
268
  const css = await Bun.file(cssPath).text();
240
269
  const base = path.dirname(cssPath);
270
+ const imported = new Map<string, string>();
241
271
  const compiler = await compile(css, {
242
272
  base,
243
- loadStylesheet: (id, fromBase) => this.#loadStylesheet(id, fromBase),
273
+ loadStylesheet: (id, fromBase) => this.#loadStylesheet(id, fromBase, imported),
244
274
  loadModule: (id, fromBase) => this.#loadModule(id, fromBase),
245
275
  });
246
- return { cssPath, compiler };
276
+ return { cssPath, compiler, imported };
247
277
  }),
248
278
  );
249
279
 
@@ -258,20 +288,32 @@ export class CssCompiler {
258
288
  `css candidates scanned count=${candidates.length} sources=${sourcePaths.length} dirs=${sourceDirs.size} in ${Date.now() - scanStarted}ms`,
259
289
  );
260
290
  const parts: string[] = [];
291
+ const imported: ImportedStylesheet[] = [];
261
292
  for (const entry of compilers) {
262
293
  if (!entry) continue;
263
- 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
+ }
264
304
  }
265
305
  this.#logger.verbose(
266
306
  `css compiled paths=${cssPaths.length} candidates=${candidates.length} in ${Date.now() - compileStarted}ms`,
267
307
  );
268
- return parts.join("\n");
308
+ return { css: parts.join("\n"), imported };
269
309
  }
270
310
 
271
- async #loadStylesheet(id: string, fromBase: string) {
311
+ async #loadStylesheet(id: string, fromBase: string, imported?: Map<string, string>) {
272
312
  const p = await this.#resolveCssImport(id, fromBase);
273
313
  this.#discoveredCssPaths.add(p);
274
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)`);
275
317
  return { path: p, base: path.dirname(p), content };
276
318
  }
277
319
 
@@ -388,6 +430,17 @@ export function isIgnoredNodeModuleSource(filePath: string): boolean {
388
430
  return NODE_MODULES_RE.test(filePath) && !AKANJS_NODE_MODULE_RE.test(filePath);
389
431
  }
390
432
 
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
+
391
444
  function getPageKeyBasePath(pageKey: string, basePaths: string[]): string | null {
392
445
  const normalized = pageKey.split(path.sep).join("/").replace(/^\.\//, "");
393
446
  const segments = normalized.split("/");
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import type { RoutesManifest } from "akanjs/server";
7
7
  import { CsrArtifactBuilder } from "./csrArtifactBuilder";
8
- import { CssCompiler, isIgnoredNodeModuleSource } from "./cssCompiler";
8
+ import { CssCompiler, declaredCustomProperties, isIgnoredNodeModuleSource } from "./cssCompiler";
9
9
  import { CssImportResolver } from "./cssImportResolver";
10
10
  import { DevChangePlanner } from "./devChangePlanner";
11
11
  import { DevGeneratedIndexSync } from "./devGeneratedIndexSync";
@@ -443,6 +443,17 @@ describe("CssCompiler", () => {
443
443
  expect(css).toContain(".text-fuchsia-500");
444
444
  });
445
445
 
446
+ test("reads declarations that prove a stylesheet arrived, ignoring theme variables that may not", () => {
447
+ expect(declaredCustomProperties(":root { --kakao: #fee500; --naver: #1ec800; }")).toEqual(["--kakao", "--naver"]);
448
+ expect(declaredCustomProperties("@theme inline {\n --color-brand: var(--brand);\n}\n")).toEqual([]);
449
+ expect(declaredCustomProperties("@theme { --color-x: initial; }\n:root { --brand: #111; }")).toEqual(["--brand"]);
450
+ expect(declaredCustomProperties(".a { color: var(--kakao); }")).toEqual([]);
451
+ expect(declaredCustomProperties(":root{--a:#111}\n@media (min-width:1px){:root{--a:#222;--b:#333}}")).toEqual([
452
+ "--a",
453
+ "--b",
454
+ ]);
455
+ });
456
+
446
457
  test("fails loudly on a stylesheet import that resolves to nothing", async () => {
447
458
  const root = await makeTempRoot();
448
459
  const cssPath = path.join(root, "apps/demo/page/styles.css");
@@ -459,6 +470,28 @@ describe("CssCompiler", () => {
459
470
  );
460
471
  });
461
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
+
462
495
  test("compiles lib-owned tokens ahead of the app stylesheets that may override them", async () => {
463
496
  const root = await makeTempRoot();
464
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.11",
3
+ "version": "3.0.0-alpha.13",
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.11",
48
+ "akanjs": "3.0.0-alpha.13",
49
49
  "chalk": "^5.6.2",
50
50
  "commander": "^14.0.3",
51
51
  "dayjs": "^1.11.20",