@akanjs/devkit 3.0.0-alpha.7 → 3.0.0-alpha.71

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 (57) 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/akanConfig/akanConfig.test.ts +182 -14
  8. package/akanConfig/akanConfig.ts +132 -47
  9. package/akanConfig/types.ts +8 -0
  10. package/akanContext.ts +53 -11
  11. package/applicationBuildRunner.test.ts +1 -1
  12. package/applicationBuildRunner.ts +45 -21
  13. package/artifact/implicitRootLayout.ts +2 -2
  14. package/biome.base.json +340 -0
  15. package/biomeBase.ts +9 -0
  16. package/executors.test.ts +87 -5
  17. package/executors.ts +40 -9
  18. package/formSetterScanner.test.ts +80 -0
  19. package/formSetterScanner.ts +92 -0
  20. package/frontendBuild/buildRouteClient.test.ts +28 -2
  21. package/frontendBuild/clientBuildTypes.ts +4 -0
  22. package/frontendBuild/clientEntriesBundler.ts +4 -1
  23. package/frontendBuild/cssCompiler.ts +122 -11
  24. package/frontendBuild/cssImportResolver.ts +8 -7
  25. package/frontendBuild/fontPruner.test.ts +220 -0
  26. package/frontendBuild/fontPruner.ts +206 -0
  27. package/frontendBuild/frontendBuild.test.ts +88 -1
  28. package/frontendBuild/hmrWatcher.ts +1 -1
  29. package/frontendBuild/index.ts +1 -0
  30. package/frontendBuild/routeClientBuilder.ts +12 -5
  31. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  32. package/frontendBuild/styleGuard.test.ts +15 -0
  33. package/frontendBuild/styleGuard.ts +17 -0
  34. package/frontendBuild/vendorSpecifiers.ts +1 -0
  35. package/getCredentials.ts +1 -3
  36. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  37. package/incrementalBuilder/incrementalBuilder.host.ts +1 -1
  38. package/incrementalBuilder/incrementalBuilder.proc.ts +2 -2
  39. package/integration/devStabilityHarness.ts +2 -10
  40. package/lint/no-async-component-in-ui.grit +35 -0
  41. package/lint/no-daisyui-legacy-class.grit +26 -9
  42. package/lint/no-deprecated-log-level.grit +17 -0
  43. package/lint/no-import-client-in-server.grit +48 -0
  44. package/lint/no-import-server-in-client.grit +45 -0
  45. package/lint/no-init-fetch-in-client.grit +47 -0
  46. package/lint/no-model-type-in-util-zone.grit +58 -0
  47. package/lint/no-unpublished-form-setter.grit +41 -0
  48. package/linter.ts +17 -12
  49. package/package.json +5 -5
  50. package/qualityScanner.test.ts +52 -0
  51. package/qualityScanner.ts +46 -18
  52. package/repoIdentity.ts +42 -0
  53. package/scanInfo.ts +29 -23
  54. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  55. package/tsconfig.json +1 -1
  56. package/workspaceLayout.test.ts +56 -4
  57. package/workspaceLayout.ts +49 -4
