@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
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { rm, rmdir, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isFontOptimizationEnabled } from "akanjs/client";
|
|
4
|
+
import type { App } from "../commandDecorators";
|
|
5
|
+
import { FontOptimizer } from "./fontOptimizer";
|
|
6
|
+
|
|
7
|
+
const FONT_GLOB = "**/*.{woff2,woff,ttf,otf,ttc,eot}";
|
|
8
|
+
const REFERRER_GLOB = "**/*.{css,js,mjs,cjs,json,html,htm,svg,xml,webmanifest}";
|
|
9
|
+
|
|
10
|
+
export type FontKeepReason =
|
|
11
|
+
| { kind: "unoptimized" }
|
|
12
|
+
| { kind: "declared"; glob: string }
|
|
13
|
+
| { kind: "referenced"; referrer: string };
|
|
14
|
+
|
|
15
|
+
export interface FontPruneResult {
|
|
16
|
+
removed: { file: string; bytes: number }[];
|
|
17
|
+
kept: { file: string; bytes: number; reason: FontKeepReason }[];
|
|
18
|
+
freedBytes: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Drops the font sources in a build's `public/` that no built surface reads. A font with `optimize` on is
|
|
23
|
+
* served from `/_akan/fonts` after subsetting, so its source is a build input — the image ships it and the
|
|
24
|
+
* runtime never opens it. Only `dist` is touched: an app's and a lib's own `public/` keep every file, because
|
|
25
|
+
* one lib's fonts are picked over differently by every app that mounts it and by other repos.
|
|
26
|
+
*/
|
|
27
|
+
export class FontPruner {
|
|
28
|
+
#app: App;
|
|
29
|
+
#keepGlobs: string[];
|
|
30
|
+
#publicRoot: string;
|
|
31
|
+
#artifactRoot: string;
|
|
32
|
+
|
|
33
|
+
constructor(app: App, { keepFonts = [] }: { keepFonts?: string[] } = {}) {
|
|
34
|
+
this.#app = app;
|
|
35
|
+
this.#keepGlobs = keepFonts;
|
|
36
|
+
this.#publicRoot = path.join(app.dist.cwdPath, "public");
|
|
37
|
+
this.#artifactRoot = path.join(app.dist.cwdPath, ".akan/artifact");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async prune(): Promise<FontPruneResult> {
|
|
41
|
+
const empty: FontPruneResult = { removed: [], kept: [], freedBytes: 0 };
|
|
42
|
+
if (!(await this.#isDirectory(this.#publicRoot))) return empty;
|
|
43
|
+
const candidates = await this.#collectCandidates();
|
|
44
|
+
if (!candidates.length) return empty;
|
|
45
|
+
|
|
46
|
+
const fonts = await new FontOptimizer(this.#app, "build").discoverFonts();
|
|
47
|
+
const unoptimizedSrcs = new Set<string>();
|
|
48
|
+
const optimizedSrcs = new Set<string>();
|
|
49
|
+
for (const font of fonts) {
|
|
50
|
+
const target = isFontOptimizationEnabled(font) ? optimizedSrcs : unoptimizedSrcs;
|
|
51
|
+
for (const fontPath of font.paths) target.add(path.posix.basename(fontPath.src));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const result: FontPruneResult = { removed: [], kept: [], freedBytes: 0 };
|
|
55
|
+
const undecided: typeof candidates = [];
|
|
56
|
+
for (const candidate of candidates) {
|
|
57
|
+
const reason = this.#staticKeepReason(candidate, unoptimizedSrcs);
|
|
58
|
+
if (reason) result.kept.push({ file: candidate.rel, bytes: candidate.bytes, reason });
|
|
59
|
+
else undecided.push(candidate);
|
|
60
|
+
}
|
|
61
|
+
if (undecided.length) {
|
|
62
|
+
const referrers = await this.#findReferrers(undecided, optimizedSrcs);
|
|
63
|
+
for (const candidate of undecided) {
|
|
64
|
+
const referrer = referrers.get(candidate.rel);
|
|
65
|
+
if (referrer)
|
|
66
|
+
result.kept.push({ file: candidate.rel, bytes: candidate.bytes, reason: { kind: "referenced", referrer } });
|
|
67
|
+
else result.removed.push({ file: candidate.rel, bytes: candidate.bytes });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await this.#remove(result.removed.map(({ file }) => file));
|
|
72
|
+
result.freedBytes = result.removed.reduce((total, { bytes }) => total + bytes, 0);
|
|
73
|
+
this.#report(result);
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#staticKeepReason(candidate: { rel: string; basename: string }, unoptimizedSrcs: Set<string>): FontKeepReason | null {
|
|
78
|
+
if (unoptimizedSrcs.has(candidate.basename)) return { kind: "unoptimized" };
|
|
79
|
+
for (const glob of this.#keepGlobs) {
|
|
80
|
+
if (new Bun.Glob(glob).match(candidate.rel)) return { kind: "declared", glob };
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async #collectCandidates() {
|
|
86
|
+
const candidates: { rel: string; basename: string; bytes: number }[] = [];
|
|
87
|
+
for await (const rel of new Bun.Glob(FONT_GLOB).scan({ cwd: this.#publicRoot, dot: false })) {
|
|
88
|
+
const entry = await stat(path.join(this.#publicRoot, rel)).catch(() => null);
|
|
89
|
+
if (!entry?.isFile()) continue;
|
|
90
|
+
candidates.push({ rel: rel.split(path.sep).join("/"), basename: path.basename(rel), bytes: entry.size });
|
|
91
|
+
}
|
|
92
|
+
return candidates;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Matched by basename rather than by URL, so a reference survives every spelling a referrer may use — an
|
|
97
|
+
* absolute or relative `url()`, and the percent-encoded form a font whose filename carries a space needs.
|
|
98
|
+
*
|
|
99
|
+
* The build's own bundles are read on weaker terms than `public/` and the compiled CSS: every route file's
|
|
100
|
+
* `fonts` declaration is inlined into the pages bundle, the client chunks and the CSR shell, so a declared
|
|
101
|
+
* source is in all three whether or not anything loads it. A hit there is therefore ignored for a font the
|
|
102
|
+
* optimizer already subset — that string is the declaration, not a fetch.
|
|
103
|
+
*/
|
|
104
|
+
async #findReferrers(
|
|
105
|
+
candidates: { rel: string; basename: string }[],
|
|
106
|
+
optimizedSrcs: Set<string>,
|
|
107
|
+
): Promise<Map<string, string>> {
|
|
108
|
+
const referrers = new Map<string, string>();
|
|
109
|
+
const authoritative = candidates;
|
|
110
|
+
const bundled = candidates.filter((candidate) => !optimizedSrcs.has(candidate.basename));
|
|
111
|
+
const roots: { dir: string; label: string; needles: typeof candidates }[] = [
|
|
112
|
+
{ dir: this.#publicRoot, label: "public", needles: authoritative },
|
|
113
|
+
{ dir: path.join(this.#artifactRoot, "styles"), label: ".akan/artifact/styles", needles: authoritative },
|
|
114
|
+
{ dir: path.join(this.#artifactRoot, "client"), label: ".akan/artifact/client", needles: bundled },
|
|
115
|
+
{ dir: path.join(this.#artifactRoot, "client-ssr"), label: ".akan/artifact/client-ssr", needles: bundled },
|
|
116
|
+
{ dir: path.join(this.#artifactRoot, "server"), label: ".akan/artifact/server", needles: bundled },
|
|
117
|
+
{ dir: path.join(this.#app.dist.cwdPath, "csr"), label: "csr", needles: bundled },
|
|
118
|
+
];
|
|
119
|
+
for (const root of roots) {
|
|
120
|
+
const pending = root.needles.filter((candidate) => !referrers.has(candidate.rel));
|
|
121
|
+
if (!pending.length || !(await this.#isDirectory(root.dir))) continue;
|
|
122
|
+
await this.#scanRoot(root, pending, referrers);
|
|
123
|
+
}
|
|
124
|
+
return referrers;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async #scanRoot(
|
|
128
|
+
root: { dir: string; label: string },
|
|
129
|
+
pending: { rel: string; basename: string }[],
|
|
130
|
+
referrers: Map<string, string>,
|
|
131
|
+
) {
|
|
132
|
+
const needles = pending.map((candidate) => ({
|
|
133
|
+
rel: candidate.rel,
|
|
134
|
+
forms: [...new Set([candidate.basename, encodeURIComponent(candidate.basename)])],
|
|
135
|
+
}));
|
|
136
|
+
for await (const rel of new Bun.Glob(REFERRER_GLOB).scan({ cwd: root.dir, dot: false })) {
|
|
137
|
+
const remaining = needles.filter((needle) => !referrers.has(needle.rel));
|
|
138
|
+
if (!remaining.length) return;
|
|
139
|
+
const text = await Bun.file(path.join(root.dir, rel))
|
|
140
|
+
.text()
|
|
141
|
+
.catch(() => "");
|
|
142
|
+
if (!text) continue;
|
|
143
|
+
for (const needle of remaining) {
|
|
144
|
+
if (needle.forms.some((form) => text.includes(form)))
|
|
145
|
+
referrers.set(needle.rel, `${root.label}/${rel.split(path.sep).join("/")}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async #remove(files: string[]) {
|
|
151
|
+
await Promise.all(files.map((file) => rm(path.join(this.#publicRoot, file), { force: true })));
|
|
152
|
+
const dirs = [...new Set(files.map((file) => path.posix.dirname(file)))]
|
|
153
|
+
.filter((dir) => dir !== "." && dir !== "/")
|
|
154
|
+
.sort((a, b) => b.length - a.length);
|
|
155
|
+
for (const dir of dirs) await rmdir(path.join(this.#publicRoot, dir)).catch(() => undefined);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The referrer roll-up is `info`, not `verbose`: a generated file that lists every asset in `public/` — a
|
|
160
|
+
* service-worker precache manifest is the usual one — is a real reference and keeps every font it names, so
|
|
161
|
+
* without this line a build that pruned nothing looks the same as a build with nothing to prune.
|
|
162
|
+
*/
|
|
163
|
+
#report(result: FontPruneResult) {
|
|
164
|
+
if (!result.removed.length && !result.kept.length) return;
|
|
165
|
+
for (const { file, bytes } of result.removed)
|
|
166
|
+
this.#app.verbose(`[font-prune] dropped public/${file} (${FontPruner.formatBytes(bytes)})`);
|
|
167
|
+
for (const { file, reason } of result.kept)
|
|
168
|
+
this.#app.verbose(`[font-prune] kept public/${file} — ${FontPruner.describe(reason)}`);
|
|
169
|
+
const byReferrer = new Map<string, { files: number; bytes: number }>();
|
|
170
|
+
for (const { bytes, reason } of result.kept) {
|
|
171
|
+
if (reason.kind !== "referenced") continue;
|
|
172
|
+
const entry = byReferrer.get(reason.referrer) ?? { files: 0, bytes: 0 };
|
|
173
|
+
entry.files += 1;
|
|
174
|
+
entry.bytes += bytes;
|
|
175
|
+
byReferrer.set(reason.referrer, entry);
|
|
176
|
+
}
|
|
177
|
+
if (!byReferrer.size) return;
|
|
178
|
+
const ranked = [...byReferrer.entries()].sort(([, a], [, b]) => b.bytes - a.bytes);
|
|
179
|
+
const named = ranked
|
|
180
|
+
.slice(0, 3)
|
|
181
|
+
.map(([referrer, { files, bytes }]) => `${referrer} (${files}, ${FontPruner.formatBytes(bytes)})`);
|
|
182
|
+
const rest = ranked.length - named.length;
|
|
183
|
+
this.#app.logger.info(
|
|
184
|
+
`[font-prune] kept ${FontPruner.formatBytes(ranked.reduce((total, [, entry]) => total + entry.bytes, 0))} of fonts in public/ because these reference them: ${named.join(", ")}${rest > 0 ? ` and ${rest} more` : ""}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
static describe(reason: FontKeepReason) {
|
|
189
|
+
if (reason.kind === "unoptimized") return "declared with optimize: false, so the runtime CSS serves it";
|
|
190
|
+
if (reason.kind === "declared") return `kept by assets.keepFonts "${reason.glob}"`;
|
|
191
|
+
return `referenced by ${reason.referrer}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
static formatBytes(bytes: number): string {
|
|
195
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
196
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
197
|
+
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async #isDirectory(dir: string) {
|
|
201
|
+
return await stat(dir).then(
|
|
202
|
+
(entry) => entry.isDirectory(),
|
|
203
|
+
() => false,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -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";
|
|
@@ -54,10 +54,10 @@ describe("PagesEntrySourceGenerator", () => {
|
|
|
54
54
|
);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
test("generates static import source for single-file CSR bundles", () => {
|
|
57
|
+
test("generates static import source for single-file CSR bundles", async () => {
|
|
58
58
|
const indexAbs = path.resolve("/repo/apps/demo/page/_index.tsx");
|
|
59
59
|
const adminAbs = path.resolve("/repo/apps/demo/page/admin.tsx");
|
|
60
|
-
const source = PagesEntrySourceGenerator.generateStatic([
|
|
60
|
+
const source = await PagesEntrySourceGenerator.generateStatic([
|
|
61
61
|
{ key: "./_index.tsx", moduleAbsPath: indexAbs },
|
|
62
62
|
{ key: "./admin.tsx", moduleAbsPath: adminAbs },
|
|
63
63
|
]);
|
|
@@ -88,7 +88,7 @@ describe("PagesEntrySourceGenerator", () => {
|
|
|
88
88
|
await write(expressionPath, "export default async () => null;");
|
|
89
89
|
await write(namedExportPath, "async function NamedExport() { return null; }\nexport { NamedExport as default };");
|
|
90
90
|
|
|
91
|
-
const source = PagesEntrySourceGenerator.generateStatic([
|
|
91
|
+
const source = await PagesEntrySourceGenerator.generateStatic([
|
|
92
92
|
{ key: "./_index.tsx", moduleAbsPath: indexPath },
|
|
93
93
|
{ key: "./admin.tsx", moduleAbsPath: adminPath },
|
|
94
94
|
{ key: "./typed.tsx", moduleAbsPath: typedPath },
|
|
@@ -123,7 +123,7 @@ describe("PagesBundleBuilder", () => {
|
|
|
123
123
|
outdir,
|
|
124
124
|
target: "bun",
|
|
125
125
|
format: "esm",
|
|
126
|
-
plugins: [PagesBundleBuilder.
|
|
126
|
+
plugins: [PagesBundleBuilder.createCssStubPlugin()],
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
expect(result.success).toBe(true);
|
|
@@ -178,15 +178,96 @@ describe("CsrArtifactBuilder", () => {
|
|
|
178
178
|
expect(inlined).not.toContain("src=");
|
|
179
179
|
});
|
|
180
180
|
|
|
181
|
-
test("creates inline stylesheet
|
|
182
|
-
const html =
|
|
183
|
-
'<head><link rel="stylesheet" href="/_akan/styles/akanjs.css" data-akan-css="active" /><link rel="stylesheet" href="./generated.css" /></head>';
|
|
184
|
-
const stripped = CsrArtifactBuilder.stripBundledStylesheetLinks(html);
|
|
181
|
+
test("creates inline stylesheet with the closing tag escaped", () => {
|
|
185
182
|
const style = CsrArtifactBuilder.createInlineStyle("body::before{content:'</style>';}");
|
|
186
183
|
|
|
187
|
-
expect(stripped).toBe("<head></head>");
|
|
188
184
|
expect(style).toBe("<style data-akan-css=\"active\">\nbody::before{content:'<\\/style>';}\n</style>");
|
|
189
185
|
});
|
|
186
|
+
|
|
187
|
+
test("injects into the real head even when an inline bundle quotes body and head tags", () => {
|
|
188
|
+
const bundle = 'console.info("<body>", "</head>", "<!--");';
|
|
189
|
+
const html = [
|
|
190
|
+
"<!doctype html>",
|
|
191
|
+
"<html>",
|
|
192
|
+
"<head>",
|
|
193
|
+
`<script type="module">${bundle}</script>`,
|
|
194
|
+
"</head>",
|
|
195
|
+
'<body><div id="root"></div></body>',
|
|
196
|
+
"</html>",
|
|
197
|
+
].join("\n");
|
|
198
|
+
|
|
199
|
+
const first = CsrArtifactBuilder.injectBeforeHeadEnd(html, "<style>a{}</style>");
|
|
200
|
+
const second = CsrArtifactBuilder.injectBeforeHeadEnd(first, "<style>b{}</style>");
|
|
201
|
+
|
|
202
|
+
expect(second.startsWith("<!doctype html>")).toBe(true);
|
|
203
|
+
expect(second.indexOf("<style>a{}</style>")).toBeGreaterThan(second.indexOf(bundle));
|
|
204
|
+
expect(second.indexOf("<style>b{}</style>")).toBeGreaterThan(second.indexOf("<style>a{}</style>"));
|
|
205
|
+
expect(second.indexOf("<style>b{}</style>")).toBeLessThan(second.indexOf("\n</head>"));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("never prepends: without a head the snippet lands before body, else at the end", () => {
|
|
209
|
+
expect(CsrArtifactBuilder.injectBeforeHeadEnd("<html><body></body></html>", "<style></style>")).toBe(
|
|
210
|
+
"<html><style></style>\n<body></body></html>",
|
|
211
|
+
);
|
|
212
|
+
expect(CsrArtifactBuilder.injectBeforeHeadEnd("<div></div>", "<style></style>")).toBe(
|
|
213
|
+
"<div></div>\n<style></style>",
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("gives each basePath its own routes plus the routes outside every basePath", () => {
|
|
218
|
+
const entries = [
|
|
219
|
+
{ key: "./_layout.tsx", moduleAbsPath: "/repo/page/_layout.tsx" },
|
|
220
|
+
{ key: "./(sign)/signin.tsx", moduleAbsPath: "/repo/page/(sign)/signin.tsx" },
|
|
221
|
+
{
|
|
222
|
+
key: "./office/__root_layout.tsx",
|
|
223
|
+
moduleAbsPath: "/repo/.akan/generated/root-layouts/office__root_layout.tsx",
|
|
224
|
+
},
|
|
225
|
+
{ key: "./office/_index.tsx", moduleAbsPath: "/repo/page/office/_index.tsx" },
|
|
226
|
+
{
|
|
227
|
+
key: "./(user)/soft/__root_layout.tsx",
|
|
228
|
+
moduleAbsPath: "/repo/.akan/generated/root-layouts/soft__root_layout.tsx",
|
|
229
|
+
},
|
|
230
|
+
{ key: "./(user)/soft/home.tsx", moduleAbsPath: "/repo/page/(user)/soft/home.tsx" },
|
|
231
|
+
];
|
|
232
|
+
const basePaths = ["office", "soft"];
|
|
233
|
+
|
|
234
|
+
expect(CsrArtifactBuilder.pageEntriesForBasePath(entries, "office", basePaths).map((entry) => entry.key)).toEqual([
|
|
235
|
+
"./_layout.tsx",
|
|
236
|
+
"./(sign)/signin.tsx",
|
|
237
|
+
"./office/__root_layout.tsx",
|
|
238
|
+
"./office/_index.tsx",
|
|
239
|
+
]);
|
|
240
|
+
expect(CsrArtifactBuilder.pageEntriesForBasePath(entries, "soft", basePaths).map((entry) => entry.key)).toEqual([
|
|
241
|
+
"./_layout.tsx",
|
|
242
|
+
"./(sign)/signin.tsx",
|
|
243
|
+
"./(user)/soft/__root_layout.tsx",
|
|
244
|
+
"./(user)/soft/home.tsx",
|
|
245
|
+
]);
|
|
246
|
+
expect(CsrArtifactBuilder.pageEntriesForBasePath(entries, "", [])).toEqual(entries);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("keeps route stylesheets out of the browser bundle so the HTML links no CSS", async () => {
|
|
250
|
+
const root = await makeTempRoot();
|
|
251
|
+
const htmlPath = path.join(root, "src/index.html");
|
|
252
|
+
await write(
|
|
253
|
+
htmlPath,
|
|
254
|
+
'<!doctype html><html><head></head><body><script type="module" src="./index.csr.tsx"></script></body></html>',
|
|
255
|
+
);
|
|
256
|
+
await write(path.join(root, "src/index.csr.tsx"), 'import "./styles.css";\nconsole.info("boot");\n');
|
|
257
|
+
await write(path.join(root, "src/styles.css"), ":root { --foreground: #fff; }\n");
|
|
258
|
+
|
|
259
|
+
const result = await Bun.build({
|
|
260
|
+
target: "browser",
|
|
261
|
+
entrypoints: [htmlPath],
|
|
262
|
+
root: path.join(root, "src"),
|
|
263
|
+
outdir: path.join(root, "out"),
|
|
264
|
+
plugins: [PagesBundleBuilder.createCssStubPlugin()],
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
expect(result.success).toBe(true);
|
|
268
|
+
expect(result.outputs.some((output) => output.path.endsWith(".css"))).toBe(false);
|
|
269
|
+
expect(await readFile(path.join(root, "out/index.html"), "utf8")).not.toContain("stylesheet");
|
|
270
|
+
});
|
|
190
271
|
});
|
|
191
272
|
|
|
192
273
|
describe("SsrBaseArtifactBuilder", () => {
|
|
@@ -391,6 +472,20 @@ describe("CssImportResolver", () => {
|
|
|
391
472
|
expect(await resolver.resolve("@libs/ui/missing", root)).toBeNull();
|
|
392
473
|
});
|
|
393
474
|
|
|
475
|
+
test("never substitutes the package stylesheet for a subpath that does not exist", async () => {
|
|
476
|
+
const root = await makeTempRoot();
|
|
477
|
+
await write(
|
|
478
|
+
path.join(root, "node_modules/vendor/package.json"),
|
|
479
|
+
JSON.stringify({ name: "vendor", style: "index.css" }),
|
|
480
|
+
);
|
|
481
|
+
await write(path.join(root, "node_modules/vendor/index.css"), ".vendor {}\n");
|
|
482
|
+
|
|
483
|
+
const resolver = new CssImportResolver(root, {});
|
|
484
|
+
|
|
485
|
+
expect(await resolver.resolve("vendor", root)).toBe(path.join(root, "node_modules/vendor/index.css"));
|
|
486
|
+
expect(await resolver.resolve("vendor/ui/tokens.css", root)).toBeNull();
|
|
487
|
+
});
|
|
488
|
+
|
|
394
489
|
test("resolves css from single-package Akan workspace subpaths", async () => {
|
|
395
490
|
const root = await makeTempRoot();
|
|
396
491
|
await write(path.join(root, "pkgs/akanjs/ui/styles.css"), "body {}\n");
|
|
@@ -428,4 +523,77 @@ describe("CssCompiler", () => {
|
|
|
428
523
|
|
|
429
524
|
expect(css).toContain(".text-fuchsia-500");
|
|
430
525
|
});
|
|
526
|
+
|
|
527
|
+
test("reads declarations that prove a stylesheet arrived, ignoring theme variables that may not", () => {
|
|
528
|
+
expect(declaredCustomProperties(":root { --kakao: #fee500; --naver: #1ec800; }")).toEqual(["--kakao", "--naver"]);
|
|
529
|
+
expect(declaredCustomProperties("@theme inline {\n --color-brand: var(--brand);\n}\n")).toEqual([]);
|
|
530
|
+
expect(declaredCustomProperties("@theme { --color-x: initial; }\n:root { --brand: #111; }")).toEqual(["--brand"]);
|
|
531
|
+
expect(declaredCustomProperties(".a { color: var(--kakao); }")).toEqual([]);
|
|
532
|
+
expect(declaredCustomProperties(":root{--a:#111}\n@media (min-width:1px){:root{--a:#222;--b:#333}}")).toEqual([
|
|
533
|
+
"--a",
|
|
534
|
+
"--b",
|
|
535
|
+
]);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("fails loudly on a stylesheet import that resolves to nothing", async () => {
|
|
539
|
+
const root = await makeTempRoot();
|
|
540
|
+
const cssPath = path.join(root, "apps/demo/page/styles.css");
|
|
541
|
+
await write(cssPath, '@import "../../../libs/shared/ui/tokens.css";\n');
|
|
542
|
+
|
|
543
|
+
const compiler = new CssCompiler({
|
|
544
|
+
workspace: { workspaceRoot: root },
|
|
545
|
+
cwdPath: path.join(root, "apps/demo"),
|
|
546
|
+
getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
|
|
547
|
+
} as never);
|
|
548
|
+
|
|
549
|
+
await expect(compiler.compileCss([cssPath], [])).rejects.toThrow(
|
|
550
|
+
/failed to resolve stylesheet import "\.\.\/\.\.\/\.\.\/libs\/shared\/ui\/tokens\.css"/,
|
|
551
|
+
);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("reports every @import per base path so the written asset can be checked against it", async () => {
|
|
555
|
+
const root = await makeTempRoot();
|
|
556
|
+
const appDir = path.join(root, "apps/demo");
|
|
557
|
+
await write(path.join(appDir, "page/_index.tsx"), 'import "./styles.css";\nexport default () => null;\n');
|
|
558
|
+
await write(path.join(appDir, "page/styles.css"), '@import "../../../libs/shared/ui/brand.css";\n');
|
|
559
|
+
await write(path.join(root, "libs/shared/ui/brand.css"), ":root { --kakao: #fee500; --naver: #1ec800; }\n");
|
|
560
|
+
|
|
561
|
+
const compiler = new CssCompiler({
|
|
562
|
+
workspace: { workspaceRoot: root },
|
|
563
|
+
cwdPath: appDir,
|
|
564
|
+
getPageKeys: async () => ["./_index.tsx"],
|
|
565
|
+
getConfig: async () => ({ barrelImports: [], basePaths: [] }),
|
|
566
|
+
getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
|
|
567
|
+
} as never);
|
|
568
|
+
const cssByBasePath = await compiler.getCssByBasePath();
|
|
569
|
+
|
|
570
|
+
expect(cssByBasePath[""]).toContain("--kakao");
|
|
571
|
+
expect(compiler.importedStylesheetsByBasePath[""]).toEqual([
|
|
572
|
+
{ cssPath: path.join(root, "libs/shared/ui/brand.css"), declaredNames: ["--kakao", "--naver"] },
|
|
573
|
+
]);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("compiles lib-owned tokens ahead of the app stylesheets that may override them", async () => {
|
|
577
|
+
const root = await makeTempRoot();
|
|
578
|
+
const appDir = path.join(root, "apps/demo");
|
|
579
|
+
await write(
|
|
580
|
+
path.join(appDir, "page/_index.tsx"),
|
|
581
|
+
'import "./styles.css";\nimport { Card } from "@libs/shared/ui";\nexport default () => <Card />;\n',
|
|
582
|
+
);
|
|
583
|
+
await write(path.join(appDir, "page/styles.css"), ":root { --brand: #111111; }\n");
|
|
584
|
+
await write(path.join(root, "libs/shared/ui/index.ts"), "export const Card = () => null;\n");
|
|
585
|
+
await write(path.join(root, "libs/shared/ui/tokens.css"), ":root { --kakao: #fee500; }\n");
|
|
586
|
+
await write(path.join(root, "libs/unused/ui/tokens.css"), ":root { --unused: #000000; }\n");
|
|
587
|
+
|
|
588
|
+
const compiler = new CssCompiler({
|
|
589
|
+
workspace: { workspaceRoot: root },
|
|
590
|
+
cwdPath: appDir,
|
|
591
|
+
getPageKeys: async () => ["./_index.tsx"],
|
|
592
|
+
getConfig: async () => ({ barrelImports: [] }),
|
|
593
|
+
getTsConfig: async () => ({ compilerOptions: { paths: { "@libs/*": ["./libs/*"] } } }),
|
|
594
|
+
} as never);
|
|
595
|
+
const { cssPaths } = await compiler.discoverCssAndSources();
|
|
596
|
+
|
|
597
|
+
expect(cssPaths).toEqual([path.join(root, "libs/shared/ui/tokens.css"), path.join(appDir, "page/styles.css")]);
|
|
598
|
+
});
|
|
431
599
|
});
|
|
@@ -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
|
}
|
package/frontendBuild/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export * from "./cssImportResolver";
|
|
|
9
9
|
export * from "./devChangePlanner";
|
|
10
10
|
export * from "./devGeneratedIndexSync";
|
|
11
11
|
export * from "./fontOptimizer";
|
|
12
|
+
export * from "./fontPruner";
|
|
12
13
|
export * from "./hmrChangeClassifier";
|
|
13
14
|
export * from "./hmrWatcher";
|
|
14
15
|
export * from "./pagesBundleBuilder";
|
|
@@ -19,7 +20,6 @@ export * from "./routesManifestArtifactSerializer";
|
|
|
19
20
|
export * from "./sourceMtimeIndex";
|
|
20
21
|
export * from "./ssrBaseArtifactBuilder";
|
|
21
22
|
export * from "./styleContract";
|
|
22
|
-
export * from "./styleGuard";
|
|
23
23
|
export * from "./themeValidator";
|
|
24
24
|
export * from "./vendorSpecifiers";
|
|
25
25
|
export * from "./watchRootResolver";
|
|
@@ -64,7 +64,7 @@ export class PagesBundleBuilder {
|
|
|
64
64
|
define: this.#define(),
|
|
65
65
|
plugins: [
|
|
66
66
|
PagesBundleBuilder.createPagesEntryPlugin(entrySource),
|
|
67
|
-
PagesBundleBuilder.
|
|
67
|
+
PagesBundleBuilder.createCssStubPlugin(),
|
|
68
68
|
PagesBundleBuilder.createServerUseClientFetchPlugin(),
|
|
69
69
|
await createExternalizeFrameworkPlugin({ app: this.#app, extra: akanConfig.externalLibs }),
|
|
70
70
|
akanConfig.barrelImports.length > 0
|
|
@@ -137,9 +137,9 @@ export class PagesBundleBuilder {
|
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
static
|
|
140
|
+
static createCssStubPlugin(): BunPlugin {
|
|
141
141
|
return {
|
|
142
|
-
name: "akan-
|
|
142
|
+
name: "akan-css-stub",
|
|
143
143
|
setup(build) {
|
|
144
144
|
build.onLoad({ filter: /\.css$/ }, () => ({
|
|
145
145
|
contents: "",
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
1
|
import path from "node:path";
|
|
3
|
-
import ts from "typescript";
|
|
4
2
|
import type { PageEntry } from "../artifact/implicitRootLayout";
|
|
3
|
+
import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
|
|
5
4
|
|
|
6
5
|
export class PagesEntrySourceGenerator {
|
|
7
6
|
#pageEntries: PageEntry[];
|
|
@@ -22,101 +21,25 @@ export class PagesEntrySourceGenerator {
|
|
|
22
21
|
return `export const pages = {\n${lines.join("\n")}\n};\n`;
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
static generateStatic(pageEntries: PageEntry[]): string {
|
|
26
|
-
return new PagesEntrySourceGenerator(pageEntries).generateStatic();
|
|
24
|
+
static async generateStatic(pageEntries: PageEntry[]): Promise<string> {
|
|
25
|
+
return await new PagesEntrySourceGenerator(pageEntries).generateStatic();
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
generateStatic(): string {
|
|
28
|
+
async generateStatic(): Promise<string> {
|
|
30
29
|
const imports = this.#pageEntries.map(({ moduleAbsPath }, index) => {
|
|
31
30
|
const specifier = PagesEntrySourceGenerator.#toImportSpecifier(moduleAbsPath);
|
|
32
31
|
return `import * as page${index} from ${JSON.stringify(specifier)};`;
|
|
33
32
|
});
|
|
34
|
-
const entries =
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
const entries = await Promise.all(
|
|
34
|
+
this.#pageEntries.map(async ({ key, moduleAbsPath }, index) => {
|
|
35
|
+
const isAsyncDefault = await AsyncDefaultExportDetector.detect(moduleAbsPath);
|
|
36
|
+
return ` ${JSON.stringify(key)}: { loader: async () => page${index}, isAsyncDefault: ${isAsyncDefault} },`;
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
38
39
|
return `${imports.join("\n")}\nexport const pages = {\n${entries.join("\n")}\n};\n`;
|
|
39
40
|
}
|
|
41
|
+
|
|
40
42
|
static #toImportSpecifier(moduleAbsPath: string): string {
|
|
41
43
|
return path.resolve(moduleAbsPath).split(path.sep).join("/");
|
|
42
44
|
}
|
|
43
|
-
|
|
44
|
-
static #hasAsyncDefaultExport(moduleAbsPath: string): boolean {
|
|
45
|
-
try {
|
|
46
|
-
const source = fs.readFileSync(path.resolve(moduleAbsPath), "utf8");
|
|
47
|
-
const sourceFile = ts.createSourceFile(
|
|
48
|
-
moduleAbsPath,
|
|
49
|
-
source,
|
|
50
|
-
ts.ScriptTarget.Latest,
|
|
51
|
-
true,
|
|
52
|
-
PagesEntrySourceGenerator.#scriptKind(moduleAbsPath),
|
|
53
|
-
);
|
|
54
|
-
return PagesEntrySourceGenerator.#sourceFileHasAsyncDefaultExport(sourceFile);
|
|
55
|
-
} catch {
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
static #sourceFileHasAsyncDefaultExport(sourceFile: ts.SourceFile): boolean {
|
|
61
|
-
const asyncBindings = new Map<string, boolean>();
|
|
62
|
-
let defaultIdentifier: string | null = null;
|
|
63
|
-
|
|
64
|
-
for (const statement of sourceFile.statements) {
|
|
65
|
-
if (ts.isFunctionDeclaration(statement)) {
|
|
66
|
-
if (PagesEntrySourceGenerator.#hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) {
|
|
67
|
-
return PagesEntrySourceGenerator.#hasModifier(statement, ts.SyntaxKind.AsyncKeyword);
|
|
68
|
-
}
|
|
69
|
-
if (statement.name) {
|
|
70
|
-
asyncBindings.set(
|
|
71
|
-
statement.name.text,
|
|
72
|
-
PagesEntrySourceGenerator.#hasModifier(statement, ts.SyntaxKind.AsyncKeyword),
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (ts.isVariableStatement(statement)) {
|
|
79
|
-
for (const declaration of statement.declarationList.declarations) {
|
|
80
|
-
if (!ts.isIdentifier(declaration.name)) continue;
|
|
81
|
-
asyncBindings.set(
|
|
82
|
-
declaration.name.text,
|
|
83
|
-
PagesEntrySourceGenerator.#isAsyncFunctionExpression(declaration.initializer),
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (ts.isExportAssignment(statement)) {
|
|
90
|
-
if (PagesEntrySourceGenerator.#isAsyncFunctionExpression(statement.expression)) return true;
|
|
91
|
-
if (ts.isIdentifier(statement.expression)) defaultIdentifier = statement.expression.text;
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
96
|
-
const exportClause = statement.exportClause;
|
|
97
|
-
for (const specifier of exportClause.elements) {
|
|
98
|
-
if (specifier.name.text !== "default") continue;
|
|
99
|
-
defaultIdentifier = specifier.propertyName?.text ?? specifier.name.text;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return defaultIdentifier ? asyncBindings.get(defaultIdentifier) === true : false;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
static #hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean {
|
|
108
|
-
return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) ?? false);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
static #isAsyncFunctionExpression(node?: ts.Expression): boolean {
|
|
112
|
-
return Boolean(
|
|
113
|
-
node &&
|
|
114
|
-
(ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
|
|
115
|
-
PagesEntrySourceGenerator.#hasModifier(node, ts.SyntaxKind.AsyncKeyword),
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
static #scriptKind(moduleAbsPath: string): ts.ScriptKind {
|
|
120
|
-
return moduleAbsPath.endsWith(".tsx") || moduleAbsPath.endsWith(".jsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
121
|
-
}
|
|
122
45
|
}
|
|
@@ -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 {
|