@akanjs/devkit 3.0.0-alpha.11 → 3.0.0-alpha.111
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/CHANGELOG.md +15 -0
- package/DEV_RUNTIME_KNOBS.md +34 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/agentsIndex.test.ts +10 -0
- package/agentsIndex.ts +21 -1
- package/aiEditor.ts +1 -1
- package/akanApp/BackendImportGraph.test.ts +120 -0
- package/akanApp/BackendImportGraph.ts +167 -0
- package/akanApp/akanApp.host.test.ts +16 -678
- package/akanApp/akanApp.host.ts +113 -578
- package/akanApp/devHostPolicy.test.ts +542 -0
- package/akanApp/devHostPolicy.ts +442 -0
- package/akanApp/index.ts +2 -0
- package/akanConfig/akanConfig.test.ts +182 -14
- package/akanConfig/akanConfig.ts +143 -47
- package/akanConfig/types.ts +9 -0
- package/akanContext.ts +30 -14
- package/akanMcpContract.ts +25 -4
- package/appSelectionMemory.ts +28 -0
- package/applicationBuildRunner.test.ts +37 -2
- package/applicationBuildRunner.ts +52 -23
- package/artifact/implicitRootLayout.test.ts +70 -1
- package/artifact/implicitRootLayout.ts +41 -14
- package/artifact/routeSeedIndex.test.ts +1 -0
- package/biome.base.json +28 -1
- package/biomeBase.ts +1 -1
- package/capacitorApp.ts +10 -0
- package/cloud/globalConfig.ts +9 -2
- package/codegenLock.test.ts +137 -0
- package/codegenLock.ts +136 -0
- package/commandDecorators/argMeta.ts +9 -1
- package/commandDecorators/command.ts +94 -7
- package/commandDecorators/commandBuilder.ts +20 -5
- package/commandDecorators/commandDecorators.test.ts +115 -3
- package/commandDecorators/helpFormatter.ts +4 -0
- package/dependencyScanner.test.ts +99 -0
- package/dependencyScanner.ts +27 -24
- package/devkitUtils.test.ts +0 -104
- package/executors.test.ts +135 -7
- package/executors.ts +115 -25
- package/fileEditor.ts +19 -19
- package/fileSys.ts +19 -1
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/frontendBuild/autoImportSync.test.ts +58 -0
- package/frontendBuild/autoImportSync.ts +7 -2
- package/frontendBuild/buildRouteClient.test.ts +47 -24
- package/frontendBuild/clientBuildTypes.ts +4 -0
- package/frontendBuild/clientEntriesBundler.ts +4 -1
- package/frontendBuild/clientEntryDiscovery.ts +2 -2
- package/frontendBuild/csrArtifactBuilder.ts +116 -84
- package/frontendBuild/cssCompiler.ts +63 -10
- package/frontendBuild/devGeneratedIndexSync.ts +3 -2
- package/frontendBuild/fontOptimizer.test.ts +75 -2
- package/frontendBuild/fontOptimizer.ts +98 -27
- package/frontendBuild/fontPruner.test.ts +220 -0
- package/frontendBuild/fontPruner.ts +206 -0
- package/frontendBuild/frontendBuild.test.ts +124 -10
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/index.ts +1 -1
- package/frontendBuild/pagesBundleBuilder.ts +3 -3
- package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
- package/frontendBuild/routeClientBuilder.ts +12 -5
- package/frontendBuild/sourceMtimeIndex.ts +1 -1
- package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
- package/frontendBuild/styleContract.ts +14 -20
- package/frontendBuild/themeValidator.ts +22 -17
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/frontendBuild/watchRootResolver.test.ts +129 -0
- package/frontendBuild/watchRootResolver.ts +66 -1
- package/incrementalBuilder/devWatchBatch.test.ts +18 -20
- package/incrementalBuilder/devWatchBatch.ts +6 -2
- package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
- package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
- package/index.ts +0 -7
- package/integration/devStabilityHarness.ts +13 -19
- package/libSource.test.ts +112 -0
- package/libSource.ts +126 -0
- package/lint/__fixtures__/README.md +40 -0
- package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
- package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
- package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
- package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
- package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
- package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
- package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
- package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
- package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
- package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
- package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
- package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
- package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
- package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
- package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
- package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
- package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
- package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
- package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
- package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
- package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
- package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
- package/lint/gritRules.test.ts +178 -0
- package/lint/no-arbitrary-color.grit +1 -1
- package/lint/no-async-component-in-ui.grit +35 -0
- package/lint/no-bang-comment-in-client.grit +23 -10
- package/lint/no-daisyui-legacy-class.grit +26 -9
- package/lint/no-deprecated-log-level.grit +17 -0
- package/lint/no-init-fetch-in-client.grit +47 -0
- package/lint/no-inline-color.grit +10 -8
- package/lint/no-interpolated-arbitrary-class.grit +3 -3
- package/lint/no-model-type-in-util-zone.grit +58 -0
- package/lint/no-raw-palette-class.grit +3 -3
- package/lint/no-unpublished-form-setter.grit +41 -0
- package/lint/non-scalar-props-restricted.grit +16 -7
- package/linter.test.ts +80 -0
- package/linter.ts +82 -13
- package/package.json +5 -8
- package/prompter.ts +9 -4
- package/qualityScanner.test.ts +80 -142
- package/qualityScanner.ts +89 -31
- package/recipeScanner.ts +4 -1
- package/routeSourceValidator.test.ts +63 -0
- package/routeSourceValidator.ts +123 -16
- package/scanInfo.ts +32 -26
- package/semver.test.ts +26 -0
- package/semver.ts +31 -0
- package/slicePlanner.test.ts +182 -0
- package/slicePlanner.ts +235 -0
- package/ssrScanner.test.ts +301 -0
- package/subspace.test.ts +418 -0
- package/subspace.ts +723 -0
- package/subspaceConfig.ts +76 -0
- package/transforms/asyncDefaultExportDetector.ts +105 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/externalizeFrameworkPlugin.ts +0 -1
- package/transforms/transforms.test.ts +5 -5
- package/transforms/tsconfigPackageResolver.test.ts +230 -0
- package/tsconfig.json +6 -2
- package/typeChecker.ts +1 -1
- package/types.ts +1 -0
- package/uploadRelease.ts +2 -2
- package/workflow/executor.test.ts +146 -0
- package/workspaceLayout.test.ts +56 -4
- package/workspaceLayout.ts +48 -2
- package/builder.ts +0 -164
- package/extractDeps.ts +0 -86
- package/frontendBuild/styleGuard.test.ts +0 -165
- package/frontendBuild/styleGuard.ts +0 -322
- package/getCredentials.ts +0 -19
- package/getModelFileData.ts +0 -62
- package/mcpScanner.ts +0 -217
- package/src/capacitorApp.ts +0 -282
- package/storeScanner.ts +0 -173
- package/streamAi.ts +0 -45
- package/ui/MultiScrollList.tsx +0 -242
- package/ui/ScrollList.tsx +0 -107
- package/ui/index.ts +0 -2
- package/useStdoutDimensions.ts +0 -20
|
@@ -17,15 +17,16 @@ const makeApp = async (layoutSource: string) => {
|
|
|
17
17
|
await mkdir(path.join(cwdPath, "public/fonts"), { recursive: true });
|
|
18
18
|
await writeFile(path.join(cwdPath, "page/_layout.tsx"), layoutSource);
|
|
19
19
|
await Bun.write(path.join(cwdPath, "public/fonts/Assistant-Regular.woff2"), Bun.file(SOURCE_FONT));
|
|
20
|
+
const warnings: string[] = [];
|
|
20
21
|
const app = {
|
|
21
22
|
cwdPath,
|
|
22
23
|
dist: { cwdPath: path.join(root, "dist/apps/demo") },
|
|
23
24
|
workspace: { workspaceRoot: root },
|
|
24
25
|
getPageKeys: async () => ["./_layout.tsx"],
|
|
25
26
|
verbose: () => undefined,
|
|
26
|
-
logger: { warn: () =>
|
|
27
|
+
logger: { warn: (message: string) => warnings.push(message) },
|
|
27
28
|
} as unknown as App;
|
|
28
|
-
return { app, cwdPath };
|
|
29
|
+
return { app, cwdPath, warnings };
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
const layoutWith = (extra = "") => `
|
|
@@ -40,6 +41,22 @@ export default function Layout() {
|
|
|
40
41
|
}
|
|
41
42
|
`;
|
|
42
43
|
|
|
44
|
+
const chainLayoutWith = (declaration: string) => `
|
|
45
|
+
import { rootLayout } from "akanjs/client";
|
|
46
|
+
|
|
47
|
+
export default rootLayout()
|
|
48
|
+
.fonts(${declaration})
|
|
49
|
+
.theme("dark")
|
|
50
|
+
.render(({ children }) => children);
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
const chainFontEntry = (extra = "") => `{
|
|
54
|
+
name: "Assistant",${extra}
|
|
55
|
+
paths: [{ src: "/fonts/Assistant-Regular.woff2", weight: 400 }],
|
|
56
|
+
}`;
|
|
57
|
+
|
|
58
|
+
const chainFontList = (extra = "") => `[\n ${chainFontEntry(extra)},\n ]`;
|
|
59
|
+
|
|
43
60
|
const optimize = (app: App) => new FontOptimizer(app, "start").optimize();
|
|
44
61
|
|
|
45
62
|
afterEach(async () => {
|
|
@@ -109,3 +126,59 @@ describe("FontOptimizer cache", () => {
|
|
|
109
126
|
expect(result.files).toEqual([]);
|
|
110
127
|
});
|
|
111
128
|
});
|
|
129
|
+
|
|
130
|
+
describe("FontOptimizer discovery", () => {
|
|
131
|
+
test("reads the .fonts() stage of a rootLayout() chain", async () => {
|
|
132
|
+
const { app, warnings } = await makeApp(chainLayoutWith(chainFontList()));
|
|
133
|
+
|
|
134
|
+
const result = await optimize(app);
|
|
135
|
+
|
|
136
|
+
expect(result.fonts.map((font) => font.name)).toEqual(["Assistant"]);
|
|
137
|
+
expect(result.files).toHaveLength(1);
|
|
138
|
+
expect(result.css).toContain("@font-face");
|
|
139
|
+
expect(warnings).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("keeps reading the legacy fonts export", async () => {
|
|
143
|
+
const { app } = await makeApp(layoutWith());
|
|
144
|
+
const result = await optimize(app);
|
|
145
|
+
expect(result.fonts.map((font) => font.name)).toEqual(["Assistant"]);
|
|
146
|
+
expect(result.files).toHaveLength(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("picks up a config field the chain declares, so the cache key sees it", async () => {
|
|
150
|
+
const { app, cwdPath } = await makeApp(chainLayoutWith(chainFontList()));
|
|
151
|
+
const first = await optimize(app);
|
|
152
|
+
|
|
153
|
+
await writeFile(
|
|
154
|
+
path.join(cwdPath, "page/_layout.tsx"),
|
|
155
|
+
chainLayoutWith(chainFontList(`\n className: "font-brand",`)),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const second = await optimize(app);
|
|
159
|
+
expect(second.css).not.toBe(first.css);
|
|
160
|
+
expect(second.css).toContain(".font-brand");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// A list the build cannot read subsets nothing while the runtime still preloads /_akan/fonts, so the 404s
|
|
164
|
+
// have to be announced at build time rather than found in a browser console.
|
|
165
|
+
test("warns when the chain is handed a font list it cannot read", async () => {
|
|
166
|
+
const { app, warnings } = await makeApp(chainLayoutWith("brandFonts"));
|
|
167
|
+
|
|
168
|
+
const result = await optimize(app);
|
|
169
|
+
|
|
170
|
+
expect(result.fonts).toEqual([]);
|
|
171
|
+
expect(result.files).toEqual([]);
|
|
172
|
+
expect(warnings).toHaveLength(1);
|
|
173
|
+
expect(warnings[0]).toContain("page/_layout.tsx");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("warns about an entry it cannot read and keeps the ones it can", async () => {
|
|
177
|
+
const { app, warnings } = await makeApp(chainLayoutWith(`[...brandFonts, ${chainFontEntry()}]`));
|
|
178
|
+
|
|
179
|
+
const result = await optimize(app);
|
|
180
|
+
|
|
181
|
+
expect(result.fonts.map((font) => font.name)).toEqual(["Assistant"]);
|
|
182
|
+
expect(warnings).toHaveLength(1);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -43,7 +43,9 @@ export class FontOptimizer {
|
|
|
43
43
|
#woff2Ready: Promise<void> | null = null;
|
|
44
44
|
|
|
45
45
|
static #ksX1001Text: string | null = null;
|
|
46
|
-
|
|
46
|
+
// 2: the key moved to sha256 over a deterministically ordered `auto` text, so v1 entries cannot be
|
|
47
|
+
// compared against and their subsets were built from `page`/`ui` only.
|
|
48
|
+
static readonly #cacheVersion = 2;
|
|
47
49
|
|
|
48
50
|
constructor(app: App, command: FontOptimizerCommand = "start") {
|
|
49
51
|
this.#app = app;
|
|
@@ -96,9 +98,18 @@ export class FontOptimizer {
|
|
|
96
98
|
}
|
|
97
99
|
// `auto` derives the subset from app source text, which no font config hash can capture.
|
|
98
100
|
if (this.#getFontSubsets(font).includes("auto"))
|
|
99
|
-
sources.push({ autoSubsetText: this.#
|
|
101
|
+
sources.push({ autoSubsetText: this.#cacheDigest(await this.#collectAutoSubsetText()) });
|
|
100
102
|
}
|
|
101
|
-
return this.#
|
|
103
|
+
return this.#cacheDigest({ version: FontOptimizer.#cacheVersion, fonts, sources });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Cache keys get a cryptographic digest, not the 32-bit FNV `#hashFontConfig` computes for filenames:
|
|
108
|
+
* a collision there serves a stale subset as if it were current, while a filename only has to be short
|
|
109
|
+
* and stable.
|
|
110
|
+
*/
|
|
111
|
+
#cacheDigest(value: unknown) {
|
|
112
|
+
return new Bun.CryptoHasher("sha256").update(this.#stableStringify(value)).digest("hex");
|
|
102
113
|
}
|
|
103
114
|
|
|
104
115
|
async #fileStamp(filePath: string): Promise<{ mtimeMs: number; size: number } | null> {
|
|
@@ -141,7 +152,7 @@ export class FontOptimizer {
|
|
|
141
152
|
const file = Bun.file(filePath);
|
|
142
153
|
if (!(await file.exists())) return;
|
|
143
154
|
const source = await file.text();
|
|
144
|
-
// A
|
|
155
|
+
// A `.fonts()` stage or a `fonts` declaration cannot exist in text that never mentions it, and parsing the
|
|
145
156
|
// route files that never declare one is what a cached optimize() otherwise spends its time on.
|
|
146
157
|
if (!source.includes("fonts")) return;
|
|
147
158
|
fonts.push(...this.#extractFontsExport(source, filePath));
|
|
@@ -175,23 +186,75 @@ export class FontOptimizer {
|
|
|
175
186
|
if (faceCss.length > 0) this.#cssParts.push(...faceCss, this.#buildRootVariableRule(font));
|
|
176
187
|
}
|
|
177
188
|
|
|
189
|
+
/**
|
|
190
|
+
* The font list of a route file, from the `.fonts([…])` stage of its default-exported `rootLayout()` chain
|
|
191
|
+
* or from the legacy `export const fonts`. Only an inline literal is readable, because the build enumerates
|
|
192
|
+
* routes without evaluating them — and a list it cannot read is warned about rather than skipped in silence:
|
|
193
|
+
* the runtime still emits `/_akan/fonts` preloads for fonts nothing subset, and every one of them 404s.
|
|
194
|
+
*/
|
|
178
195
|
#extractFontsExport(source: string, filePath: string): ReactFont[] {
|
|
179
196
|
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
180
|
-
const
|
|
197
|
+
const declaration = this.#findFontsDeclaration(sourceFile);
|
|
198
|
+
if (!declaration) return [];
|
|
199
|
+
const value = this.#literalToValue(declaration);
|
|
200
|
+
const entries: unknown[] = Array.isArray(value) ? value : [];
|
|
201
|
+
const fonts = entries.filter((entry): entry is ReactFont => this.#isReadableFont(entry));
|
|
202
|
+
if (fonts.length !== entries.length || !Array.isArray(value))
|
|
203
|
+
this.#app.logger.warn(
|
|
204
|
+
`[font] ${path.relative(this.#app.cwdPath, filePath)} declares fonts the build cannot read without evaluating the module — write the list inline, or nothing is subset for it and every /_akan/fonts request 404s`,
|
|
205
|
+
);
|
|
206
|
+
return fonts.map((font) => this.#withFontDefaults(font));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#isReadableFont(value: unknown): value is ReactFont {
|
|
210
|
+
if (!value || typeof value !== "object") return false;
|
|
211
|
+
const font = value as Partial<ReactFont>;
|
|
212
|
+
return typeof font.name === "string" && Array.isArray(font.paths);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#findFontsDeclaration(sourceFile: ts.SourceFile): ts.Expression | null {
|
|
181
216
|
for (const statement of sourceFile.statements) {
|
|
217
|
+
if (ts.isExportAssignment(statement)) {
|
|
218
|
+
if (statement.isExportEquals) continue;
|
|
219
|
+
const stage = this.#findChainStageArgument(statement.expression, "fonts");
|
|
220
|
+
if (stage) return stage;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
182
223
|
if (!ts.isVariableStatement(statement)) continue;
|
|
183
224
|
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
|
|
184
|
-
|
|
185
|
-
if (!isExported) continue;
|
|
225
|
+
if (!modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue;
|
|
186
226
|
for (const declaration of statement.declarationList.declarations) {
|
|
187
227
|
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "fonts") continue;
|
|
188
|
-
|
|
189
|
-
if (Array.isArray(value)) {
|
|
190
|
-
fonts.push(...(value as ReactFont[]).map((font) => this.#withFontDefaults(font)));
|
|
191
|
-
}
|
|
228
|
+
if (declaration.initializer) return declaration.initializer;
|
|
192
229
|
}
|
|
193
230
|
}
|
|
194
|
-
return
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Walks a `rootLayout()` chain from its outermost call inward, so the stage that wins at runtime is the one read. */
|
|
235
|
+
#findChainStageArgument(expression: ts.Expression, stageName: string): ts.Expression | null {
|
|
236
|
+
let node = this.#unwrapExpression(expression);
|
|
237
|
+
let argument: ts.Expression | null = null;
|
|
238
|
+
while (node && ts.isCallExpression(node)) {
|
|
239
|
+
const callee = this.#unwrapExpression(node.expression);
|
|
240
|
+
if (!callee) return null;
|
|
241
|
+
if (ts.isIdentifier(callee)) return callee.text === "rootLayout" ? argument : null;
|
|
242
|
+
if (!ts.isPropertyAccessExpression(callee)) return null;
|
|
243
|
+
if (callee.name.text === stageName) argument ??= node.arguments[0] ?? null;
|
|
244
|
+
node = this.#unwrapExpression(callee.expression);
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#unwrapExpression(expression?: ts.Expression): ts.Expression | undefined {
|
|
250
|
+
let current = expression;
|
|
251
|
+
while (
|
|
252
|
+
current &&
|
|
253
|
+
(ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isParenthesizedExpression(current))
|
|
254
|
+
) {
|
|
255
|
+
current = current.expression;
|
|
256
|
+
}
|
|
257
|
+
return current;
|
|
195
258
|
}
|
|
196
259
|
|
|
197
260
|
#literalToValue(node: ts.Node): unknown {
|
|
@@ -387,26 +450,34 @@ export class FontOptimizer {
|
|
|
387
450
|
return "";
|
|
388
451
|
}
|
|
389
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Every source that can put a glyph on screen, concatenated in a **stable** order.
|
|
455
|
+
*
|
|
456
|
+
* The order is load-bearing even though a glyph set is not: `#buildCacheKey` hashes this string, so
|
|
457
|
+
* reading the roots concurrently and pushing as each file resolved made the key depend on i/o
|
|
458
|
+
* scheduling — measured 8 distinct keys over 8 runs against unchanged sources, which means the cache
|
|
459
|
+
* never hit and every build re-subset the fonts.
|
|
460
|
+
*
|
|
461
|
+
* `lib` is in the roots because that is where user-facing text actually lives: a dictionary's
|
|
462
|
+
* `[en, ko]` pairs are the Korean in the app, and a subset built from `page` and `ui` alone renders
|
|
463
|
+
* them as tofu — while hashing the same partial text also stopped a new label from invalidating.
|
|
464
|
+
*/
|
|
390
465
|
async #collectAutoSubsetText() {
|
|
391
466
|
//* Synced lib pages hold app-visible text too, and a glob never crosses the symlink that mounts them.
|
|
392
467
|
const libPageRoots = (await this.#app.getPageRoots()).filter((root) => root.keyPrefix).map((root) => root.dir);
|
|
393
|
-
const roots = [...["page", "ui"].map((dir) => path.join(this.#app.cwdPath, dir)), ...libPageRoots];
|
|
468
|
+
const roots = [...["page", "ui", "lib"].map((dir) => path.join(this.#app.cwdPath, dir)), ...libPageRoots];
|
|
394
469
|
const glob = new Bun.Glob("**/*.{ts,tsx,js,jsx,html,md}");
|
|
395
470
|
const parts: string[] = [];
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
parts.push(await Bun.file(filePath).text());
|
|
407
|
-
}
|
|
408
|
-
}),
|
|
409
|
-
);
|
|
471
|
+
for (const root of [...new Set(roots)].sort()) {
|
|
472
|
+
const isDir = await stat(root).then(
|
|
473
|
+
(entry) => entry.isDirectory(),
|
|
474
|
+
() => false,
|
|
475
|
+
);
|
|
476
|
+
if (!isDir) continue;
|
|
477
|
+
const filePaths: string[] = [];
|
|
478
|
+
for await (const filePath of glob.scan({ cwd: root, absolute: true })) filePaths.push(filePath);
|
|
479
|
+
for (const filePath of filePaths.sort()) parts.push(await Bun.file(filePath).text());
|
|
480
|
+
}
|
|
410
481
|
return parts.join("");
|
|
411
482
|
}
|
|
412
483
|
|
|
@@ -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
|
+
});
|