@@ -0,0 +1,220 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import type { App } from "../commandDecorators";
6
+ import { FontPruner } from "./fontPruner";
7
+
8
+ const tempRoots: string[] = [];
9
+
10
+ interface Tree {
11
+ layout?: string;
12
+ publicFiles?: Record<string, string>;
13
+ artifactFiles?: Record<string, string>;
14
+ csrFiles?: Record<string, string>;
15
+ }
16
+
17
+ const write = async (root: string, files: Record<string, string>) => {
18
+ for (const [rel, content] of Object.entries(files)) {
19
+ const abs = path.join(root, rel);
20
+ await mkdir(path.dirname(abs), { recursive: true });
21
+ await writeFile(abs, content);
22
+ }
23
+ };
24
+
25
+ const makeApp = async ({ layout = layoutWith(), publicFiles = {}, artifactFiles = {}, csrFiles = {} }: Tree = {}) => {
26
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-devkit-font-prune-"));
27
+ tempRoots.push(root);
28
+ const cwdPath = path.join(root, "apps/demo");
29
+ const distPath = path.join(root, "dist/apps/demo");
30
+ await mkdir(path.join(cwdPath, "page"), { recursive: true });
31
+ await writeFile(path.join(cwdPath, "page/_layout.tsx"), layout);
32
+ await write(path.join(distPath, "public"), publicFiles);
33
+ await write(path.join(distPath, ".akan/artifact"), artifactFiles);
34
+ await write(path.join(distPath, "csr"), csrFiles);
35
+ const app = {
36
+ cwdPath,
37
+ dist: { cwdPath: distPath },
38
+ workspace: { workspaceRoot: root },
39
+ getPageKeys: async () => ["./_layout.tsx"],
40
+ getPageRoots: async () => [],
41
+ verbose: () => undefined,
42
+ logger: { info: () => undefined, warn: () => undefined },
43
+ } as unknown as App;
44
+ return { app, distPath };
45
+ };
46
+
47
+ const layoutWith = (extra = "") => `
48
+ export const fonts = [
49
+ {
50
+ name: "pretendard",${extra}
51
+ paths: [{ src: "/libs/shared/fonts/Pretendard-Bold.woff2", weight: 700 }],
52
+ },
53
+ ];
54
+ export default function Layout() {
55
+ return null;
56
+ }
57
+ `;
58
+
59
+ const exists = (distPath: string, rel: string) => Bun.file(path.join(distPath, "public", rel)).exists();
60
+
61
+ afterEach(async () => {
62
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
63
+ });
64
+
65
+ describe("FontPruner", () => {
66
+ test("drops a subset font's source and reports the bytes freed", async () => {
67
+ const { app, distPath } = await makeApp({
68
+ publicFiles: { "libs/shared/fonts/Pretendard-Bold.woff2": "x".repeat(2048) },
69
+ });
70
+
71
+ const result = await new FontPruner(app).prune();
72
+
73
+ expect(result.removed.map(({ file }) => file)).toEqual(["libs/shared/fonts/Pretendard-Bold.woff2"]);
74
+ expect(result.freedBytes).toBe(2048);
75
+ expect(await exists(distPath, "libs/shared/fonts/Pretendard-Bold.woff2")).toBe(false);
76
+ });
77
+
78
+ test("drops a font no declaration and no surface mentions", async () => {
79
+ const { app, distPath } = await makeApp({
80
+ publicFiles: { "libs/shared/fonts/NotoSansKR.ttf": "x" },
81
+ });
82
+
83
+ const result = await new FontPruner(app).prune();
84
+
85
+ expect(result.removed.map(({ file }) => file)).toEqual(["libs/shared/fonts/NotoSansKR.ttf"]);
86
+ expect(await exists(distPath, "libs/shared/fonts/NotoSansKR.ttf")).toBe(false);
87
+ });
88
+
89
+ test("keeps a font a stylesheet in public loads by url", async () => {
90
+ const { app, distPath } = await makeApp({
91
+ publicFiles: {
92
+ "libs/shared/fonts/Assistant-Bold.woff2": "x",
93
+ "libs/shared/excalidraw.css": '@font-face{src:url("/libs/shared/fonts/Assistant-Bold.woff2")}',
94
+ },
95
+ });
96
+
97
+ const result = await new FontPruner(app).prune();
98
+
99
+ expect(result.removed).toEqual([]);
100
+ expect(result.kept).toEqual([
101
+ {
102
+ file: "libs/shared/fonts/Assistant-Bold.woff2",
103
+ bytes: 1,
104
+ reason: { kind: "referenced", referrer: "public/libs/shared/excalidraw.css" },
105
+ },
106
+ ]);
107
+ expect(await exists(distPath, "libs/shared/fonts/Assistant-Bold.woff2")).toBe(true);
108
+ });
109
+
110
+ test("keeps a font whose filename a referrer percent-encodes", async () => {
111
+ const { app } = await makeApp({
112
+ publicFiles: {
113
+ "fonts/Lemon Milk Pro Medium.otf": "x",
114
+ "brand.css": "@font-face{src:url(/fonts/Lemon%20Milk%20Pro%20Medium.otf)}",
115
+ },
116
+ });
117
+
118
+ const result = await new FontPruner(app).prune();
119
+
120
+ expect(result.removed).toEqual([]);
121
+ expect(result.kept[0]?.reason).toEqual({ kind: "referenced", referrer: "public/brand.css" });
122
+ });
123
+
124
+ test("keeps the source of a font declared with optimize: false", async () => {
125
+ const { app, distPath } = await makeApp({
126
+ layout: layoutWith("\n optimize: false,"),
127
+ publicFiles: { "libs/shared/fonts/Pretendard-Bold.woff2": "x" },
128
+ });
129
+
130
+ const result = await new FontPruner(app).prune();
131
+
132
+ expect(result.removed).toEqual([]);
133
+ expect(result.kept[0]?.reason).toEqual({ kind: "unoptimized" });
134
+ expect(await exists(distPath, "libs/shared/fonts/Pretendard-Bold.woff2")).toBe(true);
135
+ });
136
+
137
+ test("keeps a font matched by an assets.keepFonts glob", async () => {
138
+ const { app, distPath } = await makeApp({
139
+ publicFiles: { "libs/shared/fonts/Assistant-Bold.woff2": "x" },
140
+ });
141
+
142
+ const result = await new FontPruner(app, { keepFonts: ["libs/shared/fonts/Assistant-*.woff2"] }).prune();
143
+
144
+ expect(result.removed).toEqual([]);
145
+ expect(result.kept[0]?.reason).toEqual({ kind: "declared", glob: "libs/shared/fonts/Assistant-*.woff2" });
146
+ expect(await exists(distPath, "libs/shared/fonts/Assistant-Bold.woff2")).toBe(true);
147
+ });
148
+
149
+ test("ignores the declaration the build inlines into its own bundles", async () => {
150
+ const { app } = await makeApp({
151
+ artifactFiles: {
152
+ "server/pages-abc.js": 'const fonts=[{paths:[{src:"/libs/shared/fonts/Pretendard-Bold.woff2"}]}]',
153
+ },
154
+ csrFiles: { "index.html": '<script>src:"/libs/shared/fonts/Pretendard-Bold.woff2"</script>' },
155
+ publicFiles: { "libs/shared/fonts/Pretendard-Bold.woff2": "x" },
156
+ });
157
+
158
+ const result = await new FontPruner(app).prune();
159
+
160
+ expect(result.removed.map(({ file }) => file)).toEqual(["libs/shared/fonts/Pretendard-Bold.woff2"]);
161
+ });
162
+
163
+ test("keeps a font a bundle loads that no declaration names", async () => {
164
+ const { app } = await makeApp({
165
+ artifactFiles: { "client/chunk-abc.js": 'new FontFace("x","url(/fonts/Runtime-Regular.woff2)")' },
166
+ publicFiles: { "fonts/Runtime-Regular.woff2": "x" },
167
+ });
168
+
169
+ const result = await new FontPruner(app).prune();
170
+
171
+ expect(result.removed).toEqual([]);
172
+ expect(result.kept[0]?.reason).toEqual({ kind: "referenced", referrer: ".akan/artifact/client/chunk-abc.js" });
173
+ });
174
+
175
+ test("keeps a font the compiled stylesheet still points at", async () => {
176
+ const { app } = await makeApp({
177
+ artifactFiles: { "styles/root-abc.css": "@font-face{src:url(/libs/shared/fonts/Pretendard-Bold.woff2)}" },
178
+ publicFiles: { "libs/shared/fonts/Pretendard-Bold.woff2": "x" },
179
+ });
180
+
181
+ const result = await new FontPruner(app).prune();
182
+
183
+ expect(result.removed).toEqual([]);
184
+ expect(result.kept[0]?.reason).toEqual({
185
+ kind: "referenced",
186
+ referrer: ".akan/artifact/styles/root-abc.css",
187
+ });
188
+ });
189
+
190
+ test("leaves non-font assets alone", async () => {
191
+ const { app, distPath } = await makeApp({
192
+ publicFiles: { "logo.png": "x", "video.mp4": "x", "libs/shared/fonts/NotoSansKR.ttf": "x" },
193
+ });
194
+
195
+ await new FontPruner(app).prune();
196
+
197
+ expect(await exists(distPath, "logo.png")).toBe(true);
198
+ expect(await exists(distPath, "video.mp4")).toBe(true);
199
+ expect(await exists(distPath, "libs/shared/fonts/NotoSansKR.ttf")).toBe(false);
200
+ });
201
+
202
+ test("does nothing when the build shipped no public directory", async () => {
203
+ const { app } = await makeApp();
204
+ await rm(path.join(app.dist.cwdPath, "public"), { recursive: true, force: true });
205
+
206
+ const result = await new FontPruner(app).prune();
207
+
208
+ expect(result).toEqual({ removed: [], kept: [], freedBytes: 0 });
209
+ });
210
+
211
+ test("removes a directory the prune emptied", async () => {
212
+ const { app, distPath } = await makeApp({
213
+ publicFiles: { "libs/shared/fonts/NotoSansKR.ttf": "x" },
214
+ });
215
+
216
+ await new FontPruner(app).prune();
217
+
218
+ expect(await Bun.file(path.join(distPath, "public/libs/shared/fonts")).exists()).toBe(false);
219
+ });
220
+ });
@@ -0,0 +1,206 @@
1
+ import { rm, rmdir, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { isFontOptimizationEnabled } from "akanjs/client";
4
+ import type { App } from "../commandDecorators";
5
+ import { FontOptimizer } from "./fontOptimizer";
6
+
7
+ const FONT_GLOB = "**/*.{woff2,woff,ttf,otf,ttc,eot}";
8
+ const REFERRER_GLOB = "**/*.{css,js,mjs,cjs,json,html,htm,svg,xml,webmanifest}";
9
+
10
+ export type FontKeepReason =
11
+ | { kind: "unoptimized" }
12
+ | { kind: "declared"; glob: string }
13
+ | { kind: "referenced"; referrer: string };
14
+
15
+ export interface FontPruneResult {
16
+ removed: { file: string; bytes: number }[];
17
+ kept: { file: string; bytes: number; reason: FontKeepReason }[];
18
+ freedBytes: number;
19
+ }
20
+
21
+ /**
22
+ * Drops the font sources in a build's `public/` that no built surface reads. A font with `optimize` on is
23
+ * served from `/_akan/fonts` after subsetting, so its source is a build input — the image ships it and the
24
+ * runtime never opens it. Only `dist` is touched: an app's and a lib's own `public/` keep every file, because
25
+ * one lib's fonts are picked over differently by every app that mounts it and by other repos.
26
+ */
27
+ export class FontPruner {
28
+ #app: App;
29
+ #keepGlobs: string[];
30
+ #publicRoot: string;
31
+ #artifactRoot: string;
32
+
33
+ constructor(app: App, { keepFonts = [] }: { keepFonts?: string[] } = {}) {
34
+ this.#app = app;
35
+ this.#keepGlobs = keepFonts;
36
+ this.#publicRoot = path.join(app.dist.cwdPath, "public");
37
+ this.#artifactRoot = path.join(app.dist.cwdPath, ".akan/artifact");
38
+ }
39
+
40
+ async prune(): Promise<FontPruneResult> {
41
+ const empty: FontPruneResult = { removed: [], kept: [], freedBytes: 0 };
42
+ if (!(await this.#isDirectory(this.#publicRoot))) return empty;
43
+ const candidates = await this.#collectCandidates();
44
+ if (!candidates.length) return empty;
45
+
46
+ const fonts = await new FontOptimizer(this.#app, "build").discoverFonts();
47
+ const unoptimizedSrcs = new Set<string>();
48
+ const optimizedSrcs = new Set<string>();
49
+ for (const font of fonts) {
50
+ const target = isFontOptimizationEnabled(font) ? optimizedSrcs : unoptimizedSrcs;
51
+ for (const fontPath of font.paths) target.add(path.posix.basename(fontPath.src));
52
+ }
53
+
54
+ const result: FontPruneResult = { removed: [], kept: [], freedBytes: 0 };
55
+ const undecided: typeof candidates = [];
56
+ for (const candidate of candidates) {
57
+ const reason = this.#staticKeepReason(candidate, unoptimizedSrcs);
58
+ if (reason) result.kept.push({ file: candidate.rel, bytes: candidate.bytes, reason });
59
+ else undecided.push(candidate);
60
+ }
61
+ if (undecided.length) {
62
+ const referrers = await this.#findReferrers(undecided, optimizedSrcs);
63
+ for (const candidate of undecided) {
64
+ const referrer = referrers.get(candidate.rel);
65
+ if (referrer)
66
+ result.kept.push({ file: candidate.rel, bytes: candidate.bytes, reason: { kind: "referenced", referrer } });
67
+ else result.removed.push({ file: candidate.rel, bytes: candidate.bytes });
68
+ }
69
+ }
70
+
71
+ await this.#remove(result.removed.map(({ file }) => file));
72
+ result.freedBytes = result.removed.reduce((total, { bytes }) => total + bytes, 0);
73
+ this.#report(result);
74
+ return result;
75
+ }
76
+
77
+ #staticKeepReason(candidate: { rel: string; basename: string }, unoptimizedSrcs: Set<string>): FontKeepReason | null {
78
+ if (unoptimizedSrcs.has(candidate.basename)) return { kind: "unoptimized" };
79
+ for (const glob of this.#keepGlobs) {
80
+ if (new Bun.Glob(glob).match(candidate.rel)) return { kind: "declared", glob };
81
+ }
82
+ return null;
83
+ }
84
+
85
+ async #collectCandidates() {
86
+ const candidates: { rel: string; basename: string; bytes: number }[] = [];
87
+ for await (const rel of new Bun.Glob(FONT_GLOB).scan({ cwd: this.#publicRoot, dot: false })) {
88
+ const entry = await stat(path.join(this.#publicRoot, rel)).catch(() => null);
89
+ if (!entry?.isFile()) continue;
90
+ candidates.push({ rel: rel.split(path.sep).join("/"), basename: path.basename(rel), bytes: entry.size });
91
+ }
92
+ return candidates;
93
+ }
94
+
95
+ /**
96
+ * Matched by basename rather than by URL, so a reference survives every spelling a referrer may use — an
97
+ * absolute or relative `url()`, and the percent-encoded form a font whose filename carries a space needs.
98
+ *
99
+ * The build's own bundles are read on weaker terms than `public/` and the compiled CSS: every route file's
100
+ * `fonts` declaration is inlined into the pages bundle, the client chunks and the CSR shell, so a declared
101
+ * source is in all three whether or not anything loads it. A hit there is therefore ignored for a font the
102
+ * optimizer already subset — that string is the declaration, not a fetch.
103
+ */
104
+ async #findReferrers(
105
+ candidates: { rel: string; basename: string }[],
106
+ optimizedSrcs: Set<string>,
107
+ ): Promise<Map<string, string>> {
108
+ const referrers = new Map<string, string>();
109
+ const authoritative = candidates;
110
+ const bundled = candidates.filter((candidate) => !optimizedSrcs.has(candidate.basename));
111
+ const roots: { dir: string; label: string; needles: typeof candidates }[] = [
112
+ { dir: this.#publicRoot, label: "public", needles: authoritative },
113
+ { dir: path.join(this.#artifactRoot, "styles"), label: ".akan/artifact/styles", needles: authoritative },
114
+ { dir: path.join(this.#artifactRoot, "client"), label: ".akan/artifact/client", needles: bundled },
115
+ { dir: path.join(this.#artifactRoot, "client-ssr"), label: ".akan/artifact/client-ssr", needles: bundled },
116
+ { dir: path.join(this.#artifactRoot, "server"), label: ".akan/artifact/server", needles: bundled },
117
+ { dir: path.join(this.#app.dist.cwdPath, "csr"), label: "csr", needles: bundled },
118
+ ];
119
+ for (const root of roots) {
120
+ const pending = root.needles.filter((candidate) => !referrers.has(candidate.rel));
121
+ if (!pending.length || !(await this.#isDirectory(root.dir))) continue;
122
+ await this.#scanRoot(root, pending, referrers);
123
+ }
124
+ return referrers;
125
+ }
126
+
127
+ async #scanRoot(
128
+ root: { dir: string; label: string },
129
+ pending: { rel: string; basename: string }[],
130
+ referrers: Map<string, string>,
131
+ ) {
132
+ const needles = pending.map((candidate) => ({
133
+ rel: candidate.rel,
134
+ forms: [...new Set([candidate.basename, encodeURIComponent(candidate.basename)])],
135
+ }));
136
+ for await (const rel of new Bun.Glob(REFERRER_GLOB).scan({ cwd: root.dir, dot: false })) {
137
+ const remaining = needles.filter((needle) => !referrers.has(needle.rel));
138
+ if (!remaining.length) return;
139
+ const text = await Bun.file(path.join(root.dir, rel))
140
+ .text()
141
+ .catch(() => "");
142
+ if (!text) continue;
143
+ for (const needle of remaining) {
144
+ if (needle.forms.some((form) => text.includes(form)))
145
+ referrers.set(needle.rel, `${root.label}/${rel.split(path.sep).join("/")}`);
146
+ }
147
+ }
148
+ }
149
+
150
+ async #remove(files: string[]) {
151
+ await Promise.all(files.map((file) => rm(path.join(this.#publicRoot, file), { force: true })));
152
+ const dirs = [...new Set(files.map((file) => path.posix.dirname(file)))]
153
+ .filter((dir) => dir !== "." && dir !== "/")
154
+ .sort((a, b) => b.length - a.length);
155
+ for (const dir of dirs) await rmdir(path.join(this.#publicRoot, dir)).catch(() => undefined);
156
+ }
157
+
158
+ /**
159
+ * The referrer roll-up is `info`, not `verbose`: a generated file that lists every asset in `public/` — a
160
+ * service-worker precache manifest is the usual one — is a real reference and keeps every font it names, so
161
+ * without this line a build that pruned nothing looks the same as a build with nothing to prune.
162
+ */
163
+ #report(result: FontPruneResult) {
164
+ if (!result.removed.length && !result.kept.length) return;
165
+ for (const { file, bytes } of result.removed)
166
+ this.#app.verbose(`[font-prune] dropped public/${file} (${FontPruner.formatBytes(bytes)})`);
167
+ for (const { file, reason } of result.kept)
168
+ this.#app.verbose(`[font-prune] kept public/${file} — ${FontPruner.describe(reason)}`);
169
+ const byReferrer = new Map<string, { files: number; bytes: number }>();
170
+ for (const { bytes, reason } of result.kept) {
171
+ if (reason.kind !== "referenced") continue;
172
+ const entry = byReferrer.get(reason.referrer) ?? { files: 0, bytes: 0 };
173
+ entry.files += 1;
174
+ entry.bytes += bytes;
175
+ byReferrer.set(reason.referrer, entry);
176
+ }
177
+ if (!byReferrer.size) return;
178
+ const ranked = [...byReferrer.entries()].sort(([, a], [, b]) => b.bytes - a.bytes);
179
+ const named = ranked
180
+ .slice(0, 3)
181
+ .map(([referrer, { files, bytes }]) => `${referrer} (${files}, ${FontPruner.formatBytes(bytes)})`);
182
+ const rest = ranked.length - named.length;
183
+ this.#app.logger.info(
184
+ `[font-prune] kept ${FontPruner.formatBytes(ranked.reduce((total, [, entry]) => total + entry.bytes, 0))} of fonts in public/ because these reference them: ${named.join(", ")}${rest > 0 ? ` and ${rest} more` : ""}`,
185
+ );
186
+ }
187
+
188
+ static describe(reason: FontKeepReason) {
189
+ if (reason.kind === "unoptimized") return "declared with optimize: false, so the runtime CSS serves it";
190
+ if (reason.kind === "declared") return `kept by assets.keepFonts "${reason.glob}"`;
191
+ return `referenced by ${reason.referrer}`;
192
+ }
193
+
194
+ static formatBytes(bytes: number): string {
195
+ if (bytes < 1024) return `${bytes}B`;
196
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
197
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
198
+ }
199
+
200
+ async #isDirectory(dir: string) {
201
+ return await stat(dir).then(
202
+ (entry) => entry.isDirectory(),
203
+ () => false,
204
+ );
205
+ }
206
+ }
@@ -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";
@@ -391,6 +391,20 @@ describe("CssImportResolver", () => {
391
391
  expect(await resolver.resolve("@libs/ui/missing", root)).toBeNull();
392
392
  });
393
393
 
394
+ test("never substitutes the package stylesheet for a subpath that does not exist", async () => {
395
+ const root = await makeTempRoot();
396
+ await write(
397
+ path.join(root, "node_modules/vendor/package.json"),
398
+ JSON.stringify({ name: "vendor", style: "index.css" }),
399
+ );
400
+ await write(path.join(root, "node_modules/vendor/index.css"), ".vendor {}\n");
401
+
402
+ const resolver = new CssImportResolver(root, {});
403
+
404
+ expect(await resolver.resolve("vendor", root)).toBe(path.join(root, "node_modules/vendor/index.css"));
405
+ expect(await resolver.resolve("vendor/ui/tokens.css", root)).toBeNull();
406
+ });
407
+
394
408
  test("resolves css from single-package Akan workspace subpaths", async () => {
395
409
  const root = await makeTempRoot();
396
410
  await write(path.join(root, "pkgs/akanjs/ui/styles.css"), "body {}\n");
@@ -428,4 +442,77 @@ describe("CssCompiler", () => {
428
442
 
429
443
  expect(css).toContain(".text-fuchsia-500");
430
444
  });
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
+
457
+ test("fails loudly on a stylesheet import that resolves to nothing", async () => {
458
+ const root = await makeTempRoot();
459
+ const cssPath = path.join(root, "apps/demo/page/styles.css");
460
+ await write(cssPath, '@import "../../../libs/shared/ui/tokens.css";\n');
461
+
462
+ const compiler = new CssCompiler({
463
+ workspace: { workspaceRoot: root },
464
+ cwdPath: path.join(root, "apps/demo"),
465
+ getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
466
+ } as never);
467
+
468
+ await expect(compiler.compileCss([cssPath], [])).rejects.toThrow(
469
+ /failed to resolve stylesheet import "\.\.\/\.\.\/\.\.\/libs\/shared\/ui\/tokens\.css"/,
470
+ );
471
+ });
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
+
495
+ test("compiles lib-owned tokens ahead of the app stylesheets that may override them", async () => {
496
+ const root = await makeTempRoot();
497
+ const appDir = path.join(root, "apps/demo");
498
+ await write(
499
+ path.join(appDir, "page/_index.tsx"),
500
+ 'import "./styles.css";\nimport { Card } from "@libs/shared/ui";\nexport default () => <Card />;\n',
501
+ );
502
+ await write(path.join(appDir, "page/styles.css"), ":root { --brand: #111111; }\n");
503
+ await write(path.join(root, "libs/shared/ui/index.ts"), "export const Card = () => null;\n");
504
+ await write(path.join(root, "libs/shared/ui/tokens.css"), ":root { --kakao: #fee500; }\n");
505
+ await write(path.join(root, "libs/unused/ui/tokens.css"), ":root { --unused: #000000; }\n");
506
+
507
+ const compiler = new CssCompiler({
508
+ workspace: { workspaceRoot: root },
509
+ cwdPath: appDir,
510
+ getPageKeys: async () => ["./_index.tsx"],
511
+ getConfig: async () => ({ barrelImports: [] }),
512
+ getTsConfig: async () => ({ compilerOptions: { paths: { "@libs/*": ["./libs/*"] } } }),
513
+ } as never);
514
+ const { cssPaths } = await compiler.discoverCssAndSources();
515
+
516
+ expect(cssPaths).toEqual([path.join(root, "libs/shared/ui/tokens.css"), path.join(appDir, "page/styles.css")]);
517
+ });
431
518
  });
@@ -209,7 +209,7 @@ export class HmrWatcher {
209
209
  // per-batch detail stays at verbose because a save-all trips this on every save.
210
210
  if (!this.#reportedCompensating) {
211
211
  this.#reportedCompensating = true;
212
- this.#logger.info(
212
+ this.#logger.verbose(
213
213
  `[hmr] recovered ${unreported} change(s) that fs.watch did not report; Bun coalesces concurrent saves and drops all but one, so changes are resolved by mtime`,
214
214
  );
215
215
  }
@@ -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";
@@ -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
  }