@akanjs/devkit 3.0.0-alpha.67 → 3.0.0-alpha.68
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/akanConfig/akanConfig.test.ts +1 -2
- package/akanConfig/akanConfig.ts +25 -5
- package/akanConfig/types.ts +2 -0
- package/applicationBuildRunner.ts +20 -3
- package/frontendBuild/fontPruner.test.ts +220 -0
- package/frontendBuild/fontPruner.ts +206 -0
- package/frontendBuild/index.ts +1 -0
- package/integration/devStabilityHarness.ts +1 -5
- package/package.json +2 -2
|
@@ -173,7 +173,7 @@ describe("AkanAppConfig", () => {
|
|
|
173
173
|
});
|
|
174
174
|
|
|
175
175
|
test("keeps a declared Dockerfile string verbatim", () => {
|
|
176
|
-
const dockerfile =
|
|
176
|
+
const dockerfile = 'FROM oven/bun:1-slim\nCOPY . .\nCMD ["bun","main.js"]';
|
|
177
177
|
const config = new AkanAppConfig(app, [], packageJson, { docker: dockerfile }, baseDevEnv);
|
|
178
178
|
|
|
179
179
|
expect(config.docker).toBe(dockerfile);
|
|
@@ -487,7 +487,6 @@ describe("AkanAppConfig lib externalLibs", () => {
|
|
|
487
487
|
});
|
|
488
488
|
});
|
|
489
489
|
|
|
490
|
-
|
|
491
490
|
describe("AkanAppConfig lib docker runs", () => {
|
|
492
491
|
const libDocker = (preRuns: string[], postRuns: string[] = []) => ({
|
|
493
492
|
externalLibs: [],
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { App, Lib } from "../commandDecorators";
|
|
|
8
8
|
import { LibExecutor, WorkspaceExecutor } from "../executors";
|
|
9
9
|
import type { BaseDevEnv, PackageJson } from "../types";
|
|
10
10
|
import {
|
|
11
|
+
type AkanAssetsConfig,
|
|
11
12
|
type AkanMobileConfig,
|
|
12
13
|
type AkanMobileTargetConfig,
|
|
13
14
|
type AkanRouteConfig,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
type DockerConfig,
|
|
22
23
|
type DockerOption,
|
|
23
24
|
type DockerRun,
|
|
25
|
+
type LibAssetsConfig,
|
|
24
26
|
type LibConfigResult,
|
|
25
27
|
type LibDockerConfig,
|
|
26
28
|
} from "./types";
|
|
@@ -111,6 +113,7 @@ const DEFAULT_AKAN_IMAGE_CONFIG: AkanImageConfig = {
|
|
|
111
113
|
maximumRedirects: 3,
|
|
112
114
|
fetchTimeoutMs: 7000,
|
|
113
115
|
maxRemoteBytes: 25 * 1024 * 1024,
|
|
116
|
+
maxConcurrency: 0,
|
|
114
117
|
};
|
|
115
118
|
|
|
116
119
|
const normalizeIndexPath = (indexPath: string | undefined): string | undefined => {
|
|
@@ -179,13 +182,20 @@ type AppConfigDeclaration = Omit<DeepPartial<AppConfigResult>, "docker" | "web">
|
|
|
179
182
|
export interface LibContributions {
|
|
180
183
|
externalLibs: string[];
|
|
181
184
|
docker: LibDockerConfig;
|
|
185
|
+
/** Keep globs rewritten to the app's own `public/`, where `akan sync` mounts each lib's assets. */
|
|
186
|
+
keepFonts?: string[];
|
|
182
187
|
}
|
|
183
188
|
|
|
184
189
|
const emptyLibContributions = (): LibContributions => ({
|
|
185
190
|
externalLibs: [],
|
|
186
191
|
docker: { preRuns: [], postRuns: [] },
|
|
192
|
+
keepFonts: [],
|
|
187
193
|
});
|
|
188
194
|
|
|
195
|
+
const normalizeKeepFonts = (keepFonts: string[] | undefined) => [
|
|
196
|
+
...new Set((keepFonts ?? []).map((glob) => glob.trim().replace(/^\/+/, "")).filter(Boolean)),
|
|
197
|
+
];
|
|
198
|
+
|
|
189
199
|
/** First occurrence wins, so a step a lib and its app both declare becomes one layer. */
|
|
190
200
|
const dedupeDockerRuns = (runs: DockerRun[]): DockerRun[] => {
|
|
191
201
|
const byKey = new Map<string, DockerRun>();
|
|
@@ -211,6 +221,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
211
221
|
/** True only when the app's akan.config.ts explicitly declares a `mobile` section (vs. the synthesized default). */
|
|
212
222
|
hasMobileConfig: boolean;
|
|
213
223
|
secrets: string[];
|
|
224
|
+
assets: AkanAssetsConfig;
|
|
214
225
|
/** Raw setting; resolved against the app's lib deps at sync time (see `AppExecutor.syncPages`). */
|
|
215
226
|
syncPageLibs: string[] | boolean;
|
|
216
227
|
baseDevEnv: BaseDevEnv;
|
|
@@ -251,6 +262,13 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
251
262
|
process.env.AKAN_PUBLIC_LOCALES = this.i18n.locales.join(",");
|
|
252
263
|
this.publicEnv = (config?.publicEnv as string[] | undefined) ?? ([] as string[]);
|
|
253
264
|
this.secrets = (config?.secrets as string[] | undefined) ?? ([] as string[]);
|
|
265
|
+
this.assets = {
|
|
266
|
+
pruneFonts: config?.assets?.pruneFonts ?? true,
|
|
267
|
+
keepFonts: [
|
|
268
|
+
...normalizeKeepFonts(config?.assets?.keepFonts as string[] | undefined),
|
|
269
|
+
...(libContributions.keepFonts ?? []),
|
|
270
|
+
],
|
|
271
|
+
};
|
|
254
272
|
this.syncPageLibs = (config?.syncPageLibs as string[] | boolean | undefined) ?? false;
|
|
255
273
|
this.hasMobileConfig = Boolean(config.mobile);
|
|
256
274
|
this.mobile = this.#resolveMobileConfig(config.mobile);
|
|
@@ -460,11 +478,7 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
|
|
|
460
478
|
}
|
|
461
479
|
//* Every workspace lib is read, not just this app's lib deps: narrowing the set needs the dependency
|
|
462
480
|
//* scan, and the incremental page rebundle re-reads this config on every file change.
|
|
463
|
-
static async #collectLibContributions(
|
|
464
|
-
app: App,
|
|
465
|
-
libs: string[],
|
|
466
|
-
bustImportCache: boolean,
|
|
467
|
-
): Promise<LibContributions> {
|
|
481
|
+
static async #collectLibContributions(app: App, libs: string[], bustImportCache: boolean): Promise<LibContributions> {
|
|
468
482
|
const libConfigs = await Promise.all(
|
|
469
483
|
libs.map(async (libName) =>
|
|
470
484
|
LibExecutor.from(app, libName)
|
|
@@ -481,6 +495,10 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
|
|
|
481
495
|
preRuns: libConfigs.flatMap((libConfig) => libConfig?.docker.preRuns ?? []),
|
|
482
496
|
postRuns: libConfigs.flatMap((libConfig) => libConfig?.docker.postRuns ?? []),
|
|
483
497
|
},
|
|
498
|
+
//* A lib writes the glob against its own `public/`; `akan sync` mounts that at `public/libs/<lib>`.
|
|
499
|
+
keepFonts: libConfigs.flatMap((libConfig) =>
|
|
500
|
+
(libConfig?.assets.keepFonts ?? []).map((glob) => `libs/${libConfig?.lib.name}/${glob}`),
|
|
501
|
+
),
|
|
484
502
|
};
|
|
485
503
|
}
|
|
486
504
|
#resolveProductionDependencyVersion(lib: string) {
|
|
@@ -592,12 +610,14 @@ export class AkanLibConfig implements LibConfigResult {
|
|
|
592
610
|
lib: Lib;
|
|
593
611
|
externalLibs: string[];
|
|
594
612
|
docker: LibDockerConfig;
|
|
613
|
+
assets: LibAssetsConfig;
|
|
595
614
|
/** Live-only: plugins declared in this lib's `akan.config.ts` (never serialized). */
|
|
596
615
|
plugins: AkanPlugin[];
|
|
597
616
|
constructor(lib: Lib, config: DeepPartial<LibConfigResult>, plugins: AkanPlugin[] = []) {
|
|
598
617
|
this.lib = lib;
|
|
599
618
|
this.externalLibs = config?.externalLibs ?? [];
|
|
600
619
|
this.docker = { preRuns: config?.docker?.preRuns ?? [], postRuns: config?.docker?.postRuns ?? [] };
|
|
620
|
+
this.assets = { keepFonts: normalizeKeepFonts(config?.assets?.keepFonts as string[] | undefined) };
|
|
601
621
|
this.plugins = plugins;
|
|
602
622
|
}
|
|
603
623
|
static async from(lib: Lib, { bustImportCache = false }: { bustImportCache?: boolean } = {}) {
|
package/akanConfig/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type {
|
|
2
|
+
AkanAssetsConfig,
|
|
2
3
|
AkanConfigFile,
|
|
3
4
|
AkanExecutor,
|
|
4
5
|
AkanMobileConfig,
|
|
@@ -23,6 +24,7 @@ export type {
|
|
|
23
24
|
DockerOption,
|
|
24
25
|
DockerRun,
|
|
25
26
|
FileConventionScanResult,
|
|
27
|
+
LibAssetsConfig,
|
|
26
28
|
LibConfig,
|
|
27
29
|
LibConfigInput,
|
|
28
30
|
LibConfigResult,
|
|
@@ -7,7 +7,13 @@ import type {
|
|
|
7
7
|
ApplicationBuildResult,
|
|
8
8
|
} from "./applicationBuildReporter";
|
|
9
9
|
import type { App } from "./commandDecorators";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
AllRoutesBuilder,
|
|
12
|
+
CsrArtifactBuilder,
|
|
13
|
+
FontPruner,
|
|
14
|
+
precompressArtifacts,
|
|
15
|
+
SsrBaseArtifactBuilder,
|
|
16
|
+
} from "./frontendBuild";
|
|
11
17
|
import { Spinner } from "./spinner";
|
|
12
18
|
|
|
13
19
|
export interface TypecheckOptions {
|
|
@@ -15,7 +21,7 @@ export interface TypecheckOptions {
|
|
|
15
21
|
incremental?: boolean;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
|
-
export type BuildPhaseId = "prepare" | "typecheck" | "backend" | "ssr" | "csr" | "compress" | "metadata";
|
|
24
|
+
export type BuildPhaseId = "prepare" | "typecheck" | "backend" | "ssr" | "csr" | "assets" | "compress" | "metadata";
|
|
19
25
|
|
|
20
26
|
export type BuildPhaseResult = ApplicationBuildPhaseResult & { id: BuildPhaseId };
|
|
21
27
|
export type BuildResult = ApplicationBuildResult;
|
|
@@ -37,6 +43,7 @@ const BUILD_PHASE_EMOJIS: Record<BuildPhaseId, string> = {
|
|
|
37
43
|
backend: "📦",
|
|
38
44
|
ssr: "🧭",
|
|
39
45
|
csr: "🎨",
|
|
46
|
+
assets: "✂️",
|
|
40
47
|
compress: "🗜️",
|
|
41
48
|
metadata: "📝",
|
|
42
49
|
};
|
|
@@ -77,7 +84,7 @@ export class ApplicationBuildRunner {
|
|
|
77
84
|
async build({ spinner = false }: BuildOptions = {}): Promise<BuildResult> {
|
|
78
85
|
// serial build is needed because of Bun.build is unstable for parallel build
|
|
79
86
|
const phaseOptions = { spinner };
|
|
80
|
-
const { web } = await this.#app.getConfig();
|
|
87
|
+
const { web, assets } = await this.#app.getConfig();
|
|
81
88
|
await this.#runPhase("prepare", "Preparing output directory", () => this.#app.prepareCommand("build"), undefined, {
|
|
82
89
|
spinner,
|
|
83
90
|
});
|
|
@@ -108,6 +115,16 @@ export class ApplicationBuildRunner {
|
|
|
108
115
|
(result) => result?.outputDir ?? (web.csr ? "skipped" : "disabled by akan.config.ts web.csr"),
|
|
109
116
|
phaseOptions,
|
|
110
117
|
);
|
|
118
|
+
await this.#runPhase(
|
|
119
|
+
"assets",
|
|
120
|
+
"Trimming unread static assets",
|
|
121
|
+
async () => (assets.pruneFonts ? await new FontPruner(this.#app, assets).prune() : null),
|
|
122
|
+
(result) =>
|
|
123
|
+
result
|
|
124
|
+
? `${result.removed.length} font file(s) dropped, ${ApplicationBuildRunner.formatBytes(result.freedBytes)} freed; ${result.kept.length} kept`
|
|
125
|
+
: "disabled by akan.config.ts assets.pruneFonts",
|
|
126
|
+
phaseOptions,
|
|
127
|
+
);
|
|
111
128
|
await this.#runPhase(
|
|
112
129
|
"compress",
|
|
113
130
|
"Compressing static assets",
|
|
@@ -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
|
+
}
|
package/frontendBuild/index.ts
CHANGED
|
@@ -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";
|
|
@@ -118,11 +118,7 @@ export default config;
|
|
|
118
118
|
),
|
|
119
119
|
this.writeFile(
|
|
120
120
|
"env/env.client.ts",
|
|
121
|
-
`
|
|
122
|
-
|
|
123
|
-
export const env = {
|
|
124
|
-
...getEnv(),
|
|
125
|
-
} as const;
|
|
121
|
+
`export const env = {} as const;
|
|
126
122
|
`,
|
|
127
123
|
),
|
|
128
124
|
this.writeFile(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.68",
|
|
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.
|
|
48
|
+
"akanjs": "3.0.0-alpha.68",
|
|
49
49
|
"chalk": "^5.6.2",
|
|
50
50
|
"commander": "^14.0.3",
|
|
51
51
|
"dayjs": "^1.11.20",
|