@akanjs/devkit 3.0.0-alpha.9 → 3.0.0-alpha.91
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 +27 -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 +1 -1
- package/applicationBuildRunner.ts +45 -21
- package/artifact/implicitRootLayout.test.ts +67 -0
- package/artifact/implicitRootLayout.ts +27 -7
- package/artifact/routeSeedIndex.test.ts +1 -0
- package/biome.base.json +86 -47
- package/biomeBase.ts +6 -5
- 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 +129 -4
- package/executors.ts +105 -24
- 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 +123 -12
- package/frontendBuild/cssImportResolver.ts +8 -7
- package/frontendBuild/devGeneratedIndexSync.ts +3 -2
- package/frontendBuild/fontOptimizer.ts +37 -18
- package/frontendBuild/fontPruner.test.ts +220 -0
- package/frontendBuild/fontPruner.ts +206 -0
- package/frontendBuild/frontendBuild.test.ts +178 -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 +67 -0
- package/frontendBuild/watchRootResolver.ts +13 -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 +2 -10
- 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 +99 -25
- package/package.json +5 -8
- package/prompter.ts +9 -4
- package/qualityScanner.test.ts +116 -0
- package/qualityScanner.ts +89 -21
- package/recipeScanner.ts +4 -1
- 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 +103 -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/src/capacitorApp.ts +0 -282
- 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,6 +17,12 @@ interface CssDiscovery {
|
|
|
17
17
|
sourcePaths: string[];
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** One `@import` target and the custom properties it declares, which is what proves it arrived downstream. */
|
|
21
|
+
export interface ImportedStylesheet {
|
|
22
|
+
cssPath: string;
|
|
23
|
+
declaredNames: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
20
26
|
export class CssCompiler {
|
|
21
27
|
#logger = new Logger("CssCompiler");
|
|
22
28
|
#transpiler = new Bun.Transpiler({ loader: "tsx" });
|
|
@@ -46,6 +52,13 @@ export class CssCompiler {
|
|
|
46
52
|
#fileExistsCache = new Map<string, Promise<boolean>>();
|
|
47
53
|
#resolvedFileCache = new Map<string, Promise<string | null>>();
|
|
48
54
|
#resolvedSpecifierCache = new Map<string, Promise<string | null>>();
|
|
55
|
+
/** Every stylesheet this compile reached, entry points and `@import` targets alike. */
|
|
56
|
+
#discoveredCssPaths = new Set<string>();
|
|
57
|
+
/**
|
|
58
|
+
* `@import` targets per base path, so whoever writes the asset can check the file it actually wrote — the
|
|
59
|
+
* compiled text and the written artifact are two different places a declaration can go missing.
|
|
60
|
+
*/
|
|
61
|
+
importedStylesheetsByBasePath: Record<string, ImportedStylesheet[]> = {};
|
|
49
62
|
|
|
50
63
|
#fileExists(absPath: string): Promise<boolean> {
|
|
51
64
|
let cached = this.#fileExistsCache.get(absPath);
|
|
@@ -76,13 +89,20 @@ export class CssCompiler {
|
|
|
76
89
|
}
|
|
77
90
|
async getCss({ refresh }: { refresh?: boolean } = {}) {
|
|
78
91
|
if (this.#cssText !== null && !refresh) return this.#cssText;
|
|
92
|
+
this.#discoveredCssPaths.clear();
|
|
93
|
+
this.importedStylesheetsByBasePath = {};
|
|
79
94
|
const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh });
|
|
80
|
-
|
|
95
|
+
const { css, imported } = await this.#compileWithImports(cssPaths, sourcePaths);
|
|
96
|
+
this.#cssText = css;
|
|
97
|
+
this.importedStylesheetsByBasePath = { "": imported };
|
|
98
|
+
await this.#warnUnreachableStylesheets();
|
|
81
99
|
return this.#cssText;
|
|
82
100
|
}
|
|
83
101
|
|
|
84
102
|
async getCssByBasePath({ refresh }: { refresh?: boolean } = {}): Promise<Record<string, string>> {
|
|
85
103
|
if (this.#cssTextByBasePath !== null && !refresh) return this.#cssTextByBasePath;
|
|
104
|
+
this.#discoveredCssPaths.clear();
|
|
105
|
+
this.importedStylesheetsByBasePath = {};
|
|
86
106
|
const akanConfig = await this.#app.getConfig({ refresh });
|
|
87
107
|
const pageKeys = await this.#app.getPageKeys({ refresh });
|
|
88
108
|
const basePaths = [...akanConfig.basePaths];
|
|
@@ -92,7 +112,8 @@ export class CssCompiler {
|
|
|
92
112
|
if (rootPageKeys.length === 0) return ["", ""] as const;
|
|
93
113
|
const started = Date.now();
|
|
94
114
|
const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh, pageKeys: rootPageKeys });
|
|
95
|
-
const css = await this
|
|
115
|
+
const { css, imported } = await this.#compileWithImports(cssPaths, sourcePaths);
|
|
116
|
+
this.importedStylesheetsByBasePath[""] = imported;
|
|
96
117
|
this.#logger.verbose(
|
|
97
118
|
`css base=root paths=${cssPaths.length} sources=${sourcePaths.length} in ${Date.now() - started}ms`,
|
|
98
119
|
);
|
|
@@ -103,7 +124,8 @@ export class CssCompiler {
|
|
|
103
124
|
if (basePathPageKeys.length === 0) return [basePath, ""] as const;
|
|
104
125
|
const started = Date.now();
|
|
105
126
|
const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh, pageKeys: basePathPageKeys });
|
|
106
|
-
const css = await this
|
|
127
|
+
const { css, imported } = await this.#compileWithImports(cssPaths, sourcePaths);
|
|
128
|
+
this.importedStylesheetsByBasePath[basePath] = imported;
|
|
107
129
|
this.#logger.verbose(
|
|
108
130
|
`css base=${basePath} paths=${cssPaths.length} sources=${sourcePaths.length} in ${Date.now() - started}ms`,
|
|
109
131
|
);
|
|
@@ -111,9 +133,26 @@ export class CssCompiler {
|
|
|
111
133
|
}),
|
|
112
134
|
]);
|
|
113
135
|
this.#cssTextByBasePath = Object.fromEntries(cssEntries);
|
|
136
|
+
await this.#warnUnreachableStylesheets();
|
|
114
137
|
return this.#cssTextByBasePath;
|
|
115
138
|
}
|
|
116
139
|
|
|
140
|
+
/**
|
|
141
|
+
* A stylesheet under `page/` reaches the build only by being imported from a route source. One that nothing
|
|
142
|
+
* imports compiles to nothing and reports success, which is indistinguishable from an empty theme — so say it
|
|
143
|
+
* out loud once per compile rather than leaving it to be noticed as unstyled elements in the browser.
|
|
144
|
+
*/
|
|
145
|
+
async #warnUnreachableStylesheets() {
|
|
146
|
+
const pageDir = path.join(this.#app.cwdPath, "page");
|
|
147
|
+
const glob = new Bun.Glob("**/*.css");
|
|
148
|
+
for await (const cssPath of glob.scan({ cwd: pageDir, absolute: true })) {
|
|
149
|
+
// `(libs)` is a link farm: the same file is discovered under its real path in `libs/`, never this one.
|
|
150
|
+
if (cssPath.includes(`${path.sep}(libs)${path.sep}`)) continue;
|
|
151
|
+
if (this.#discoveredCssPaths.has(cssPath)) continue;
|
|
152
|
+
this.#logger.warn(`css ${path.relative(this.#app.cwdPath, cssPath)} is imported by no route and never compiled`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
117
156
|
async discoverCss({ refresh }: { refresh?: boolean } = {}): Promise<string[]> {
|
|
118
157
|
const { cssPaths } = await this.discoverCssAndSources({ refresh });
|
|
119
158
|
return cssPaths;
|
|
@@ -180,22 +219,61 @@ export class CssCompiler {
|
|
|
180
219
|
}
|
|
181
220
|
}
|
|
182
221
|
|
|
183
|
-
|
|
222
|
+
const tokenPaths = await this.#libTokenStylesheets(sourceFiles);
|
|
223
|
+
const cssPaths = [...new Set([...tokenPaths, ...cssFiles])];
|
|
224
|
+
for (const cssPath of cssPaths) this.#discoveredCssPaths.add(cssPath);
|
|
225
|
+
return { cssPaths, sourcePaths: [...sourceFiles] };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* `libs/<lib>/ui/tokens.css` of every lib the page graph reached, so a lib can own the fixed colours its own
|
|
230
|
+
* components need instead of each consuming app re-declaring them. Ordered ahead of the app's stylesheets:
|
|
231
|
+
* the app is the last word on any variable both declare.
|
|
232
|
+
*/
|
|
233
|
+
async #libTokenStylesheets(sourceFiles: Set<string>): Promise<string[]> {
|
|
234
|
+
const libsRoot = path.join(this.#app.workspace.workspaceRoot, "libs");
|
|
235
|
+
const libNames = new Set<string>();
|
|
236
|
+
for (const filePath of sourceFiles) {
|
|
237
|
+
const relPath = path.relative(libsRoot, filePath);
|
|
238
|
+
if (relPath.startsWith("..") || path.isAbsolute(relPath)) continue;
|
|
239
|
+
const [libName] = relPath.split(path.sep);
|
|
240
|
+
if (libName) libNames.add(libName);
|
|
241
|
+
}
|
|
242
|
+
const tokenPaths = await Promise.all(
|
|
243
|
+
[...libNames].sort().map(async (libName) => {
|
|
244
|
+
const tokensPath = path.join(libsRoot, libName, "ui/tokens.css");
|
|
245
|
+
return (await this.#fileExists(tokensPath)) ? tokensPath : null;
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
return tokenPaths.filter((tokensPath): tokensPath is string => !!tokensPath);
|
|
184
249
|
}
|
|
185
250
|
async compileCss(cssPaths: string[], sourcePaths: string[]): Promise<string> {
|
|
186
|
-
|
|
251
|
+
const { css } = await this.#compileWithImports(cssPaths, sourcePaths);
|
|
252
|
+
return css;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The collector is per compile rather than per compiler instance: `getCssByBasePath` compiles every base path
|
|
257
|
+
* concurrently, so instance state would mix one base path's imports into another's check.
|
|
258
|
+
*/
|
|
259
|
+
async #compileWithImports(
|
|
260
|
+
cssPaths: string[],
|
|
261
|
+
sourcePaths: string[],
|
|
262
|
+
): Promise<{ css: string; imported: ImportedStylesheet[] }> {
|
|
263
|
+
if (cssPaths.length === 0) return { css: "", imported: [] };
|
|
187
264
|
|
|
188
265
|
const compileStarted = Date.now();
|
|
189
266
|
const compilers = await Promise.all(
|
|
190
267
|
cssPaths.map(async (cssPath) => {
|
|
191
268
|
const css = await Bun.file(cssPath).text();
|
|
192
269
|
const base = path.dirname(cssPath);
|
|
270
|
+
const imported = new Map<string, string>();
|
|
193
271
|
const compiler = await compile(css, {
|
|
194
272
|
base,
|
|
195
|
-
loadStylesheet: (id, fromBase) => this.#loadStylesheet(id, fromBase),
|
|
273
|
+
loadStylesheet: (id, fromBase) => this.#loadStylesheet(id, fromBase, imported),
|
|
196
274
|
loadModule: (id, fromBase) => this.#loadModule(id, fromBase),
|
|
197
275
|
});
|
|
198
|
-
return { cssPath, compiler };
|
|
276
|
+
return { cssPath, compiler, imported };
|
|
199
277
|
}),
|
|
200
278
|
);
|
|
201
279
|
|
|
@@ -210,24 +288,46 @@ export class CssCompiler {
|
|
|
210
288
|
`css candidates scanned count=${candidates.length} sources=${sourcePaths.length} dirs=${sourceDirs.size} in ${Date.now() - scanStarted}ms`,
|
|
211
289
|
);
|
|
212
290
|
const parts: string[] = [];
|
|
291
|
+
const imported: ImportedStylesheet[] = [];
|
|
213
292
|
for (const entry of compilers) {
|
|
214
293
|
if (!entry) continue;
|
|
215
|
-
|
|
294
|
+
const part = entry.compiler.build(candidates);
|
|
295
|
+
parts.push(part);
|
|
296
|
+
for (const [cssPath, content] of entry.imported) {
|
|
297
|
+
const declaredNames = declaredCustomProperties(content);
|
|
298
|
+
imported.push({ cssPath, declaredNames });
|
|
299
|
+
if (declaredNames.length === 0 || declaredNames.some((name) => part.includes(`${name}:`))) continue;
|
|
300
|
+
this.#logger.warn(
|
|
301
|
+
`css @import ${cssPath} was loaded by ${entry.cssPath} but none of its ${declaredNames.length} declaration(s) reached the compiled CSS`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
216
304
|
}
|
|
217
305
|
this.#logger.verbose(
|
|
218
306
|
`css compiled paths=${cssPaths.length} candidates=${candidates.length} in ${Date.now() - compileStarted}ms`,
|
|
219
307
|
);
|
|
220
|
-
return parts.join("\n");
|
|
308
|
+
return { css: parts.join("\n"), imported };
|
|
221
309
|
}
|
|
222
310
|
|
|
223
|
-
async #loadStylesheet(id: string, fromBase: string) {
|
|
311
|
+
async #loadStylesheet(id: string, fromBase: string, imported?: Map<string, string>) {
|
|
224
312
|
const p = await this.#resolveCssImport(id, fromBase);
|
|
313
|
+
this.#discoveredCssPaths.add(p);
|
|
225
314
|
const content = await Bun.file(p).text();
|
|
315
|
+
imported?.set(p, content);
|
|
316
|
+
this.#logger.verbose(`css import "${id}" from ${fromBase} -> ${p} (${content.length} bytes)`);
|
|
226
317
|
return { path: p, base: path.dirname(p), content };
|
|
227
318
|
}
|
|
228
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Every specifier is verified here, path-shaped ones included. An `@import` the pipeline cannot resolve is
|
|
322
|
+
* a build error and never a no-op: the vocabulary closure means a component whose token declaration failed
|
|
323
|
+
* to load renders unstyled, which nothing downstream can distinguish from a design choice.
|
|
324
|
+
*/
|
|
229
325
|
async #resolveCssImport(id: string, fromBase: string): Promise<string> {
|
|
230
|
-
if (id.startsWith(".") || id.startsWith("/"))
|
|
326
|
+
if (id.startsWith(".") || id.startsWith("/")) {
|
|
327
|
+
const filePath = path.resolve(fromBase, id);
|
|
328
|
+
if (await this.#fileExists(filePath)) return filePath;
|
|
329
|
+
throw new Error(`[css] failed to resolve stylesheet import "${id}" from ${fromBase} (no file at ${filePath})`);
|
|
330
|
+
}
|
|
231
331
|
const resolver = await this.#getCssImportResolver();
|
|
232
332
|
const resolved = await resolver.resolve(id, fromBase);
|
|
233
333
|
if (resolved) return resolved;
|
|
@@ -330,7 +430,18 @@ export function isIgnoredNodeModuleSource(filePath: string): boolean {
|
|
|
330
430
|
return NODE_MODULES_RE.test(filePath) && !AKANJS_NODE_MODULE_RE.test(filePath);
|
|
331
431
|
}
|
|
332
432
|
|
|
333
|
-
|
|
433
|
+
/**
|
|
434
|
+
* `@theme` blocks are stripped first: those variables are emitted only when a utility uses one, so their
|
|
435
|
+
* absence from a build says nothing about whether the stylesheet arrived.
|
|
436
|
+
*/
|
|
437
|
+
export function declaredCustomProperties(css: string): string[] {
|
|
438
|
+
const withoutThemeBlocks = css.replace(/@theme[^{]*\{[^}]*\}/g, "");
|
|
439
|
+
return [...new Set([...withoutThemeBlocks.matchAll(/(?:^|[\s;{])(--[\w-]+)\s*:/g)].map(([, name]) => name))].filter(
|
|
440
|
+
(name): name is string => !!name,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function getPageKeyBasePath(pageKey: string, basePaths: string[]): string | null {
|
|
334
445
|
const normalized = pageKey.split(path.sep).join("/").replace(/^\.\//, "");
|
|
335
446
|
const segments = normalized.split("/");
|
|
336
447
|
const firstPublicSegment = segments.find((segment) => segment !== "[lang]" && !/^\(.+\)$/.test(segment));
|
|
@@ -106,14 +106,15 @@ export class CssImportResolver {
|
|
|
106
106
|
const pkg = await Bun.file(pkgPath).json();
|
|
107
107
|
const subpath = id === pkgName ? "." : `.${id.slice(pkgName.length)}`;
|
|
108
108
|
const exportValue = pkg.exports?.[subpath];
|
|
109
|
-
const
|
|
110
|
-
|
|
109
|
+
const exportedEntry =
|
|
110
|
+
typeof exportValue === "string"
|
|
111
111
|
? exportValue
|
|
112
|
-
: exportValue?.style || exportValue?.import || exportValue?.default
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return await this.#firstExisting(path.resolve(pkgDir,
|
|
112
|
+
: exportValue?.style || exportValue?.import || exportValue?.default;
|
|
113
|
+
if (exportedEntry) return await this.#firstExisting(path.resolve(pkgDir, exportedEntry));
|
|
114
|
+
//* A subpath names a file inside the package, so it resolves literally. Falling back to the package's own
|
|
115
|
+
//* style entry here would load a different stylesheet than the author asked for and report success.
|
|
116
|
+
if (subpath !== ".") return await this.#firstExisting(path.resolve(pkgDir, subpath));
|
|
117
|
+
return await this.#firstExisting(path.resolve(pkgDir, pkg.exports?.["."]?.style || pkg.style || "index.css"));
|
|
117
118
|
} catch {
|
|
118
119
|
return null;
|
|
119
120
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { mkdir, readdir, readFile, rm, stat
|
|
1
|
+
import { mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { FileSys } from "../fileSys";
|
|
3
4
|
|
|
4
5
|
const BARREL_FACETS = new Set(["common", "srvkit", "ui", "webkit", "plugin"]);
|
|
5
6
|
const FACET_SOURCE_FILE_RE = /\.(ts|tsx)$/;
|
|
@@ -101,7 +102,7 @@ export class DevGeneratedIndexSync {
|
|
|
101
102
|
const current = await readFile(indexPath, "utf8").catch(() => null);
|
|
102
103
|
if (current === content) return false;
|
|
103
104
|
await mkdir(dir, { recursive: true });
|
|
104
|
-
await
|
|
105
|
+
await FileSys.writeTextAtomic(indexPath, content);
|
|
105
106
|
return true;
|
|
106
107
|
}
|
|
107
108
|
|
|
@@ -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> {
|
|
@@ -387,26 +398,34 @@ export class FontOptimizer {
|
|
|
387
398
|
return "";
|
|
388
399
|
}
|
|
389
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Every source that can put a glyph on screen, concatenated in a **stable** order.
|
|
403
|
+
*
|
|
404
|
+
* The order is load-bearing even though a glyph set is not: `#buildCacheKey` hashes this string, so
|
|
405
|
+
* reading the roots concurrently and pushing as each file resolved made the key depend on i/o
|
|
406
|
+
* scheduling — measured 8 distinct keys over 8 runs against unchanged sources, which means the cache
|
|
407
|
+
* never hit and every build re-subset the fonts.
|
|
408
|
+
*
|
|
409
|
+
* `lib` is in the roots because that is where user-facing text actually lives: a dictionary's
|
|
410
|
+
* `[en, ko]` pairs are the Korean in the app, and a subset built from `page` and `ui` alone renders
|
|
411
|
+
* them as tofu — while hashing the same partial text also stopped a new label from invalidating.
|
|
412
|
+
*/
|
|
390
413
|
async #collectAutoSubsetText() {
|
|
391
414
|
//* Synced lib pages hold app-visible text too, and a glob never crosses the symlink that mounts them.
|
|
392
415
|
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];
|
|
416
|
+
const roots = [...["page", "ui", "lib"].map((dir) => path.join(this.#app.cwdPath, dir)), ...libPageRoots];
|
|
394
417
|
const glob = new Bun.Glob("**/*.{ts,tsx,js,jsx,html,md}");
|
|
395
418
|
const parts: string[] = [];
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
parts.push(await Bun.file(filePath).text());
|
|
407
|
-
}
|
|
408
|
-
}),
|
|
409
|
-
);
|
|
419
|
+
for (const root of [...new Set(roots)].sort()) {
|
|
420
|
+
const isDir = await stat(root).then(
|
|
421
|
+
(entry) => entry.isDirectory(),
|
|
422
|
+
() => false,
|
|
423
|
+
);
|
|
424
|
+
if (!isDir) continue;
|
|
425
|
+
const filePaths: string[] = [];
|
|
426
|
+
for await (const filePath of glob.scan({ cwd: root, absolute: true })) filePaths.push(filePath);
|
|
427
|
+
for (const filePath of filePaths.sort()) parts.push(await Bun.file(filePath).text());
|
|
428
|
+
}
|
|
410
429
|
return parts.join("");
|
|
411
430
|
}
|
|
412
431
|
|
|
@@ -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
|
+
});
|