@akanjs/devkit 3.0.0-alpha.105 → 3.0.0-alpha.106
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.
|
@@ -17,15 +17,16 @@ const makeApp = async (layoutSource: string) => {
|
|
|
17
17
|
await mkdir(path.join(cwdPath, "public/fonts"), { recursive: true });
|
|
18
18
|
await writeFile(path.join(cwdPath, "page/_layout.tsx"), layoutSource);
|
|
19
19
|
await Bun.write(path.join(cwdPath, "public/fonts/Assistant-Regular.woff2"), Bun.file(SOURCE_FONT));
|
|
20
|
+
const warnings: string[] = [];
|
|
20
21
|
const app = {
|
|
21
22
|
cwdPath,
|
|
22
23
|
dist: { cwdPath: path.join(root, "dist/apps/demo") },
|
|
23
24
|
workspace: { workspaceRoot: root },
|
|
24
25
|
getPageKeys: async () => ["./_layout.tsx"],
|
|
25
26
|
verbose: () => undefined,
|
|
26
|
-
logger: { warn: () =>
|
|
27
|
+
logger: { warn: (message: string) => warnings.push(message) },
|
|
27
28
|
} as unknown as App;
|
|
28
|
-
return { app, cwdPath };
|
|
29
|
+
return { app, cwdPath, warnings };
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
const layoutWith = (extra = "") => `
|
|
@@ -40,6 +41,22 @@ export default function Layout() {
|
|
|
40
41
|
}
|
|
41
42
|
`;
|
|
42
43
|
|
|
44
|
+
const chainLayoutWith = (declaration: string) => `
|
|
45
|
+
import { rootLayout } from "akanjs/client";
|
|
46
|
+
|
|
47
|
+
export default rootLayout()
|
|
48
|
+
.fonts(${declaration})
|
|
49
|
+
.theme("dark")
|
|
50
|
+
.render(({ children }) => children);
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
const chainFontEntry = (extra = "") => `{
|
|
54
|
+
name: "Assistant",${extra}
|
|
55
|
+
paths: [{ src: "/fonts/Assistant-Regular.woff2", weight: 400 }],
|
|
56
|
+
}`;
|
|
57
|
+
|
|
58
|
+
const chainFontList = (extra = "") => `[\n ${chainFontEntry(extra)},\n ]`;
|
|
59
|
+
|
|
43
60
|
const optimize = (app: App) => new FontOptimizer(app, "start").optimize();
|
|
44
61
|
|
|
45
62
|
afterEach(async () => {
|
|
@@ -109,3 +126,59 @@ describe("FontOptimizer cache", () => {
|
|
|
109
126
|
expect(result.files).toEqual([]);
|
|
110
127
|
});
|
|
111
128
|
});
|
|
129
|
+
|
|
130
|
+
describe("FontOptimizer discovery", () => {
|
|
131
|
+
test("reads the .fonts() stage of a rootLayout() chain", async () => {
|
|
132
|
+
const { app, warnings } = await makeApp(chainLayoutWith(chainFontList()));
|
|
133
|
+
|
|
134
|
+
const result = await optimize(app);
|
|
135
|
+
|
|
136
|
+
expect(result.fonts.map((font) => font.name)).toEqual(["Assistant"]);
|
|
137
|
+
expect(result.files).toHaveLength(1);
|
|
138
|
+
expect(result.css).toContain("@font-face");
|
|
139
|
+
expect(warnings).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("keeps reading the legacy fonts export", async () => {
|
|
143
|
+
const { app } = await makeApp(layoutWith());
|
|
144
|
+
const result = await optimize(app);
|
|
145
|
+
expect(result.fonts.map((font) => font.name)).toEqual(["Assistant"]);
|
|
146
|
+
expect(result.files).toHaveLength(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("picks up a config field the chain declares, so the cache key sees it", async () => {
|
|
150
|
+
const { app, cwdPath } = await makeApp(chainLayoutWith(chainFontList()));
|
|
151
|
+
const first = await optimize(app);
|
|
152
|
+
|
|
153
|
+
await writeFile(
|
|
154
|
+
path.join(cwdPath, "page/_layout.tsx"),
|
|
155
|
+
chainLayoutWith(chainFontList(`\n className: "font-brand",`)),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const second = await optimize(app);
|
|
159
|
+
expect(second.css).not.toBe(first.css);
|
|
160
|
+
expect(second.css).toContain(".font-brand");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// A list the build cannot read subsets nothing while the runtime still preloads /_akan/fonts, so the 404s
|
|
164
|
+
// have to be announced at build time rather than found in a browser console.
|
|
165
|
+
test("warns when the chain is handed a font list it cannot read", async () => {
|
|
166
|
+
const { app, warnings } = await makeApp(chainLayoutWith("brandFonts"));
|
|
167
|
+
|
|
168
|
+
const result = await optimize(app);
|
|
169
|
+
|
|
170
|
+
expect(result.fonts).toEqual([]);
|
|
171
|
+
expect(result.files).toEqual([]);
|
|
172
|
+
expect(warnings).toHaveLength(1);
|
|
173
|
+
expect(warnings[0]).toContain("page/_layout.tsx");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("warns about an entry it cannot read and keeps the ones it can", async () => {
|
|
177
|
+
const { app, warnings } = await makeApp(chainLayoutWith(`[...brandFonts, ${chainFontEntry()}]`));
|
|
178
|
+
|
|
179
|
+
const result = await optimize(app);
|
|
180
|
+
|
|
181
|
+
expect(result.fonts.map((font) => font.name)).toEqual(["Assistant"]);
|
|
182
|
+
expect(warnings).toHaveLength(1);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -152,7 +152,7 @@ export class FontOptimizer {
|
|
|
152
152
|
const file = Bun.file(filePath);
|
|
153
153
|
if (!(await file.exists())) return;
|
|
154
154
|
const source = await file.text();
|
|
155
|
-
// A
|
|
155
|
+
// A `.fonts()` stage or a `fonts` declaration cannot exist in text that never mentions it, and parsing the
|
|
156
156
|
// route files that never declare one is what a cached optimize() otherwise spends its time on.
|
|
157
157
|
if (!source.includes("fonts")) return;
|
|
158
158
|
fonts.push(...this.#extractFontsExport(source, filePath));
|
|
@@ -186,23 +186,75 @@ export class FontOptimizer {
|
|
|
186
186
|
if (faceCss.length > 0) this.#cssParts.push(...faceCss, this.#buildRootVariableRule(font));
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/**
|
|
190
|
+
* The font list of a route file, from the `.fonts([…])` stage of its default-exported `rootLayout()` chain
|
|
191
|
+
* or from the legacy `export const fonts`. Only an inline literal is readable, because the build enumerates
|
|
192
|
+
* routes without evaluating them — and a list it cannot read is warned about rather than skipped in silence:
|
|
193
|
+
* the runtime still emits `/_akan/fonts` preloads for fonts nothing subset, and every one of them 404s.
|
|
194
|
+
*/
|
|
189
195
|
#extractFontsExport(source: string, filePath: string): ReactFont[] {
|
|
190
196
|
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
191
|
-
const
|
|
197
|
+
const declaration = this.#findFontsDeclaration(sourceFile);
|
|
198
|
+
if (!declaration) return [];
|
|
199
|
+
const value = this.#literalToValue(declaration);
|
|
200
|
+
const entries: unknown[] = Array.isArray(value) ? value : [];
|
|
201
|
+
const fonts = entries.filter((entry): entry is ReactFont => this.#isReadableFont(entry));
|
|
202
|
+
if (fonts.length !== entries.length || !Array.isArray(value))
|
|
203
|
+
this.#app.logger.warn(
|
|
204
|
+
`[font] ${path.relative(this.#app.cwdPath, filePath)} declares fonts the build cannot read without evaluating the module — write the list inline, or nothing is subset for it and every /_akan/fonts request 404s`,
|
|
205
|
+
);
|
|
206
|
+
return fonts.map((font) => this.#withFontDefaults(font));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#isReadableFont(value: unknown): value is ReactFont {
|
|
210
|
+
if (!value || typeof value !== "object") return false;
|
|
211
|
+
const font = value as Partial<ReactFont>;
|
|
212
|
+
return typeof font.name === "string" && Array.isArray(font.paths);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#findFontsDeclaration(sourceFile: ts.SourceFile): ts.Expression | null {
|
|
192
216
|
for (const statement of sourceFile.statements) {
|
|
217
|
+
if (ts.isExportAssignment(statement)) {
|
|
218
|
+
if (statement.isExportEquals) continue;
|
|
219
|
+
const stage = this.#findChainStageArgument(statement.expression, "fonts");
|
|
220
|
+
if (stage) return stage;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
193
223
|
if (!ts.isVariableStatement(statement)) continue;
|
|
194
224
|
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
|
|
195
|
-
|
|
196
|
-
if (!isExported) continue;
|
|
225
|
+
if (!modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue;
|
|
197
226
|
for (const declaration of statement.declarationList.declarations) {
|
|
198
227
|
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "fonts") continue;
|
|
199
|
-
|
|
200
|
-
if (Array.isArray(value)) {
|
|
201
|
-
fonts.push(...(value as ReactFont[]).map((font) => this.#withFontDefaults(font)));
|
|
202
|
-
}
|
|
228
|
+
if (declaration.initializer) return declaration.initializer;
|
|
203
229
|
}
|
|
204
230
|
}
|
|
205
|
-
return
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Walks a `rootLayout()` chain from its outermost call inward, so the stage that wins at runtime is the one read. */
|
|
235
|
+
#findChainStageArgument(expression: ts.Expression, stageName: string): ts.Expression | null {
|
|
236
|
+
let node = this.#unwrapExpression(expression);
|
|
237
|
+
let argument: ts.Expression | null = null;
|
|
238
|
+
while (node && ts.isCallExpression(node)) {
|
|
239
|
+
const callee = this.#unwrapExpression(node.expression);
|
|
240
|
+
if (!callee) return null;
|
|
241
|
+
if (ts.isIdentifier(callee)) return callee.text === "rootLayout" ? argument : null;
|
|
242
|
+
if (!ts.isPropertyAccessExpression(callee)) return null;
|
|
243
|
+
if (callee.name.text === stageName) argument ??= node.arguments[0] ?? null;
|
|
244
|
+
node = this.#unwrapExpression(callee.expression);
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#unwrapExpression(expression?: ts.Expression): ts.Expression | undefined {
|
|
250
|
+
let current = expression;
|
|
251
|
+
while (
|
|
252
|
+
current &&
|
|
253
|
+
(ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isParenthesizedExpression(current))
|
|
254
|
+
) {
|
|
255
|
+
current = current.expression;
|
|
256
|
+
}
|
|
257
|
+
return current;
|
|
206
258
|
}
|
|
207
259
|
|
|
208
260
|
#literalToValue(node: ts.Node): unknown {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.106",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"@langchain/openai": "^1.4.6",
|
|
45
45
|
"@tailwindcss/node": "^4.3.0",
|
|
46
46
|
"@trapezedev/project": "^7.1.4",
|
|
47
|
-
"akanjs": "3.0.0-alpha.
|
|
47
|
+
"akanjs": "3.0.0-alpha.106",
|
|
48
48
|
"chalk": "^5.6.2",
|
|
49
49
|
"commander": "^14.0.3",
|
|
50
50
|
"dayjs": "^1.11.20",
|