@akanjs/devkit 1.0.20 → 2.1.0-rc.1
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 +65 -0
- package/README.md +62 -6
- package/aiEditor.ts +304 -0
- package/akanApp/akanApp.host.ts +393 -0
- package/akanApp/index.ts +1 -0
- package/akanConfig/akanConfig.test.ts +236 -0
- package/akanConfig/akanConfig.ts +384 -0
- package/akanConfig/index.ts +2 -0
- package/akanConfig/types.ts +23 -0
- package/applicationBuildReporter.ts +69 -0
- package/applicationBuildRunner.ts +302 -0
- package/applicationReleasePackager.ts +206 -0
- package/artifact/implicitRootLayout.ts +155 -0
- package/artifact/index.ts +1 -0
- package/artifact/routeSeedIndex.test.ts +98 -0
- package/artifact/routeSeedIndex.ts +130 -0
- package/auth.ts +41 -0
- package/builder.ts +164 -0
- package/capacitor.base.config.ts +88 -0
- package/capacitorApp.ts +440 -0
- package/commandDecorators/argMeta.ts +102 -0
- package/commandDecorators/command.ts +351 -0
- package/commandDecorators/commandBuilder.ts +224 -0
- package/commandDecorators/commandDecorators.test.ts +212 -0
- package/commandDecorators/commandMeta.ts +7 -0
- package/commandDecorators/dependencyBuilder.ts +100 -0
- package/{esm/src/commandDecorators/helpFormatter.js → commandDecorators/helpFormatter.ts} +100 -47
- package/{esm/src/commandDecorators/index.js → commandDecorators/index.ts} +4 -2
- package/commandDecorators/targetMeta.ts +31 -0
- package/commandDecorators/types.ts +10 -0
- package/constants.ts +25 -0
- package/createTunnel.ts +36 -0
- package/dependencyScanner.ts +357 -0
- package/devkitUtils.test.ts +259 -0
- package/executors.test.ts +315 -0
- package/executors.ts +1390 -0
- package/{esm/src/extractDeps.js → extractDeps.ts} +26 -20
- package/{esm/src/fileEditor.js → fileEditor.ts} +51 -32
- package/fileSys.ts +39 -0
- package/frontendBuild/allRoutesBuilder.ts +103 -0
- package/frontendBuild/buildRouteClient.test.ts +190 -0
- package/frontendBuild/clientBuildTypes.ts +114 -0
- package/frontendBuild/clientEntriesBundler.ts +303 -0
- package/frontendBuild/clientEntryDiscovery.ts +199 -0
- package/frontendBuild/csrArtifactBuilder.ts +237 -0
- package/frontendBuild/cssCompiler.ts +286 -0
- package/frontendBuild/cssImportResolver.ts +116 -0
- package/frontendBuild/fontOptimizer.ts +427 -0
- package/frontendBuild/frontendBuild.test.ts +204 -0
- package/frontendBuild/hmrChangeClassifier.ts +28 -0
- package/frontendBuild/hmrWatcher.ts +102 -0
- package/frontendBuild/index.ts +18 -0
- package/frontendBuild/pagesBundleBuilder.ts +137 -0
- package/frontendBuild/pagesEntrySourceGenerator.ts +37 -0
- package/frontendBuild/precompressArtifacts.ts +59 -0
- package/frontendBuild/routeClientBuilder.ts +290 -0
- package/frontendBuild/routesManifestArtifactSerializer.ts +62 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +139 -0
- package/frontendBuild/vendorSpecifiers.ts +16 -0
- package/frontendBuild/watchRootResolver.ts +28 -0
- package/getCredentials.ts +19 -0
- package/getDirname.ts +3 -0
- package/getModelFileData.ts +59 -0
- package/getRelatedCnsts.ts +313 -0
- package/guideline.ts +19 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +51 -0
- package/incrementalBuilder/incrementalBuilder.host.ts +152 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +331 -0
- package/incrementalBuilder/index.ts +1 -0
- package/{esm/src/index.js → index.ts} +28 -15
- package/lint/no-deep-internal-import.grit +25 -0
- package/lint/no-import-client-functions.grit +32 -0
- package/lint/no-import-external-library.grit +21 -0
- package/lint/no-js-private-class-method.grit +42 -0
- package/lint/no-use-client-in-server.grit +7 -0
- package/lint/non-scalar-props-restricted.grit +13 -0
- package/linter.ts +271 -0
- package/mobile/index.ts +1 -0
- package/mobile/mobileTarget.test.ts +53 -0
- package/mobile/mobileTarget.ts +88 -0
- package/package.json +48 -31
- package/prompter.ts +72 -0
- package/scanInfo.ts +606 -0
- package/selectModel.ts +11 -0
- package/{esm/src/spinner.js → spinner.ts} +22 -28
- package/{esm/src/capacitorApp.js → src/capacitorApp.ts} +82 -81
- package/sshTunnel.ts +152 -0
- package/{esm/src/streamAi.js → streamAi.ts} +18 -12
- package/transforms/barrelAnalyzer.ts +278 -0
- package/transforms/barrelImportsPlugin.ts +504 -0
- package/transforms/externalizeFrameworkPlugin.ts +185 -0
- package/transforms/index.ts +5 -0
- package/transforms/rscUseClientTransform.ts +59 -0
- package/transforms/transforms.test.ts +208 -0
- package/transforms/useClientBundlePlugin.ts +47 -0
- package/tsconfig.json +37 -0
- package/typeChecker.ts +264 -0
- package/types.ts +44 -0
- package/ui/MultiScrollList.tsx +242 -0
- package/ui/ScrollList.tsx +107 -0
- package/ui/index.ts +2 -0
- package/{esm/src/uploadRelease.js → uploadRelease.ts} +50 -34
- package/{esm/src/useStdoutDimensions.js → useStdoutDimensions.ts} +5 -5
- package/cjs/index.js +0 -21
- package/cjs/src/aiEditor.js +0 -311
- package/cjs/src/auth.js +0 -72
- package/cjs/src/builder.js +0 -114
- package/cjs/src/capacitorApp.js +0 -313
- package/cjs/src/commandDecorators/argMeta.js +0 -88
- package/cjs/src/commandDecorators/command.js +0 -324
- package/cjs/src/commandDecorators/commandMeta.js +0 -30
- package/cjs/src/commandDecorators/helpFormatter.js +0 -211
- package/cjs/src/commandDecorators/index.js +0 -31
- package/cjs/src/commandDecorators/targetMeta.js +0 -57
- package/cjs/src/commandDecorators/types.js +0 -15
- package/cjs/src/constants.js +0 -46
- package/cjs/src/createTunnel.js +0 -49
- package/cjs/src/dependencyScanner.js +0 -220
- package/cjs/src/executors.js +0 -964
- package/cjs/src/extractDeps.js +0 -103
- package/cjs/src/fileEditor.js +0 -120
- package/cjs/src/getCredentials.js +0 -44
- package/cjs/src/getDirname.js +0 -38
- package/cjs/src/getModelFileData.js +0 -66
- package/cjs/src/getRelatedCnsts.js +0 -260
- package/cjs/src/guideline.js +0 -15
- package/cjs/src/index.js +0 -65
- package/cjs/src/linter.js +0 -238
- package/cjs/src/prompter.js +0 -85
- package/cjs/src/scanInfo.js +0 -491
- package/cjs/src/selectModel.js +0 -46
- package/cjs/src/spinner.js +0 -93
- package/cjs/src/streamAi.js +0 -62
- package/cjs/src/typeChecker.js +0 -207
- package/cjs/src/types.js +0 -15
- package/cjs/src/uploadRelease.js +0 -112
- package/cjs/src/useStdoutDimensions.js +0 -43
- package/esm/index.js +0 -1
- package/esm/src/aiEditor.js +0 -282
- package/esm/src/auth.js +0 -42
- package/esm/src/builder.js +0 -81
- package/esm/src/commandDecorators/argMeta.js +0 -54
- package/esm/src/commandDecorators/command.js +0 -290
- package/esm/src/commandDecorators/commandMeta.js +0 -7
- package/esm/src/commandDecorators/targetMeta.js +0 -33
- package/esm/src/commandDecorators/types.js +0 -0
- package/esm/src/constants.js +0 -17
- package/esm/src/createTunnel.js +0 -26
- package/esm/src/dependencyScanner.js +0 -187
- package/esm/src/executors.js +0 -928
- package/esm/src/getCredentials.js +0 -11
- package/esm/src/getDirname.js +0 -5
- package/esm/src/getModelFileData.js +0 -33
- package/esm/src/getRelatedCnsts.js +0 -221
- package/esm/src/guideline.js +0 -0
- package/esm/src/linter.js +0 -205
- package/esm/src/prompter.js +0 -51
- package/esm/src/scanInfo.js +0 -455
- package/esm/src/selectModel.js +0 -13
- package/esm/src/typeChecker.js +0 -174
- package/esm/src/types.js +0 -0
- package/index.d.ts +0 -1
- package/src/aiEditor.d.ts +0 -50
- package/src/auth.d.ts +0 -9
- package/src/builder.d.ts +0 -18
- package/src/capacitorApp.d.ts +0 -39
- package/src/commandDecorators/argMeta.d.ts +0 -67
- package/src/commandDecorators/command.d.ts +0 -2
- package/src/commandDecorators/commandMeta.d.ts +0 -2
- package/src/commandDecorators/helpFormatter.d.ts +0 -3
- package/src/commandDecorators/index.d.ts +0 -6
- package/src/commandDecorators/targetMeta.d.ts +0 -19
- package/src/commandDecorators/types.d.ts +0 -1
- package/src/constants.d.ts +0 -26
- package/src/createTunnel.d.ts +0 -8
- package/src/dependencyScanner.d.ts +0 -23
- package/src/executors.d.ts +0 -296
- package/src/extractDeps.d.ts +0 -7
- package/src/fileEditor.d.ts +0 -16
- package/src/getCredentials.d.ts +0 -12
- package/src/getDirname.d.ts +0 -1
- package/src/getModelFileData.d.ts +0 -16
- package/src/getRelatedCnsts.d.ts +0 -53
- package/src/guideline.d.ts +0 -19
- package/src/index.d.ts +0 -23
- package/src/linter.d.ts +0 -109
- package/src/prompter.d.ts +0 -14
- package/src/scanInfo.d.ts +0 -82
- package/src/selectModel.d.ts +0 -1
- package/src/spinner.d.ts +0 -20
- package/src/streamAi.d.ts +0 -6
- package/src/typeChecker.d.ts +0 -52
- package/src/types.d.ts +0 -31
- package/src/uploadRelease.d.ts +0 -10
- package/src/useStdoutDimensions.d.ts +0 -1
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
ReactFont,
|
|
5
|
+
ReactFontDeclaration,
|
|
6
|
+
ReactFontFace,
|
|
7
|
+
ReactFontPath,
|
|
8
|
+
ReactFontStyle,
|
|
9
|
+
ReactFontSubset,
|
|
10
|
+
} from "akanjs/client";
|
|
11
|
+
import {
|
|
12
|
+
type FontCategory,
|
|
13
|
+
generateFontFace,
|
|
14
|
+
getMetricsForFamily,
|
|
15
|
+
readMetrics,
|
|
16
|
+
resolveCategoryFallbacks,
|
|
17
|
+
} from "fontaine";
|
|
18
|
+
import { createFont, woff2 } from "fonteditor-core";
|
|
19
|
+
import subsetFont from "subset-font";
|
|
20
|
+
import ts from "typescript";
|
|
21
|
+
import type { App } from "../commandDecorators";
|
|
22
|
+
|
|
23
|
+
const FONT_URL_PREFIX = "/_akan/fonts";
|
|
24
|
+
const DEFAULT_FONT_SUBSETS: ReactFontSubset[] = ["latin"];
|
|
25
|
+
|
|
26
|
+
export interface OptimizeAppFontsResult {
|
|
27
|
+
css: string;
|
|
28
|
+
fonts: ReactFont[];
|
|
29
|
+
files: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type FontOptimizerCommand = "build" | "start";
|
|
33
|
+
|
|
34
|
+
export class FontOptimizer {
|
|
35
|
+
#app: App;
|
|
36
|
+
#command: FontOptimizerCommand;
|
|
37
|
+
#artifactRoot: string;
|
|
38
|
+
#files: string[] = [];
|
|
39
|
+
#cssParts: string[] = [];
|
|
40
|
+
#woff2Ready: Promise<void> | null = null;
|
|
41
|
+
|
|
42
|
+
static #ksX1001Text: string | null = null;
|
|
43
|
+
|
|
44
|
+
constructor(app: App, command: FontOptimizerCommand = "start") {
|
|
45
|
+
this.#app = app;
|
|
46
|
+
this.#command = command;
|
|
47
|
+
this.#artifactRoot = path.join(command === "build" ? app.dist.cwdPath : app.cwdPath, ".akan/artifact");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async optimize(): Promise<OptimizeAppFontsResult> {
|
|
51
|
+
const fonts = await this.discoverFonts();
|
|
52
|
+
for (const font of fonts) {
|
|
53
|
+
if (!this.#isFontOptimizationEnabled(font)) continue;
|
|
54
|
+
await this.#optimizeFont(font);
|
|
55
|
+
}
|
|
56
|
+
const fontUtilityCss = this.#buildFontUtilityRules(fonts);
|
|
57
|
+
if (fontUtilityCss) this.#cssParts.push(fontUtilityCss);
|
|
58
|
+
return { css: this.#cssParts.join("\n"), fonts, files: this.#files };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async discoverFonts(): Promise<ReactFont[]> {
|
|
62
|
+
const pageKeys = await this.#app.getPageKeys();
|
|
63
|
+
const fonts: ReactFont[] = [];
|
|
64
|
+
await Promise.all(
|
|
65
|
+
pageKeys.map(async (key) => {
|
|
66
|
+
const filePath = path.resolve(this.#app.cwdPath, "page", key);
|
|
67
|
+
const file = Bun.file(filePath);
|
|
68
|
+
if (!(await file.exists())) return;
|
|
69
|
+
fonts.push(...this.#extractFontsExport(await file.text(), filePath));
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
return this.#dedupeFonts(fonts);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async #optimizeFont(font: ReactFont) {
|
|
76
|
+
const faceCss: string[] = [];
|
|
77
|
+
for (const face of this.#getFontFaces(font)) {
|
|
78
|
+
const sourcePath = await this.#resolveFontSourcePath(face.src);
|
|
79
|
+
if (!sourcePath) {
|
|
80
|
+
this.#app.logger.warn(`[font] source not found: ${face.src}`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const outputPath = path.join(this.#artifactRoot, face.optimizedSrc.replace(/^\/_akan\//, ""));
|
|
85
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
86
|
+
|
|
87
|
+
const sourceBuffer = Buffer.from(await Bun.file(sourcePath).arrayBuffer());
|
|
88
|
+
const outputBuffer =
|
|
89
|
+
font.subset === false
|
|
90
|
+
? await this.#convertToWoff2(sourceBuffer, sourcePath)
|
|
91
|
+
: await subsetFont(sourceBuffer, await this.#getSubsetText(font), { targetFormat: "woff2" });
|
|
92
|
+
await Bun.write(outputPath, outputBuffer);
|
|
93
|
+
this.#files.push(outputPath);
|
|
94
|
+
|
|
95
|
+
faceCss.push(this.#buildOptimizedFontFaceRule(font, face));
|
|
96
|
+
const fallbackCss = await this.#buildFontaineFallbackCss(font, face, outputPath);
|
|
97
|
+
if (fallbackCss) faceCss.push(fallbackCss);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (faceCss.length > 0) this.#cssParts.push(...faceCss, this.#buildRootVariableRule(font));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#extractFontsExport(source: string, filePath: string): ReactFont[] {
|
|
104
|
+
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
105
|
+
const fonts: ReactFont[] = [];
|
|
106
|
+
for (const statement of sourceFile.statements) {
|
|
107
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
108
|
+
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
|
|
109
|
+
const isExported = modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
|
|
110
|
+
if (!isExported) continue;
|
|
111
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
112
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "fonts") continue;
|
|
113
|
+
const value = declaration.initializer ? this.#literalToValue(declaration.initializer) : null;
|
|
114
|
+
if (Array.isArray(value)) {
|
|
115
|
+
fonts.push(...(value as ReactFont[]).map((font) => this.#withFontDefaults(font)));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return fonts;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#literalToValue(node: ts.Node): unknown {
|
|
123
|
+
if (ts.isStringLiteralLike(node)) return node.text;
|
|
124
|
+
if (ts.isNumericLiteral(node)) return Number(node.text);
|
|
125
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
126
|
+
if (node.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
127
|
+
if (ts.isArrayLiteralExpression(node)) return node.elements.map((element) => this.#literalToValue(element));
|
|
128
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
129
|
+
const obj: Record<string, unknown> = {};
|
|
130
|
+
for (const prop of node.properties) {
|
|
131
|
+
if (!ts.isPropertyAssignment(prop)) continue;
|
|
132
|
+
const name = this.#getPropertyName(prop.name);
|
|
133
|
+
if (!name) continue;
|
|
134
|
+
obj[name] = this.#literalToValue(prop.initializer);
|
|
135
|
+
}
|
|
136
|
+
return obj;
|
|
137
|
+
}
|
|
138
|
+
if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node)) {
|
|
139
|
+
return this.#literalToValue(node.expression);
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#getPropertyName(name: ts.PropertyName): string | null {
|
|
145
|
+
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#dedupeFonts(fonts: ReactFont[]) {
|
|
150
|
+
const map = new Map<string, ReactFont>();
|
|
151
|
+
for (const font of fonts) map.set(JSON.stringify(font), font);
|
|
152
|
+
return [...map.values()];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#withFontDefaults(font: ReactFont): ReactFont {
|
|
156
|
+
return { ...font, subsets: font.subsets ?? [...DEFAULT_FONT_SUBSETS] };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#getFontSubsets(font: ReactFont): ReactFontSubset[] {
|
|
160
|
+
return font.subsets ?? DEFAULT_FONT_SUBSETS;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#getFontVariableName(font: ReactFont) {
|
|
164
|
+
return font.variable ?? `--font-${font.name}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#getFontFallbackName(font: ReactFont) {
|
|
168
|
+
return font.fallbackName ?? `${font.name} fallback`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#isFontOptimizationEnabled(font: ReactFont) {
|
|
172
|
+
return font.optimize !== false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#getFontStyles(font: ReactFont): ReactFontStyle[] {
|
|
176
|
+
return font.styles?.length ? font.styles : ["normal"];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#getFontFaces(font: ReactFont): ReactFontFace[] {
|
|
180
|
+
const enabledStyles = new Set(this.#getFontStyles(font));
|
|
181
|
+
return font.paths
|
|
182
|
+
.map((fontPath) => {
|
|
183
|
+
const style = fontPath.style ?? "normal";
|
|
184
|
+
return {
|
|
185
|
+
font,
|
|
186
|
+
path: fontPath,
|
|
187
|
+
src: fontPath.src,
|
|
188
|
+
weight: fontPath.weight,
|
|
189
|
+
style,
|
|
190
|
+
optimizedSrc: this.#getOptimizedFontSrc(font, fontPath),
|
|
191
|
+
};
|
|
192
|
+
})
|
|
193
|
+
.filter((face) => enabledStyles.has(face.style));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#getOptimizedFontSrc(font: ReactFont, fontPath: ReactFontPath) {
|
|
197
|
+
const style = fontPath.style ?? "normal";
|
|
198
|
+
const hash = this.#hashFontConfig({
|
|
199
|
+
name: font.name,
|
|
200
|
+
src: fontPath.src,
|
|
201
|
+
weight: fontPath.weight,
|
|
202
|
+
style,
|
|
203
|
+
display: font.display,
|
|
204
|
+
subset: font.subset,
|
|
205
|
+
subsets: this.#getFontSubsets(font),
|
|
206
|
+
subsetText: font.subsetText,
|
|
207
|
+
subsetFiles: font.subsetFiles,
|
|
208
|
+
declarations: [...(font.declarations ?? []), ...(fontPath.declarations ?? [])],
|
|
209
|
+
});
|
|
210
|
+
return `${FONT_URL_PREFIX}/${this.#slugFontPart(font.name)}-${this.#slugFontPart(String(fontPath.weight))}-${style}-${hash}.woff2`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#hashFontConfig(value: unknown) {
|
|
214
|
+
const input = this.#stableStringify(value);
|
|
215
|
+
let hash = 0x811c9dc5;
|
|
216
|
+
for (let i = 0; i < input.length; i++) {
|
|
217
|
+
hash ^= input.charCodeAt(i);
|
|
218
|
+
hash = Math.imul(hash, 0x01000193);
|
|
219
|
+
}
|
|
220
|
+
return (hash >>> 0).toString(36);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#stableStringify(value: unknown): string {
|
|
224
|
+
if (Array.isArray(value)) return `[${value.map((item) => this.#stableStringify(item)).join(",")}]`;
|
|
225
|
+
if (value && typeof value === "object") {
|
|
226
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
227
|
+
.filter(([, v]) => v !== undefined)
|
|
228
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
229
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${this.#stableStringify(v)}`).join(",")}}`;
|
|
230
|
+
}
|
|
231
|
+
return JSON.stringify(value);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#slugFontPart(value: string) {
|
|
235
|
+
return (
|
|
236
|
+
value
|
|
237
|
+
.trim()
|
|
238
|
+
.toLowerCase()
|
|
239
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
240
|
+
.replace(/^-+|-+$/g, "") || "font"
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async #resolveFontSourcePath(src: string) {
|
|
245
|
+
if (!src.startsWith("/")) return null;
|
|
246
|
+
const rel = src.replace(/^\//, "");
|
|
247
|
+
const candidates = [
|
|
248
|
+
this.#command === "build" ? path.join(this.#app.dist.cwdPath, "public", rel) : null,
|
|
249
|
+
path.join(this.#app.cwdPath, "public", rel),
|
|
250
|
+
this.#resolveWorkspacePublicPath(rel),
|
|
251
|
+
].filter(Boolean) as string[];
|
|
252
|
+
for (const candidate of candidates) {
|
|
253
|
+
if (await Bun.file(candidate).exists()) return candidate;
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async #convertToWoff2(buffer: Buffer, sourcePath: string) {
|
|
259
|
+
await this.#initWoff2();
|
|
260
|
+
const font = createFont(buffer, { type: this.#getFontType(sourcePath, buffer) });
|
|
261
|
+
return font.write({ type: "woff2", toBuffer: true });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async #initWoff2() {
|
|
265
|
+
this.#woff2Ready ??= woff2.init().then(() => undefined);
|
|
266
|
+
return this.#woff2Ready;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
#getFontType(sourcePath: string, buffer: Buffer) {
|
|
270
|
+
const signature = buffer.toString("ascii", 0, 4);
|
|
271
|
+
if (signature === "wOFF") return "woff";
|
|
272
|
+
if (signature === "wOF2") return "woff2";
|
|
273
|
+
if (signature === "OTTO") return "otf";
|
|
274
|
+
const ext = path.extname(sourcePath).slice(1).toLowerCase();
|
|
275
|
+
if (ext === "otf" || ext === "woff" || ext === "woff2") return ext;
|
|
276
|
+
return "ttf";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
#resolveWorkspacePublicPath(rel: string) {
|
|
280
|
+
const [root, dep, ...rest] = rel.split("/");
|
|
281
|
+
if (root !== "libs" || !dep || rest.length === 0) return null;
|
|
282
|
+
return path.join(this.#app.workspace.workspaceRoot, "libs", dep, "public", ...rest);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async #getSubsetText(font: ReactFont) {
|
|
286
|
+
const parts = new Set<string>();
|
|
287
|
+
const subsets = this.#getFontSubsets(font);
|
|
288
|
+
for (const subset of subsets) parts.add(await this.#getSubsetPresetText(subset));
|
|
289
|
+
if (font.subsetText) parts.add(font.subsetText);
|
|
290
|
+
for (const filePath of font.subsetFiles ?? []) {
|
|
291
|
+
const abs = path.isAbsolute(filePath) ? filePath : path.join(this.#app.cwdPath, filePath);
|
|
292
|
+
const file = Bun.file(abs);
|
|
293
|
+
if (await file.exists()) parts.add(await file.text());
|
|
294
|
+
}
|
|
295
|
+
return [...parts].join("");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async #getSubsetPresetText(subset: ReactFontSubset) {
|
|
299
|
+
if (subset === "latin") return this.#rangeText(0x20, 0x7e);
|
|
300
|
+
if (subset === "latin-ext") return `${this.#rangeText(0x20, 0x7e)}${this.#rangeText(0xa0, 0x024f)}`;
|
|
301
|
+
if (subset === "ks-x-1001") return FontOptimizer.#getKsX1001Text();
|
|
302
|
+
if (subset === "auto") return this.#collectAutoSubsetText();
|
|
303
|
+
return "";
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async #collectAutoSubsetText() {
|
|
307
|
+
const roots = ["page", "ui"].map((dir) => path.join(this.#app.cwdPath, dir));
|
|
308
|
+
const glob = new Bun.Glob("**/*.{ts,tsx,js,jsx,html,md}");
|
|
309
|
+
const parts: string[] = [];
|
|
310
|
+
await Promise.all(
|
|
311
|
+
roots.map(async (root) => {
|
|
312
|
+
if (!(await Bun.file(root).exists())) return;
|
|
313
|
+
for await (const filePath of glob.scan({ cwd: root, absolute: true })) {
|
|
314
|
+
parts.push(await Bun.file(filePath).text());
|
|
315
|
+
}
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
return parts.join("");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#rangeText(start: number, end: number) {
|
|
322
|
+
let text = "";
|
|
323
|
+
for (let code = start; code <= end; code++) text += String.fromCodePoint(code);
|
|
324
|
+
return text;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
static #getKsX1001Text() {
|
|
328
|
+
if (FontOptimizer.#ksX1001Text) return FontOptimizer.#ksX1001Text;
|
|
329
|
+
try {
|
|
330
|
+
const decoder = new TextDecoder("euc-kr");
|
|
331
|
+
const chars = new Set<string>();
|
|
332
|
+
for (let lead = 0xa1; lead <= 0xfe; lead++) {
|
|
333
|
+
for (let trail = 0xa1; trail <= 0xfe; trail++) {
|
|
334
|
+
const char = decoder.decode(Uint8Array.of(lead, trail));
|
|
335
|
+
if (char && char !== "\uFFFD") chars.add(char);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
FontOptimizer.#ksX1001Text = [...chars].join("");
|
|
339
|
+
} catch {
|
|
340
|
+
FontOptimizer.#ksX1001Text = FontOptimizer.#rangeTextStatic(0xac00, 0xd7a3);
|
|
341
|
+
}
|
|
342
|
+
return FontOptimizer.#ksX1001Text;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
static #rangeTextStatic(start: number, end: number) {
|
|
346
|
+
let text = "";
|
|
347
|
+
for (let code = start; code <= end; code++) text += String.fromCodePoint(code);
|
|
348
|
+
return text;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#buildOptimizedFontFaceRule(font: ReactFont, face: ReactFontFace) {
|
|
352
|
+
const declarations = [
|
|
353
|
+
["font-family", this.#quote(font.name)],
|
|
354
|
+
["src", `url(${this.#quote(face.optimizedSrc)}) format("woff2")`],
|
|
355
|
+
["font-weight", String(face.weight)],
|
|
356
|
+
["font-style", face.style],
|
|
357
|
+
["font-display", font.display ?? "swap"],
|
|
358
|
+
...this.#toDeclarationEntries(font.declarations),
|
|
359
|
+
...this.#toDeclarationEntries(face.path.declarations),
|
|
360
|
+
];
|
|
361
|
+
return `@font-face {\n${declarations.map(([prop, value]) => ` ${prop}: ${value};`).join("\n")}\n}`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async #buildFontaineFallbackCss(font: ReactFont, face: ReactFontFace, outputPath: string) {
|
|
365
|
+
if (font.adjustFontFallback === false) return "";
|
|
366
|
+
const metrics = await readMetrics(outputPath).catch(() => null);
|
|
367
|
+
if (!metrics) return "";
|
|
368
|
+
const fallbacks = resolveCategoryFallbacks({
|
|
369
|
+
fontFamily: font.name,
|
|
370
|
+
fallbacks: font.fallbacks ?? {},
|
|
371
|
+
metrics: { ...metrics, category: font.category },
|
|
372
|
+
});
|
|
373
|
+
const css: string[] = [];
|
|
374
|
+
for (let i = fallbacks.length - 1; i >= 0; i--) {
|
|
375
|
+
const fallback = fallbacks[i];
|
|
376
|
+
const fallbackMetrics = await getMetricsForFamily(fallback);
|
|
377
|
+
if (!fallbackMetrics) continue;
|
|
378
|
+
css.push(
|
|
379
|
+
generateFontFace(
|
|
380
|
+
{ ...metrics, category: font.category as FontCategory | undefined },
|
|
381
|
+
{
|
|
382
|
+
name: this.#getFontFallbackName(font),
|
|
383
|
+
font: fallback,
|
|
384
|
+
metrics: fallbackMetrics,
|
|
385
|
+
"font-weight": String(face.weight),
|
|
386
|
+
"font-style": face.style,
|
|
387
|
+
},
|
|
388
|
+
),
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
return css.join("");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
#buildRootVariableRule(font: ReactFont) {
|
|
395
|
+
return `:root { ${this.#getFontVariableName(font)}: ${this.#quote(font.name)}, ${this.#quote(
|
|
396
|
+
this.#getFontFallbackName(font),
|
|
397
|
+
)}; }`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
#buildFontUtilityRules(fonts: ReactFont[]) {
|
|
401
|
+
const rules: string[] = [];
|
|
402
|
+
const seen = new Set<string>();
|
|
403
|
+
for (const font of fonts) {
|
|
404
|
+
const className = font.className || `font-${font.name}`;
|
|
405
|
+
const selector = `.${this.#escapeCssClassName(className)}`;
|
|
406
|
+
const rule = `${selector} { font-family: var(${this.#getFontVariableName(font)}); }`;
|
|
407
|
+
if (seen.has(rule)) continue;
|
|
408
|
+
seen.add(rule);
|
|
409
|
+
rules.push(rule);
|
|
410
|
+
}
|
|
411
|
+
return rules.join("\n");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
#escapeCssClassName(value: string) {
|
|
415
|
+
return value.replace(/^-?\d|[^a-zA-Z0-9_-]/g, (part) =>
|
|
416
|
+
[...part].map((char) => `\\${char.codePointAt(0)?.toString(16)} `).join(""),
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
#toDeclarationEntries(declarations: ReactFontDeclaration[] = []) {
|
|
421
|
+
return declarations.map((declaration) => [declaration.prop, declaration.value] as const);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
#quote(value: string) {
|
|
425
|
+
return JSON.stringify(value);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
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 { fileURLToPath } from "node:url";
|
|
6
|
+
import type { RoutesManifest } from "akanjs/server";
|
|
7
|
+
import { CsrArtifactBuilder } from "./csrArtifactBuilder";
|
|
8
|
+
import { CssCompiler, isIgnoredNodeModuleSource } from "./cssCompiler";
|
|
9
|
+
import { CssImportResolver } from "./cssImportResolver";
|
|
10
|
+
import { HmrChangeClassifier } from "./hmrChangeClassifier";
|
|
11
|
+
import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
|
|
12
|
+
import { RoutesManifestArtifactSerializer } from "./routesManifestArtifactSerializer";
|
|
13
|
+
|
|
14
|
+
const tempRoots: string[] = [];
|
|
15
|
+
|
|
16
|
+
const makeTempRoot = async () => {
|
|
17
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-devkit-frontend-"));
|
|
18
|
+
tempRoots.push(root);
|
|
19
|
+
return root;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const write = async (filePath: string, content: string) => {
|
|
23
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
24
|
+
await writeFile(filePath, content);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("PagesEntrySourceGenerator", () => {
|
|
32
|
+
test("generates dynamic import source using absolute module paths", () => {
|
|
33
|
+
const source = PagesEntrySourceGenerator.generate([
|
|
34
|
+
{ key: "./_index.tsx", moduleAbsPath: "/repo/apps/demo/page/_index.tsx" },
|
|
35
|
+
{ key: "./admin.tsx", moduleAbsPath: "/repo/apps/demo/page/admin.tsx" },
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
expect(source).toBe(
|
|
39
|
+
[
|
|
40
|
+
"export const pages = {",
|
|
41
|
+
' "./_index.tsx": () => import("/repo/apps/demo/page/_index.tsx"),',
|
|
42
|
+
' "./admin.tsx": () => import("/repo/apps/demo/page/admin.tsx"),',
|
|
43
|
+
"};",
|
|
44
|
+
"",
|
|
45
|
+
].join("\n"),
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("generates static import source for single-file CSR bundles", () => {
|
|
50
|
+
const source = PagesEntrySourceGenerator.generateStatic([
|
|
51
|
+
{ key: "./_index.tsx", moduleAbsPath: "/repo/apps/demo/page/_index.tsx" },
|
|
52
|
+
{ key: "./admin.tsx", moduleAbsPath: "/repo/apps/demo/page/admin.tsx" },
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
expect(source).toBe(
|
|
56
|
+
[
|
|
57
|
+
'import * as page0 from "/repo/apps/demo/page/_index.tsx";',
|
|
58
|
+
'import * as page1 from "/repo/apps/demo/page/admin.tsx";',
|
|
59
|
+
"export const pages = {",
|
|
60
|
+
' "./_index.tsx": async () => page0,',
|
|
61
|
+
' "./admin.tsx": async () => page1,',
|
|
62
|
+
"};",
|
|
63
|
+
"",
|
|
64
|
+
].join("\n"),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("CsrArtifactBuilder", () => {
|
|
70
|
+
test("replaces module script src with inline script", async () => {
|
|
71
|
+
const html = [
|
|
72
|
+
"<html>",
|
|
73
|
+
"<head></head>",
|
|
74
|
+
"<body>",
|
|
75
|
+
'<script type="module" crossorigin src="./chunk.js"></script>',
|
|
76
|
+
"</body>",
|
|
77
|
+
"</html>",
|
|
78
|
+
].join("\n");
|
|
79
|
+
|
|
80
|
+
const inlined = await CsrArtifactBuilder.replaceModuleScriptSrc(html, (src) => {
|
|
81
|
+
expect(src).toBe("./chunk.js");
|
|
82
|
+
return 'console.log("</script>");';
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(inlined).toContain('<script type="module">\nconsole.log("<\\/script>");\n</script>');
|
|
86
|
+
expect(inlined).not.toContain("src=");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("creates inline stylesheet and strips external stylesheet links", () => {
|
|
90
|
+
const html =
|
|
91
|
+
'<head><link rel="stylesheet" href="/_akan/styles/akanjs.css" data-akan-css="active" /><link rel="stylesheet" href="./generated.css" /></head>';
|
|
92
|
+
const stripped = CsrArtifactBuilder.stripBundledStylesheetLinks(html);
|
|
93
|
+
const style = CsrArtifactBuilder.createInlineStyle("body::before{content:'</style>';}");
|
|
94
|
+
|
|
95
|
+
expect(stripped).toBe("<head></head>");
|
|
96
|
+
expect(style).toBe("<style data-akan-css=\"active\">\nbody::before{content:'<\\/style>';}\n</style>");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("RoutesManifestArtifactSerializer", () => {
|
|
101
|
+
test("serializes absolute artifact paths relative to artifact directory", () => {
|
|
102
|
+
const manifest = {
|
|
103
|
+
knownEntries: ["/repo/dist/apps/demo/.akan/artifact/pages.js", "already-relative.js"],
|
|
104
|
+
clientManifest: {
|
|
105
|
+
"/repo/dist/apps/demo/.akan/artifact/client.js": { file: "client.js" },
|
|
106
|
+
},
|
|
107
|
+
ssrManifest: {
|
|
108
|
+
moduleMap: {
|
|
109
|
+
"/entry": {
|
|
110
|
+
default: {
|
|
111
|
+
id: "/repo/dist/apps/demo/.akan/artifact/server/page.js",
|
|
112
|
+
chunks: ["/repo/dist/apps/demo/.akan/artifact/chunks/a.js", "chunks/b.js"],
|
|
113
|
+
name: "default",
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
} as unknown as RoutesManifest;
|
|
119
|
+
|
|
120
|
+
const serialized = RoutesManifestArtifactSerializer.serialize(manifest, "/repo/dist/apps/demo/.akan/artifact");
|
|
121
|
+
expect(serialized.knownEntries).toEqual(["pages.js", "already-relative.js"]);
|
|
122
|
+
expect(Object.keys(serialized.clientManifest)).toEqual(["client.js"]);
|
|
123
|
+
expect(serialized.ssrManifest.moduleMap["/entry"]?.default?.id).toBe("server/page.js");
|
|
124
|
+
expect(serialized.ssrManifest.moduleMap["/entry"]?.default?.chunks).toEqual(["chunks/a.js", "chunks/b.js"]);
|
|
125
|
+
|
|
126
|
+
const production = RoutesManifestArtifactSerializer.serialize(manifest, "/repo/dist/apps/demo/.akan/artifact", {
|
|
127
|
+
production: true,
|
|
128
|
+
});
|
|
129
|
+
expect(production.knownEntries).toBeUndefined();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("HmrChangeClassifier", () => {
|
|
134
|
+
test("classifies code, css, config, and ignored files", () => {
|
|
135
|
+
const classifier = new HmrChangeClassifier();
|
|
136
|
+
expect(classifier.classify("/repo/apps/demo/page/_index.tsx")).toBe("code");
|
|
137
|
+
expect(classifier.classify("/repo/apps/demo/page/styles.css")).toBe("css");
|
|
138
|
+
expect(classifier.classify("/repo/apps/demo/akan.config.ts")).toBe("config");
|
|
139
|
+
expect(classifier.classify("/repo/apps/demo/.DS_Store")).toBe("ignore");
|
|
140
|
+
expect(classifier.classify(`/repo/apps/demo/node_modules/pkg/index.ts`)).toBe("ignore");
|
|
141
|
+
expect(classifier.classify(`/repo/apps/demo/.akan/generated/page.tsx`)).toBe("ignore");
|
|
142
|
+
expect(classifier.classify("/repo/apps/demo/public/logo.png")).toBe("ignore");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("CssImportResolver", () => {
|
|
147
|
+
test("identifies package names and css files", () => {
|
|
148
|
+
expect(CssImportResolver.getPackageName("@scope/pkg/button")).toBe("@scope/pkg");
|
|
149
|
+
expect(CssImportResolver.getPackageName("plain-package/styles")).toBe("plain-package");
|
|
150
|
+
expect(CssImportResolver.getPackageName("@broken")).toBeNull();
|
|
151
|
+
expect(CssImportResolver.isCssFile("/repo/style.css")).toBe(true);
|
|
152
|
+
expect(CssImportResolver.isCssFile("/repo/style.scss")).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("resolves css from exact and wildcard tsconfig paths", async () => {
|
|
156
|
+
const root = await makeTempRoot();
|
|
157
|
+
await write(path.join(root, "styles/global.css"), "body {}\n");
|
|
158
|
+
await write(path.join(root, "libs/ui/button/styles.css"), ".button {}\n");
|
|
159
|
+
|
|
160
|
+
const resolver = new CssImportResolver(root, {
|
|
161
|
+
"@styles/global": ["styles/global"],
|
|
162
|
+
"@libs/ui/*": ["libs/ui/*"],
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(await resolver.resolve("@styles/global", root)).toBe(path.join(root, "styles/global.css"));
|
|
166
|
+
expect(await resolver.resolve("@libs/ui/button", root)).toBe(path.join(root, "libs/ui/button/styles.css"));
|
|
167
|
+
expect(await resolver.resolve("@libs/ui/missing", root)).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("resolves css from single-package Akan workspace subpaths", async () => {
|
|
171
|
+
const root = await makeTempRoot();
|
|
172
|
+
await write(path.join(root, "pkgs/akanjs/ui/styles.css"), "body {}\n");
|
|
173
|
+
|
|
174
|
+
const resolver = new CssImportResolver(root, {
|
|
175
|
+
"akanjs/ui/*": ["pkgs/akanjs/ui/*"],
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(await resolver.resolve("akanjs/ui/styles.css", root)).toBe(path.join(root, "pkgs/akanjs/ui/styles.css"));
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("CssCompiler", () => {
|
|
183
|
+
test("scans installed akanjs sources while ignoring other node_modules", async () => {
|
|
184
|
+
expect(isIgnoredNodeModuleSource("/repo/node_modules/react/index.js")).toBe(true);
|
|
185
|
+
expect(isIgnoredNodeModuleSource("/repo/node_modules/akanjs/ui/Button.tsx")).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("includes Tailwind candidates from installed akanjs ui sources", async () => {
|
|
189
|
+
const root = await makeTempRoot();
|
|
190
|
+
const cssPath = path.join(root, "node_modules/akanjs/ui/styles.css");
|
|
191
|
+
const uiSource = path.join(root, "node_modules/akanjs/ui/Button.tsx");
|
|
192
|
+
const tailwindCssPath = fileURLToPath(await import.meta.resolve("tailwindcss/index.css"));
|
|
193
|
+
await write(cssPath, `@import ${JSON.stringify(tailwindCssPath)};\n@source "./**/*";\n`);
|
|
194
|
+
await write(uiSource, 'export const Button = () => <button className="text-fuchsia-500" />;\n');
|
|
195
|
+
|
|
196
|
+
const compiler = new CssCompiler({
|
|
197
|
+
workspace: { workspaceRoot: root },
|
|
198
|
+
getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
|
|
199
|
+
} as never);
|
|
200
|
+
const css = await compiler.compileCss([cssPath], []);
|
|
201
|
+
|
|
202
|
+
expect(css).toContain(".text-fuchsia-500");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { ChangeKind } from "akanjs/server";
|
|
3
|
+
|
|
4
|
+
const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
5
|
+
const CSS_EXTS = new Set([".css"]);
|
|
6
|
+
const CONFIG_BASENAMES = new Set(["akan.config.ts", "bunfig.toml", "tsconfig.json", "package.json"]);
|
|
7
|
+
|
|
8
|
+
export class HmrChangeClassifier {
|
|
9
|
+
classify(abs: string): ChangeKind {
|
|
10
|
+
if (this.#isUninteresting(abs)) return "ignore";
|
|
11
|
+
const base = path.basename(abs);
|
|
12
|
+
if (CONFIG_BASENAMES.has(base)) return "config";
|
|
13
|
+
const ext = path.extname(abs).toLowerCase();
|
|
14
|
+
if (CSS_EXTS.has(ext)) return "css";
|
|
15
|
+
if (SOURCE_EXTS.has(ext)) return "code";
|
|
16
|
+
return "ignore";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#isUninteresting(abs: string): boolean {
|
|
20
|
+
const base = path.basename(abs);
|
|
21
|
+
if (!base) return true;
|
|
22
|
+
if (base.startsWith(".")) return true; // .git, .DS_Store, vim swaps, etc.
|
|
23
|
+
if (base.endsWith("~") || base.endsWith(".swp") || base.endsWith(".swx") || base.endsWith(".tmp")) return true;
|
|
24
|
+
if (abs.includes(`${path.sep}node_modules${path.sep}`)) return true;
|
|
25
|
+
if (abs.includes(`${path.sep}.akan${path.sep}`)) return true;
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|