@akanjs/devkit 3.0.0-alpha.5 → 3.0.0-alpha.51
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/README.ko.md +1 -1
- package/README.md +1 -1
- package/agentsIndex.test.ts +10 -0
- package/agentsIndex.ts +47 -1
- package/aiEditor.ts +1 -1
- package/akanConfig/akanConfig.test.ts +47 -10
- package/akanConfig/akanConfig.ts +23 -16
- package/akanContext.ts +53 -11
- package/applicationBuildRunner.test.ts +1 -1
- package/applicationBuildRunner.ts +0 -1
- package/biome.base.json +325 -0
- package/biomeBase.ts +9 -0
- package/capacitorApp.test.ts +8 -0
- package/capacitorApp.ts +112 -18
- package/executors.test.ts +29 -5
- package/executors.ts +23 -5
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/frontendBuild/buildRouteClient.test.ts +28 -2
- package/frontendBuild/clientBuildTypes.ts +4 -0
- package/frontendBuild/clientEntriesBundler.ts +4 -1
- package/frontendBuild/cssCandidateCache.test.ts +38 -0
- package/frontendBuild/cssCandidateCache.ts +15 -3
- package/frontendBuild/cssCompiler.ts +122 -11
- package/frontendBuild/cssImportResolver.ts +8 -7
- package/frontendBuild/frontendBuild.test.ts +88 -1
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/routeClientBuilder.ts +12 -5
- package/frontendBuild/ssrBaseArtifactBuilder.ts +20 -4
- package/frontendBuild/styleGuard.test.ts +15 -0
- package/frontendBuild/styleGuard.ts +17 -0
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/getCredentials.ts +1 -3
- package/incrementalBuilder/devWatchBatch.test.ts +18 -20
- package/incrementalBuilder/incrementalBuilder.host.ts +1 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +2 -2
- package/integration/devStabilityHarness.ts +1 -5
- package/lint/no-async-component-in-ui.grit +35 -0
- package/lint/no-daisyui-legacy-class.grit +26 -9
- package/lint/no-import-client-in-server.grit +48 -0
- package/lint/no-import-server-in-client.grit +45 -0
- package/lint/no-model-type-in-util-zone.grit +58 -0
- package/lint/no-return-in-store-action.grit +35 -0
- package/lint/no-unpublished-form-setter.grit +41 -0
- package/linter.ts +17 -12
- package/package.json +4 -4
- package/qualityScanner.test.ts +52 -0
- package/qualityScanner.ts +48 -37
- package/repoIdentity.ts +42 -0
- package/routeSourceValidator.test.ts +41 -0
- package/routeSourceValidator.ts +2 -44
- package/scanInfo.ts +21 -20
- package/transforms/externalizeFrameworkPlugin.ts +0 -1
- package/workspaceLayout.test.ts +56 -4
- package/workspaceLayout.ts +49 -4
|
@@ -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,6 +430,17 @@ export function isIgnoredNodeModuleSource(filePath: string): boolean {
|
|
|
330
430
|
return NODE_MODULES_RE.test(filePath) && !AKANJS_NODE_MODULE_RE.test(filePath);
|
|
331
431
|
}
|
|
332
432
|
|
|
433
|
+
/**
|
|
434
|
+
* `@theme` blocks are stripped first: those variables are emitted only when a utility uses one, so their
|
|
435
|
+
* absence from a build says nothing about whether the stylesheet arrived.
|
|
436
|
+
*/
|
|
437
|
+
export function declaredCustomProperties(css: string): string[] {
|
|
438
|
+
const withoutThemeBlocks = css.replace(/@theme[^{]*\{[^}]*\}/g, "");
|
|
439
|
+
return [...new Set([...withoutThemeBlocks.matchAll(/(?:^|[\s;{])(--[\w-]+)\s*:/g)].map(([, name]) => name))].filter(
|
|
440
|
+
(name): name is string => !!name,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
333
444
|
function getPageKeyBasePath(pageKey: string, basePaths: string[]): string | null {
|
|
334
445
|
const normalized = pageKey.split(path.sep).join("/").replace(/^\.\//, "");
|
|
335
446
|
const segments = normalized.split("/");
|
|
@@ -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
|
}
|
|
@@ -5,7 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import type { RoutesManifest } from "akanjs/server";
|
|
7
7
|
import { CsrArtifactBuilder } from "./csrArtifactBuilder";
|
|
8
|
-
import { CssCompiler, isIgnoredNodeModuleSource } from "./cssCompiler";
|
|
8
|
+
import { CssCompiler, declaredCustomProperties, isIgnoredNodeModuleSource } from "./cssCompiler";
|
|
9
9
|
import { CssImportResolver } from "./cssImportResolver";
|
|
10
10
|
import { DevChangePlanner } from "./devChangePlanner";
|
|
11
11
|
import { DevGeneratedIndexSync } from "./devGeneratedIndexSync";
|
|
@@ -391,6 +391,20 @@ describe("CssImportResolver", () => {
|
|
|
391
391
|
expect(await resolver.resolve("@libs/ui/missing", root)).toBeNull();
|
|
392
392
|
});
|
|
393
393
|
|
|
394
|
+
test("never substitutes the package stylesheet for a subpath that does not exist", async () => {
|
|
395
|
+
const root = await makeTempRoot();
|
|
396
|
+
await write(
|
|
397
|
+
path.join(root, "node_modules/vendor/package.json"),
|
|
398
|
+
JSON.stringify({ name: "vendor", style: "index.css" }),
|
|
399
|
+
);
|
|
400
|
+
await write(path.join(root, "node_modules/vendor/index.css"), ".vendor {}\n");
|
|
401
|
+
|
|
402
|
+
const resolver = new CssImportResolver(root, {});
|
|
403
|
+
|
|
404
|
+
expect(await resolver.resolve("vendor", root)).toBe(path.join(root, "node_modules/vendor/index.css"));
|
|
405
|
+
expect(await resolver.resolve("vendor/ui/tokens.css", root)).toBeNull();
|
|
406
|
+
});
|
|
407
|
+
|
|
394
408
|
test("resolves css from single-package Akan workspace subpaths", async () => {
|
|
395
409
|
const root = await makeTempRoot();
|
|
396
410
|
await write(path.join(root, "pkgs/akanjs/ui/styles.css"), "body {}\n");
|
|
@@ -428,4 +442,77 @@ describe("CssCompiler", () => {
|
|
|
428
442
|
|
|
429
443
|
expect(css).toContain(".text-fuchsia-500");
|
|
430
444
|
});
|
|
445
|
+
|
|
446
|
+
test("reads declarations that prove a stylesheet arrived, ignoring theme variables that may not", () => {
|
|
447
|
+
expect(declaredCustomProperties(":root { --kakao: #fee500; --naver: #1ec800; }")).toEqual(["--kakao", "--naver"]);
|
|
448
|
+
expect(declaredCustomProperties("@theme inline {\n --color-brand: var(--brand);\n}\n")).toEqual([]);
|
|
449
|
+
expect(declaredCustomProperties("@theme { --color-x: initial; }\n:root { --brand: #111; }")).toEqual(["--brand"]);
|
|
450
|
+
expect(declaredCustomProperties(".a { color: var(--kakao); }")).toEqual([]);
|
|
451
|
+
expect(declaredCustomProperties(":root{--a:#111}\n@media (min-width:1px){:root{--a:#222;--b:#333}}")).toEqual([
|
|
452
|
+
"--a",
|
|
453
|
+
"--b",
|
|
454
|
+
]);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("fails loudly on a stylesheet import that resolves to nothing", async () => {
|
|
458
|
+
const root = await makeTempRoot();
|
|
459
|
+
const cssPath = path.join(root, "apps/demo/page/styles.css");
|
|
460
|
+
await write(cssPath, '@import "../../../libs/shared/ui/tokens.css";\n');
|
|
461
|
+
|
|
462
|
+
const compiler = new CssCompiler({
|
|
463
|
+
workspace: { workspaceRoot: root },
|
|
464
|
+
cwdPath: path.join(root, "apps/demo"),
|
|
465
|
+
getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
|
|
466
|
+
} as never);
|
|
467
|
+
|
|
468
|
+
await expect(compiler.compileCss([cssPath], [])).rejects.toThrow(
|
|
469
|
+
/failed to resolve stylesheet import "\.\.\/\.\.\/\.\.\/libs\/shared\/ui\/tokens\.css"/,
|
|
470
|
+
);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("reports every @import per base path so the written asset can be checked against it", async () => {
|
|
474
|
+
const root = await makeTempRoot();
|
|
475
|
+
const appDir = path.join(root, "apps/demo");
|
|
476
|
+
await write(path.join(appDir, "page/_index.tsx"), 'import "./styles.css";\nexport default () => null;\n');
|
|
477
|
+
await write(path.join(appDir, "page/styles.css"), '@import "../../../libs/shared/ui/brand.css";\n');
|
|
478
|
+
await write(path.join(root, "libs/shared/ui/brand.css"), ":root { --kakao: #fee500; --naver: #1ec800; }\n");
|
|
479
|
+
|
|
480
|
+
const compiler = new CssCompiler({
|
|
481
|
+
workspace: { workspaceRoot: root },
|
|
482
|
+
cwdPath: appDir,
|
|
483
|
+
getPageKeys: async () => ["./_index.tsx"],
|
|
484
|
+
getConfig: async () => ({ barrelImports: [], basePaths: [] }),
|
|
485
|
+
getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
|
|
486
|
+
} as never);
|
|
487
|
+
const cssByBasePath = await compiler.getCssByBasePath();
|
|
488
|
+
|
|
489
|
+
expect(cssByBasePath[""]).toContain("--kakao");
|
|
490
|
+
expect(compiler.importedStylesheetsByBasePath[""]).toEqual([
|
|
491
|
+
{ cssPath: path.join(root, "libs/shared/ui/brand.css"), declaredNames: ["--kakao", "--naver"] },
|
|
492
|
+
]);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("compiles lib-owned tokens ahead of the app stylesheets that may override them", async () => {
|
|
496
|
+
const root = await makeTempRoot();
|
|
497
|
+
const appDir = path.join(root, "apps/demo");
|
|
498
|
+
await write(
|
|
499
|
+
path.join(appDir, "page/_index.tsx"),
|
|
500
|
+
'import "./styles.css";\nimport { Card } from "@libs/shared/ui";\nexport default () => <Card />;\n',
|
|
501
|
+
);
|
|
502
|
+
await write(path.join(appDir, "page/styles.css"), ":root { --brand: #111111; }\n");
|
|
503
|
+
await write(path.join(root, "libs/shared/ui/index.ts"), "export const Card = () => null;\n");
|
|
504
|
+
await write(path.join(root, "libs/shared/ui/tokens.css"), ":root { --kakao: #fee500; }\n");
|
|
505
|
+
await write(path.join(root, "libs/unused/ui/tokens.css"), ":root { --unused: #000000; }\n");
|
|
506
|
+
|
|
507
|
+
const compiler = new CssCompiler({
|
|
508
|
+
workspace: { workspaceRoot: root },
|
|
509
|
+
cwdPath: appDir,
|
|
510
|
+
getPageKeys: async () => ["./_index.tsx"],
|
|
511
|
+
getConfig: async () => ({ barrelImports: [] }),
|
|
512
|
+
getTsConfig: async () => ({ compilerOptions: { paths: { "@libs/*": ["./libs/*"] } } }),
|
|
513
|
+
} as never);
|
|
514
|
+
const { cssPaths } = await compiler.discoverCssAndSources();
|
|
515
|
+
|
|
516
|
+
expect(cssPaths).toEqual([path.join(root, "libs/shared/ui/tokens.css"), path.join(appDir, "page/styles.css")]);
|
|
517
|
+
});
|
|
431
518
|
});
|
|
@@ -209,7 +209,7 @@ export class HmrWatcher {
|
|
|
209
209
|
// per-batch detail stays at verbose because a save-all trips this on every save.
|
|
210
210
|
if (!this.#reportedCompensating) {
|
|
211
211
|
this.#reportedCompensating = true;
|
|
212
|
-
this.#logger.
|
|
212
|
+
this.#logger.verbose(
|
|
213
213
|
`[hmr] recovered ${unreported} change(s) that fs.watch did not report; Bun coalesces concurrent saves and drops all but one, so changes are resolved by mtime`,
|
|
214
214
|
);
|
|
215
215
|
}
|
|
@@ -4,7 +4,7 @@ import type { BaseBuildArtifact, ClientManifest, SsrManifest } from "akanjs/serv
|
|
|
4
4
|
import type { App } from "../commandDecorators";
|
|
5
5
|
import { createBarrelImportsPlugin } from "../transforms/barrelImportsPlugin";
|
|
6
6
|
import { toClientReferencePath } from "../transforms/rscUseClientTransform";
|
|
7
|
-
import type { ClientEntryDiscovery } from "./clientBuildTypes";
|
|
7
|
+
import type { ClientBundleTarget, ClientEntryDiscovery } from "./clientBuildTypes";
|
|
8
8
|
import { ClientEntriesBundler } from "./clientEntriesBundler";
|
|
9
9
|
import { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
|
|
10
10
|
import { VENDOR_SPECIFIERS } from "./vendorSpecifiers";
|
|
@@ -163,12 +163,11 @@ export class RouteClientBuilder {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
async #buildSsrBundle(bootstrapEntries: BootstrapEntries) {
|
|
166
|
-
const externalOptions = RouteClientBuilder.resolveSsrClientExternalOptions(this.#command);
|
|
167
166
|
return new ClientEntriesBundler({
|
|
168
167
|
app: this.#app,
|
|
169
168
|
entries: bootstrapEntries.buildEntries,
|
|
170
169
|
plugins: [await createBarrelImportsPlugin(this.#app)],
|
|
171
|
-
...
|
|
170
|
+
...RouteClientBuilder.resolveSsrClientBundleOptions(this.#command),
|
|
172
171
|
outputSubdir: "client-ssr",
|
|
173
172
|
command: this.#command,
|
|
174
173
|
}).bundle();
|
|
@@ -239,20 +238,28 @@ export class RouteClientBuilder {
|
|
|
239
238
|
return { [Bun.resolveSync("akanjs/fetch", serverEntry)]: "akanjs/fetch" };
|
|
240
239
|
}
|
|
241
240
|
|
|
242
|
-
|
|
241
|
+
/**
|
|
242
|
+
* `target: "bun"` is load-bearing: these chunks are `await import()`-ed by the SSR renderer, so a dependency
|
|
243
|
+
* resolved through its `browser` export condition can touch `document` at module scope and throw mid-render,
|
|
244
|
+
* degrading the whole document to client rendering. Bun's `conditions` only adds to the target's defaults —
|
|
245
|
+
* `browser` still wins — so the target itself has to say server.
|
|
246
|
+
*/
|
|
247
|
+
static resolveSsrClientBundleOptions(command: "build" | "start"): {
|
|
248
|
+
target: ClientBundleTarget;
|
|
243
249
|
external: readonly string[];
|
|
244
250
|
externalSubpaths?: readonly string[];
|
|
245
251
|
externalAliases?: Record<string, string>;
|
|
246
252
|
} {
|
|
247
253
|
if (command === "start") {
|
|
248
254
|
return {
|
|
255
|
+
target: "bun",
|
|
249
256
|
external: SSR_CLIENT_EXTERNALS,
|
|
250
257
|
externalSubpaths: ["akanjs/fetch"],
|
|
251
258
|
externalAliases: RouteClientBuilder.resolveSsrClientRuntimeAliases(),
|
|
252
259
|
};
|
|
253
260
|
}
|
|
254
261
|
|
|
255
|
-
return { external: SSR_CLIENT_ALIAS_EXTERNALS };
|
|
262
|
+
return { target: "bun", external: SSR_CLIENT_ALIAS_EXTERNALS };
|
|
256
263
|
}
|
|
257
264
|
|
|
258
265
|
static resolveAkanServerEntry(): string {
|
|
@@ -5,7 +5,7 @@ import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
|
5
5
|
import { computeRouteSeedIndex, type RouteSeedIndex, saveRouteSeedIndex } from "../artifact/routeSeedIndex";
|
|
6
6
|
import type { App } from "../commandDecorators";
|
|
7
7
|
import { ClientEntriesBundler } from "./clientEntriesBundler";
|
|
8
|
-
import { CssCompiler } from "./cssCompiler";
|
|
8
|
+
import { CssCompiler, type ImportedStylesheet } from "./cssCompiler";
|
|
9
9
|
import { FontOptimizer } from "./fontOptimizer";
|
|
10
10
|
import { PagesBundleBuilder } from "./pagesBundleBuilder";
|
|
11
11
|
import { RouteClientBuilder } from "./routeClientBuilder";
|
|
@@ -110,7 +110,7 @@ export class SsrBaseArtifactBuilder {
|
|
|
110
110
|
const ssrBundle = await new ClientEntriesBundler({
|
|
111
111
|
app: this.#app,
|
|
112
112
|
entries: [rscSegmentOutletEntry],
|
|
113
|
-
...RouteClientBuilder.
|
|
113
|
+
...RouteClientBuilder.resolveSsrClientBundleOptions(this.#command),
|
|
114
114
|
outputSubdir: "client-ssr",
|
|
115
115
|
command: this.#command,
|
|
116
116
|
}).bundle();
|
|
@@ -192,7 +192,7 @@ export class SsrBaseArtifactBuilder {
|
|
|
192
192
|
Object.entries(cssByBasePath).flatMap(([basePath, baseCssText]) => {
|
|
193
193
|
const cssText = [baseCssText, optimizedFonts.css].filter(Boolean).join("\n");
|
|
194
194
|
if (!cssText) return [];
|
|
195
|
-
return [this.#writeCssAsset(basePath, cssText)];
|
|
195
|
+
return [this.#writeCssAsset(basePath, cssText, cssCompiler.importedStylesheetsByBasePath[basePath] ?? [])];
|
|
196
196
|
}),
|
|
197
197
|
),
|
|
198
198
|
);
|
|
@@ -201,7 +201,7 @@ export class SsrBaseArtifactBuilder {
|
|
|
201
201
|
return { cssCompiler, optimizedFonts, cssAssets };
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
async #writeCssAsset(basePath: string, cssText: string) {
|
|
204
|
+
async #writeCssAsset(basePath: string, cssText: string, imported: ImportedStylesheet[]) {
|
|
205
205
|
const cssAssetName = basePath || "root";
|
|
206
206
|
const preparedCssText = await prepareCssAsset(this.#command, basePath, cssText);
|
|
207
207
|
const cssHash = Bun.hash(`${basePath}\n${preparedCssText}`).toString(36);
|
|
@@ -210,7 +210,23 @@ export class SsrBaseArtifactBuilder {
|
|
|
210
210
|
`/_akan/styles/${cssAssetName}-${cssHash}.css`,
|
|
211
211
|
];
|
|
212
212
|
await Bun.write(path.join(this.#absArtifactDir, cssRelPath), preparedCssText);
|
|
213
|
+
SsrBaseArtifactBuilder.#warnDroppedImports(this.#app, cssRelPath, preparedCssText, imported);
|
|
213
214
|
this.#app.verbose(`[base-artifact] wrote ${preparedCssText.length} bytes of CSS for ${basePath} -> ${cssRelPath}`);
|
|
214
215
|
return [basePath, { cssUrl, cssRelPath }] as const;
|
|
215
216
|
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Checked against the file written here rather than against the compiled text, because this is the stylesheet
|
|
220
|
+
* `base-artifact.json` points at and therefore the only one an SSR render serves. A declaration can survive
|
|
221
|
+
* the compile and still be missing from the asset — a build that ships CSS to the CSR bundle and not to the
|
|
222
|
+
* server is indistinguishable, in the browser, from a theme that was never written.
|
|
223
|
+
*/
|
|
224
|
+
static #warnDroppedImports(app: App, cssRelPath: string, css: string, imported: ImportedStylesheet[]) {
|
|
225
|
+
for (const { cssPath, declaredNames } of imported) {
|
|
226
|
+
if (declaredNames.length === 0 || declaredNames.some((name) => css.includes(`${name}:`))) continue;
|
|
227
|
+
app.logger.warn(
|
|
228
|
+
`[base-artifact] @import ${cssPath} declares ${declaredNames.length} custom propert${declaredNames.length === 1 ? "y" : "ies"} and none of them are in ${cssRelPath}`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
216
232
|
}
|
|
@@ -67,6 +67,21 @@ describe("StyleGuard daisyui-legacy", () => {
|
|
|
67
67
|
test("does not flag bare ambiguous names that collide with Tailwind", () => {
|
|
68
68
|
expect(scan('<div className="card input badge btn" />')).toHaveLength(0);
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
test("flags daisyUI colour slots the vocabulary dropped", () => {
|
|
72
|
+
expect(rules('<div className="bg-base-100" />')).toEqual(["daisyui-legacy"]);
|
|
73
|
+
expect(rules('<div className="text-base-content/70" />')).toEqual(["daisyui-legacy"]);
|
|
74
|
+
expect(rules('<div className="border-t-base-300" />')).toEqual(["daisyui-legacy"]);
|
|
75
|
+
expect(rules('<div className="text-primary-content" />')).toEqual(["daisyui-legacy"]);
|
|
76
|
+
expect(rules('<div className="border-error/30 bg-error/5" />')).toEqual(["daisyui-legacy", "daisyui-legacy"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("does not flag colour slots that survived into the semantic vocabulary", () => {
|
|
80
|
+
expect(scan('<div className="bg-primary text-primary-foreground" />')).toHaveLength(0);
|
|
81
|
+
expect(scan('<div className="bg-neutral text-info border-warning" />')).toHaveLength(0);
|
|
82
|
+
expect(scan('<div className="bg-destructive/10 text-destructive" />')).toHaveLength(0);
|
|
83
|
+
expect(scan('<div className="content-center justify-content" />')).toHaveLength(0);
|
|
84
|
+
});
|
|
70
85
|
});
|
|
71
86
|
|
|
72
87
|
// The fixtures below must contain a literal `${`. Writing it inside a plain string trips biome's
|
|
@@ -90,6 +90,13 @@ const DAISYUI_LEGACY_RE = new RegExp(
|
|
|
90
90
|
"g",
|
|
91
91
|
);
|
|
92
92
|
|
|
93
|
+
// daisyUI 색 슬롯. 어휘 폐쇄가 이 이름들을 지웠으므로 CSS 가 생성되지 않는다 — 컴포넌트 클래스와 같은
|
|
94
|
+
// 무증상 실패다. 시맨틱 어휘에 살아남은 슬롯(primary·secondary·accent·neutral·info·success·warning)은
|
|
95
|
+
// 정상이므로 제외하고, 사라진 것(base-*, *-content, error)만 잡는다.
|
|
96
|
+
const DAISYUI_TOKEN =
|
|
97
|
+
"(?:base-(?:100|200|300|content)|(?:primary|secondary|accent|neutral|info|success|warning|error)-content|error)";
|
|
98
|
+
const DAISYUI_TOKEN_RE = new RegExp(`${LEAD}(?:${PREFIX})-${DAISYUI_TOKEN}${TAIL}`, "g");
|
|
99
|
+
|
|
93
100
|
const ALL_RULES: StyleGuardRule[] = [
|
|
94
101
|
"raw-palette",
|
|
95
102
|
"arbitrary-color",
|
|
@@ -247,6 +254,16 @@ export class StyleGuard {
|
|
|
247
254
|
}),
|
|
248
255
|
);
|
|
249
256
|
}
|
|
257
|
+
for (const m of scan.matchAll(DAISYUI_TOKEN_RE)) {
|
|
258
|
+
out.push(
|
|
259
|
+
this.#violation(file, m.index ?? 0, {
|
|
260
|
+
rule: "daisyui-legacy",
|
|
261
|
+
severity: "error",
|
|
262
|
+
suggestion:
|
|
263
|
+
"daisyUI 색 슬롯은 어휘에서 제거됐습니다 — base-100/200/300→background/muted/border, base-content→foreground, *-content→*-foreground, error→destructive.",
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
250
267
|
for (const m of scan.matchAll(INTERPOLATED_ARBITRARY_RE)) {
|
|
251
268
|
out.push(
|
|
252
269
|
this.#violation(file, m.index ?? 0, {
|
package/getCredentials.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import yaml from "js-yaml";
|
|
2
|
-
|
|
3
1
|
import type { AppExecutor } from "./executors";
|
|
4
2
|
import { FileSys } from "./fileSys";
|
|
5
3
|
|
|
@@ -12,7 +10,7 @@ interface Secret {
|
|
|
12
10
|
|
|
13
11
|
export const getCredentials = async (app: AppExecutor, environment: string): Promise<AppSecret> => {
|
|
14
12
|
const content = await FileSys.readText(`${app.workspace.workspaceRoot}/infra/app/values/${app.name}-secret.yaml`);
|
|
15
|
-
const secret =
|
|
13
|
+
const secret = Bun.YAML.parse(content) as Secret;
|
|
16
14
|
const appSecret = secret[environment];
|
|
17
15
|
if (!appSecret) throw new Error(`No secret found for ${app.name} in ${environment}`);
|
|
18
16
|
return appSecret;
|
|
@@ -22,27 +22,25 @@ describe("prepareDevWatchBatch", () => {
|
|
|
22
22
|
expect(prepared.event.devPlan?.files).toEqual(prepared.files);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
test.each([
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
indexSync: { changedFiles: [generatedIndex], errors: [] },
|
|
38
|
-
changePlanner: new DevChangePlanner({ workspaceRoot: root }),
|
|
39
|
-
});
|
|
25
|
+
test.each(["common", "srvkit", "ui", "webkit"])(
|
|
26
|
+
"keeps %s facet add/delete generated index in the same generation",
|
|
27
|
+
(facet) => {
|
|
28
|
+
const root = "/repo";
|
|
29
|
+
const changedFile = `${root}/libs/shared/${facet}/tmpExample.ts`;
|
|
30
|
+
const generatedIndex = `${root}/libs/shared/${facet}/index.ts`;
|
|
31
|
+
const prepared = prepareDevWatchBatch({
|
|
32
|
+
generation: 20,
|
|
33
|
+
batch: { files: [changedFile], kinds: new Set(["code"]) },
|
|
34
|
+
indexSync: { changedFiles: [generatedIndex], errors: [] },
|
|
35
|
+
changePlanner: new DevChangePlanner({ workspaceRoot: root }),
|
|
36
|
+
});
|
|
40
37
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
38
|
+
expect(new Set(prepared.files)).toEqual(new Set([changedFile, generatedIndex]));
|
|
39
|
+
expect(prepared.event.devPlan?.generatedFiles).toEqual([generatedIndex]);
|
|
40
|
+
expect(prepared.event.devPlan?.roles).toContain("barrel");
|
|
41
|
+
expect(prepared.event.devPlan?.actions).toContain("sync-generated");
|
|
42
|
+
},
|
|
43
|
+
);
|
|
46
44
|
|
|
47
45
|
test("marks failed generated index sync as an error generation", () => {
|
|
48
46
|
const root = "/repo";
|
|
@@ -200,7 +200,7 @@ export class IncrementalBuilderHost {
|
|
|
200
200
|
// one at a time, into the dev error page a recycle is supposed to be invisible to. `ready` the
|
|
201
201
|
// field is deliberately untouched: `onExit` reads it to tell a planned exit from a boot failure.
|
|
202
202
|
this.#status = "recycling";
|
|
203
|
-
this.logger.
|
|
203
|
+
this.logger.debug(`recycling builder pid=${proc.pid} (${reason})`);
|
|
204
204
|
this.#recycleTimer = setTimeout(() => {
|
|
205
205
|
this.#recycleTimer = null;
|
|
206
206
|
if (this.#proc !== proc) return;
|
|
@@ -185,7 +185,7 @@ class IncrementalBuilder {
|
|
|
185
185
|
if (this.#shuttingDown) return;
|
|
186
186
|
this.#shuttingDown = true;
|
|
187
187
|
const started = Date.now();
|
|
188
|
-
this.#logger.
|
|
188
|
+
this.#logger.debug(`shutdown requested (${reason}); draining ${this.#inFlight} work item(s)`);
|
|
189
189
|
if (this.#cssRebuildTimer) {
|
|
190
190
|
// Only reachable if a css batch landed between the idle report and this request: the fresh
|
|
191
191
|
// boot build recompiles css from scratch anyway, so dropping the debounce loses nothing.
|
|
@@ -200,7 +200,7 @@ class IncrementalBuilder {
|
|
|
200
200
|
// flushed — a `css-updated` relayed milliseconds before this line would be dropped with no error
|
|
201
201
|
// anywhere, leaving the backend serving the previous bundle. See `BuilderChannel`.
|
|
202
202
|
const flushed = await BuilderChannel.drain();
|
|
203
|
-
this.#logger.
|
|
203
|
+
this.#logger.debug(
|
|
204
204
|
`drained in ${Date.now() - started}ms${flushed ? ` after flushing ${flushed} ipc write(s)` : ""}; exiting for recycle`,
|
|
205
205
|
);
|
|
206
206
|
process.exit(0);
|
|
@@ -127,11 +127,7 @@ export const env = {
|
|
|
127
127
|
),
|
|
128
128
|
this.writeFile(
|
|
129
129
|
"env/env.server.ts",
|
|
130
|
-
`
|
|
131
|
-
|
|
132
|
-
export const env = {
|
|
133
|
-
...getEnv(),
|
|
134
|
-
} as const;
|
|
130
|
+
`export const env = {} as const;
|
|
135
131
|
`,
|
|
136
132
|
),
|
|
137
133
|
this.writeFile(
|