@akanjs/devkit 3.0.0-alpha.1 → 3.0.0-alpha.11
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/agentsIndex.ts +26 -0
- package/akanContext.ts +43 -31
- package/applicationBuildRunner.ts +1 -1
- package/biome.base.json +313 -0
- package/biomeBase.ts +9 -0
- package/capacitorApp.test.ts +8 -0
- package/capacitorApp.ts +112 -18
- package/executors.test.ts +3 -1
- package/executors.ts +2 -1
- package/frontendBuild/allRoutesBuilder.ts +27 -8
- package/frontendBuild/cssCandidateCache.test.ts +38 -0
- package/frontendBuild/cssCandidateCache.ts +15 -3
- package/frontendBuild/cssCompiler.ts +60 -2
- package/frontendBuild/cssImportResolver.ts +8 -7
- package/frontendBuild/frontendBuild.test.ts +54 -0
- package/frontendBuild/precompressArtifacts.ts +35 -7
- package/frontendBuild/routeClientBuilder.ts +14 -2
- package/lint/no-bang-comment-in-client.grit +20 -0
- package/lint/no-import-client-in-server.grit +48 -0
- package/lint/no-import-server-in-client.grit +45 -0
- package/lint/no-return-in-store-action.grit +35 -0
- package/linter.ts +17 -12
- package/mcpScanner.ts +217 -0
- package/package.json +3 -2
- package/qualityScanner.test.ts +192 -0
- package/qualityScanner.ts +16 -45
- package/repoIdentity.ts +42 -0
- package/routeSourceValidator.test.ts +41 -0
- package/routeSourceValidator.ts +2 -44
- package/scanInfo.ts +2 -43
- package/storeScanner.ts +173 -0
- package/workspaceLayout.test.ts +26 -0
- package/workspaceLayout.ts +60 -0
package/capacitorApp.ts
CHANGED
|
@@ -24,7 +24,9 @@ interface RunIosConfig extends RunConfig {
|
|
|
24
24
|
iosDeviceId?: string;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
interface PrepareConfig extends RunConfig {
|
|
27
|
+
interface PrepareConfig extends RunConfig {
|
|
28
|
+
iosRunTargetKind?: IosRunTargetKind;
|
|
29
|
+
}
|
|
28
30
|
|
|
29
31
|
type MobileCommandEnv = Record<string, string | undefined>;
|
|
30
32
|
|
|
@@ -119,10 +121,11 @@ interface MaterializeCapacitorConfigOptions {
|
|
|
119
121
|
localServerUrl?: string;
|
|
120
122
|
localIp?: string;
|
|
121
123
|
}
|
|
124
|
+
type MobilePlatform = "ios" | "android";
|
|
122
125
|
|
|
123
126
|
export interface LocalDevHostResolution {
|
|
124
127
|
host: string;
|
|
125
|
-
source: "override" | "detected" | "loopback";
|
|
128
|
+
source: "override" | "detected" | "loopback" | "platform";
|
|
126
129
|
candidates: { name: string; address: string }[];
|
|
127
130
|
}
|
|
128
131
|
|
|
@@ -565,6 +568,16 @@ export function getAdbDeviceStateIssues(output: string) {
|
|
|
565
568
|
});
|
|
566
569
|
}
|
|
567
570
|
|
|
571
|
+
export function getAndroidLocalServerHost(adbDevicesOutput: string | undefined, fallbackHost: string) {
|
|
572
|
+
const onlineDeviceIds =
|
|
573
|
+
adbDevicesOutput
|
|
574
|
+
?.split(/\r?\n/)
|
|
575
|
+
.map((line) => line.trim().split(/\s+/))
|
|
576
|
+
.filter(([id, state]) => id && state === "device")
|
|
577
|
+
.map(([id]) => id) ?? [];
|
|
578
|
+
return onlineDeviceIds.length === 1 && onlineDeviceIds[0]?.startsWith("emulator-") ? "10.0.2.2" : fallbackHost;
|
|
579
|
+
}
|
|
580
|
+
|
|
568
581
|
export const ANDROID_MIN_SDK_VERSION = 26;
|
|
569
582
|
export function raiseGradleMinSdkVersion(content: string, floor: number = ANDROID_MIN_SDK_VERSION): string | null {
|
|
570
583
|
const match = content.match(/minSdkVersion\s*=\s*(\d+)/);
|
|
@@ -748,7 +761,7 @@ export class CapacitorApp {
|
|
|
748
761
|
async save() {
|
|
749
762
|
await this.project.commit();
|
|
750
763
|
}
|
|
751
|
-
async #prepareIos({ operation, env, regenerate = false }: PrepareConfig) {
|
|
764
|
+
async #prepareIos({ operation, env, regenerate = false, iosRunTargetKind }: PrepareConfig) {
|
|
752
765
|
await this.init({ platform: "ios", operation, env, regenerate });
|
|
753
766
|
await this.#prepareTargetAssets();
|
|
754
767
|
await this.#prepareExternalFiles("ios");
|
|
@@ -757,9 +770,10 @@ export class CapacitorApp {
|
|
|
757
770
|
await this.#applyDeepLinks("ios", { operation, env });
|
|
758
771
|
await this.#flushIosEntitlements();
|
|
759
772
|
await this.project.commit();
|
|
773
|
+
await this.#setCodeSignEntitlementsInIosIfExists("App/App.entitlements");
|
|
760
774
|
await this.#generateAssets({ operation, env });
|
|
761
775
|
this.app.verbose(`syncing iOS`);
|
|
762
|
-
await this.#spawnMobile("npx", ["cap", "sync", "ios"], { operation, env });
|
|
776
|
+
await this.#spawnMobile("npx", ["cap", "sync", "ios"], { operation, env }, { iosRunTargetKind });
|
|
763
777
|
this.app.verbose(`sync completed.`);
|
|
764
778
|
}
|
|
765
779
|
async buildIos({ env = "debug", regenerate = false }: { env?: RunConfig["env"]; regenerate?: boolean } = {}) {
|
|
@@ -777,14 +791,14 @@ export class CapacitorApp {
|
|
|
777
791
|
}
|
|
778
792
|
async runIos({ operation, env, regenerate = false, noAllowProvisioningUpdates = false, iosDeviceId }: RunIosConfig) {
|
|
779
793
|
if (operation === "release") await this.prepareWww();
|
|
780
|
-
await this.#prepareIos({ operation, env, regenerate });
|
|
781
794
|
const runTarget = await this.#selectIosRunTarget(iosDeviceId);
|
|
795
|
+
await this.#prepareIos({ operation, env, regenerate, iosRunTargetKind: runTarget.kind });
|
|
782
796
|
if (runTarget.kind === "simulator") {
|
|
783
797
|
await this.#spawnMobile(
|
|
784
798
|
"npx",
|
|
785
|
-
["cap", "run", "ios", "--target", runTarget.id],
|
|
799
|
+
["cap", "run", "ios", "--target", runTarget.id, "--no-sync"],
|
|
786
800
|
{ operation, env },
|
|
787
|
-
{ stdio: "inherit" },
|
|
801
|
+
{ stdio: "inherit", platform: "ios", iosRunTargetKind: runTarget.kind },
|
|
788
802
|
);
|
|
789
803
|
return;
|
|
790
804
|
}
|
|
@@ -878,7 +892,10 @@ export class CapacitorApp {
|
|
|
878
892
|
noAllowProvisioningUpdates: boolean;
|
|
879
893
|
}) {
|
|
880
894
|
const mobileEnv = sanitizeIosNativeRunEnv(await this.#commandEnv(operation, env));
|
|
881
|
-
const configContent = await this.#writeCapacitorConfig(
|
|
895
|
+
const configContent = await this.#writeCapacitorConfig(
|
|
896
|
+
{ operation, platform: "ios", iosRunTargetKind: runTarget.kind },
|
|
897
|
+
mobileEnv,
|
|
898
|
+
);
|
|
882
899
|
await this.#writeRootCapacitorConfig(configContent);
|
|
883
900
|
const scheme = this.#iosScheme();
|
|
884
901
|
const command = buildIosNativeRunCommand({
|
|
@@ -943,6 +960,7 @@ export class CapacitorApp {
|
|
|
943
960
|
await this.#applyAndroidMinSdkVersion();
|
|
944
961
|
await this.#applyPermissions({ operation, env });
|
|
945
962
|
await this.#applyDeepLinks("android", { operation, env });
|
|
963
|
+
await this.#disableNativeKeyboardResizeInAndroid();
|
|
946
964
|
await this.project.commit();
|
|
947
965
|
await this.#generateAssets({ operation, env });
|
|
948
966
|
await this.#ensureAndroidAssetsDir();
|
|
@@ -1010,6 +1028,24 @@ export class CapacitorApp {
|
|
|
1010
1028
|
async openAndroid() {
|
|
1011
1029
|
await this.#spawnMobile("npx", ["cap", "open", "android"], { operation: "local", env: "local" });
|
|
1012
1030
|
}
|
|
1031
|
+
async #disableNativeKeyboardResizeInAndroid() {
|
|
1032
|
+
const manifestPath = path.join(this.app.cwdPath, this.androidRootPath, "app/src/main/AndroidManifest.xml");
|
|
1033
|
+
let manifest = await readFile(manifestPath, "utf8");
|
|
1034
|
+
let changed = false;
|
|
1035
|
+
manifest = manifest.replace(/<activity\b[^>]*android:name="\.MainActivity"[^>]*>/, (activityTag) => {
|
|
1036
|
+
if (activityTag.includes("android:windowSoftInputMode=")) {
|
|
1037
|
+
const nextTag = activityTag.replace(
|
|
1038
|
+
/android:windowSoftInputMode="[^"]*"/,
|
|
1039
|
+
'android:windowSoftInputMode="adjustNothing"',
|
|
1040
|
+
);
|
|
1041
|
+
changed ||= nextTag !== activityTag;
|
|
1042
|
+
return nextTag;
|
|
1043
|
+
}
|
|
1044
|
+
changed = true;
|
|
1045
|
+
return activityTag.replace(/>$/, '\n android:windowSoftInputMode="adjustNothing">');
|
|
1046
|
+
});
|
|
1047
|
+
if (changed) await writeFile(manifestPath, manifest);
|
|
1048
|
+
}
|
|
1013
1049
|
async #ensureAndroidAssetsDir() {
|
|
1014
1050
|
await mkdir(path.join(this.app.cwdPath, this.androidAssetsPath), { recursive: true });
|
|
1015
1051
|
}
|
|
@@ -1100,12 +1136,19 @@ export class CapacitorApp {
|
|
|
1100
1136
|
if (html.includes("window.__AKAN_MOBILE_TARGET__")) return html;
|
|
1101
1137
|
return html.replace(/<\/head\s*>/i, `${script}\n</head>`);
|
|
1102
1138
|
}
|
|
1103
|
-
async #writeCapacitorConfig(
|
|
1139
|
+
async #writeCapacitorConfig(
|
|
1140
|
+
{
|
|
1141
|
+
operation,
|
|
1142
|
+
platform,
|
|
1143
|
+
iosRunTargetKind,
|
|
1144
|
+
}: Pick<RunConfig, "operation"> & { platform?: MobilePlatform; iosRunTargetKind?: IosRunTargetKind },
|
|
1145
|
+
commandEnv: MobileCommandEnv,
|
|
1146
|
+
) {
|
|
1104
1147
|
await mkdir(this.targetRoot, { recursive: true });
|
|
1105
1148
|
let localIp: string | undefined;
|
|
1106
1149
|
if (operation === "local") {
|
|
1107
1150
|
const override = commandEnv.AKAN_PUBLIC_CLIENT_HOST ?? process.env.AKAN_PUBLIC_CLIENT_HOST;
|
|
1108
|
-
const resolution =
|
|
1151
|
+
const resolution = await this.#resolveLocalDevHost({ override, platform, iosRunTargetKind });
|
|
1109
1152
|
localIp = resolution.host;
|
|
1110
1153
|
this.#logDevHostResolution(resolution, commandEnv);
|
|
1111
1154
|
}
|
|
@@ -1118,11 +1161,36 @@ export class CapacitorApp {
|
|
|
1118
1161
|
await Bun.write(path.join(this.targetRoot, "capacitor.config.json"), content);
|
|
1119
1162
|
return content;
|
|
1120
1163
|
}
|
|
1164
|
+
// An explicit override always wins; an emulator/simulator run reaches the host machine through a
|
|
1165
|
+
// loopback alias (10.0.2.2 / localhost), so LAN detection only applies to physical-device runs.
|
|
1166
|
+
async #resolveLocalDevHost({
|
|
1167
|
+
override,
|
|
1168
|
+
platform,
|
|
1169
|
+
iosRunTargetKind,
|
|
1170
|
+
}: {
|
|
1171
|
+
override?: string;
|
|
1172
|
+
platform?: MobilePlatform;
|
|
1173
|
+
iosRunTargetKind?: IosRunTargetKind;
|
|
1174
|
+
}): Promise<LocalDevHostResolution> {
|
|
1175
|
+
const resolution = selectLocalDevHost(os.networkInterfaces(), { override });
|
|
1176
|
+
if (resolution.source === "override") return resolution;
|
|
1177
|
+
if (platform === "ios" && iosRunTargetKind === "simulator")
|
|
1178
|
+
return { ...resolution, host: "localhost", source: "platform" };
|
|
1179
|
+
if (platform === "android") {
|
|
1180
|
+
try {
|
|
1181
|
+
const host = getAndroidLocalServerHost(await this.#spawn("adb", ["devices"]), resolution.host);
|
|
1182
|
+
return host === resolution.host ? resolution : { ...resolution, host, source: "platform" };
|
|
1183
|
+
} catch {
|
|
1184
|
+
return resolution;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return resolution;
|
|
1188
|
+
}
|
|
1121
1189
|
// Surface the live-reload URL a physical device must reach, and warn when auto-detection landed on
|
|
1122
1190
|
// a likely-unreachable host so a blank WebView is not mistaken for an app bug.
|
|
1123
1191
|
#logDevHostResolution(resolution: LocalDevHostResolution, commandEnv: MobileCommandEnv) {
|
|
1124
1192
|
this.app.log(`Mobile live-reload server: ${this.#localCsrUrl(resolution.host, commandEnv)}`);
|
|
1125
|
-
if (resolution.source === "override") return;
|
|
1193
|
+
if (resolution.source === "override" || resolution.source === "platform") return;
|
|
1126
1194
|
const suspicious = resolution.host === "127.0.0.1" || resolution.host.startsWith("169.254.");
|
|
1127
1195
|
const alternatives = resolution.candidates.filter((candidate) => candidate.address !== resolution.host);
|
|
1128
1196
|
if (!suspicious && alternatives.length === 0) return;
|
|
@@ -1332,20 +1400,29 @@ export class CapacitorApp {
|
|
|
1332
1400
|
command: string,
|
|
1333
1401
|
args: string[] = [],
|
|
1334
1402
|
{ operation, env }: Pick<RunConfig, "operation" | "env">,
|
|
1335
|
-
options:
|
|
1403
|
+
options: SpawnMobileOptions = {},
|
|
1336
1404
|
) {
|
|
1405
|
+
const { iosRunTargetKind, platform, ...spawnOptions } = options;
|
|
1337
1406
|
const mobileEnv = { ...(await this.#commandEnv(operation, env)), ...options.env };
|
|
1338
|
-
const configContent = await this.#writeCapacitorConfig(
|
|
1407
|
+
const configContent = await this.#writeCapacitorConfig(
|
|
1408
|
+
{ operation, platform: platform ?? this.#inferMobilePlatform(args), iosRunTargetKind },
|
|
1409
|
+
mobileEnv,
|
|
1410
|
+
);
|
|
1339
1411
|
await this.#writeRootCapacitorConfig(configContent);
|
|
1340
1412
|
try {
|
|
1341
1413
|
return await this.#spawn(command, args, {
|
|
1342
|
-
...
|
|
1414
|
+
...spawnOptions,
|
|
1343
1415
|
env: mobileEnv,
|
|
1344
1416
|
});
|
|
1345
1417
|
} finally {
|
|
1346
1418
|
await this.#clearRootCapacitorConfigs();
|
|
1347
1419
|
}
|
|
1348
1420
|
}
|
|
1421
|
+
#inferMobilePlatform(args: string[]): MobilePlatform | undefined {
|
|
1422
|
+
if (args.includes("android")) return "android";
|
|
1423
|
+
if (args.includes("ios")) return "ios";
|
|
1424
|
+
return undefined;
|
|
1425
|
+
}
|
|
1349
1426
|
async addCamera() {
|
|
1350
1427
|
await this.#setPermissionInIos({
|
|
1351
1428
|
cameraUsageDescription: "$(PRODUCT_NAME) requires access to the camera to take photos.",
|
|
@@ -1446,14 +1523,31 @@ export class CapacitorApp {
|
|
|
1446
1523
|
let end = index;
|
|
1447
1524
|
while (end < lines.length && !/^\s*\};\s*$/.test(lines[end] ?? "")) end++;
|
|
1448
1525
|
const settings = lines.slice(start, end + 1);
|
|
1449
|
-
|
|
1526
|
+
const configName = lines.slice(end + 1, end + 8).find((setting) => setting.includes("name = ")) ?? "";
|
|
1450
1527
|
const indent = line.match(/^\s*/)?.[0] ?? "";
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1528
|
+
const insertSettings = [];
|
|
1529
|
+
if (!settings.some((setting) => setting.includes("CODE_SIGN_ENTITLEMENTS"))) {
|
|
1530
|
+
insertSettings.push(`${indent}CODE_SIGN_ENTITLEMENTS = ${entitlementsRelPath};`);
|
|
1531
|
+
}
|
|
1532
|
+
if (
|
|
1533
|
+
configName.includes("name = Debug;") &&
|
|
1534
|
+
!settings.some((setting) => setting.includes("CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION"))
|
|
1535
|
+
) {
|
|
1536
|
+
insertSettings.push(`${indent}CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;`);
|
|
1537
|
+
}
|
|
1538
|
+
if (insertSettings.length > 0) {
|
|
1539
|
+
lines.splice(index, 0, ...insertSettings);
|
|
1540
|
+
index += insertSettings.length;
|
|
1541
|
+
changed = true;
|
|
1542
|
+
}
|
|
1454
1543
|
}
|
|
1455
1544
|
if (changed) await writeFile(pbxprojPath, lines.join("\n"));
|
|
1456
1545
|
}
|
|
1546
|
+
async #setCodeSignEntitlementsInIosIfExists(entitlementsRelPath: string) {
|
|
1547
|
+
const entitlementsPath = path.join(this.app.cwdPath, this.iosProjectPath, entitlementsRelPath);
|
|
1548
|
+
if (!(await Bun.file(entitlementsPath).exists())) return;
|
|
1549
|
+
await this.#setCodeSignEntitlementsInIos(entitlementsRelPath);
|
|
1550
|
+
}
|
|
1457
1551
|
async #setUrlSchemesInAndroid(schemes: string[]) {
|
|
1458
1552
|
const manifestPath = path.join(this.app.cwdPath, this.androidRootPath, "app/src/main/AndroidManifest.xml");
|
|
1459
1553
|
let manifest = await readFile(manifestPath, "utf8");
|
package/executors.test.ts
CHANGED
|
@@ -149,8 +149,10 @@ describe("Executor filesystem helpers", () => {
|
|
|
149
149
|
"AI Development Guide",
|
|
150
150
|
);
|
|
151
151
|
expect(await readFile(path.join(root, "workspace/docs/GENERATED.md"), "utf8")).toContain("Generated Akan Files");
|
|
152
|
+
// Rules and their plugin registrations live in the package's base config, so a framework release reaches an
|
|
153
|
+
// existing workspace on `bun update` — the workspace file only extends it and scopes its own files.
|
|
152
154
|
expect(await readFile(path.join(root, "workspace/biome.json"), "utf8")).toContain(
|
|
153
|
-
"
|
|
155
|
+
'"extends": ["@akanjs/devkit/biome.base.json"]',
|
|
154
156
|
);
|
|
155
157
|
});
|
|
156
158
|
|
package/executors.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from
|
|
|
42
42
|
import { FileSys } from "./fileSys";
|
|
43
43
|
import { getDirname } from "./getDirname";
|
|
44
44
|
import { Linter } from "./linter";
|
|
45
|
+
import { resolveRepoName } from "./repoIdentity";
|
|
45
46
|
import { AppInfo, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
|
|
46
47
|
import { Spinner } from "./spinner";
|
|
47
48
|
// Type-only: the implementation is loaded on demand in `getTypeChecker` to keep `typescript` out of
|
|
@@ -788,7 +789,7 @@ export class WorkspaceExecutor extends Executor {
|
|
|
788
789
|
static #execs = new Map<string, WorkspaceExecutor>();
|
|
789
790
|
static fromRoot({
|
|
790
791
|
workspaceRoot = process.cwd(),
|
|
791
|
-
repoName =
|
|
792
|
+
repoName = resolveRepoName(workspaceRoot),
|
|
792
793
|
}: {
|
|
793
794
|
workspaceRoot?: string;
|
|
794
795
|
repoName?: string;
|
|
@@ -47,11 +47,24 @@ export class AllRoutesBuilder {
|
|
|
47
47
|
this.#app.verbose(`[build-all] discovered ${seedIndex.entries.length} routes`);
|
|
48
48
|
this.#discovery = await GraphClientEntryDiscovery.create(this.#app);
|
|
49
49
|
|
|
50
|
+
// Discovery first, bundling second. Chunk splitting only dedupes within one `Bun.build`, so a
|
|
51
|
+
// dependency shared by entries from different routes was emitted once per route that reached it.
|
|
52
|
+
// Discovery is cached and does no bundling, so collecting every entry up front costs almost nothing.
|
|
53
|
+
const allEntries: string[] = [];
|
|
54
|
+
const seen = new Set<string>();
|
|
50
55
|
for (const entry of seedIndex.entries) {
|
|
51
56
|
const seeds = Array.from(new Set([...seedIndex.globalLayoutFiles, ...entry.seeds]));
|
|
52
|
-
const
|
|
53
|
-
|
|
57
|
+
for (const discovered of await this.#discovery.discover(seeds)) {
|
|
58
|
+
if (seen.has(discovered)) continue;
|
|
59
|
+
seen.add(discovered);
|
|
60
|
+
allEntries.push(discovered);
|
|
61
|
+
}
|
|
62
|
+
this.#routeIds.push(entry.routeId);
|
|
54
63
|
}
|
|
64
|
+
this.#app.verbose(`[build-all] ${allEntries.length} client entries across ${this.#routeIds.length} routes`);
|
|
65
|
+
|
|
66
|
+
const delta = await this.#buildEntries(allEntries);
|
|
67
|
+
this.#mergeDelta(delta);
|
|
55
68
|
this.#merged.knownEntries = Array.from(this.#knownSet);
|
|
56
69
|
|
|
57
70
|
const manifest: RoutesManifest = {
|
|
@@ -76,28 +89,34 @@ export class AllRoutesBuilder {
|
|
|
76
89
|
return { manifest, manifestPath, seedIndex };
|
|
77
90
|
}
|
|
78
91
|
|
|
79
|
-
async #
|
|
92
|
+
async #buildEntries(entries: string[]): Promise<BuildRouteClientResult> {
|
|
80
93
|
if (!this.#discovery) throw new Error("[build-all] client entry discovery is not initialized");
|
|
94
|
+
if (entries.length === 0)
|
|
95
|
+
return {
|
|
96
|
+
manifestDelta: {},
|
|
97
|
+
ssrManifestDelta: { moduleLoading: null, moduleMap: {} },
|
|
98
|
+
newEntries: [],
|
|
99
|
+
clientDeps: [],
|
|
100
|
+
};
|
|
81
101
|
const started = Date.now();
|
|
82
102
|
const delta = await new RouteClientBuilder({
|
|
83
103
|
app: this.#app,
|
|
84
|
-
|
|
85
|
-
|
|
104
|
+
seeds: [],
|
|
105
|
+
entries,
|
|
86
106
|
artifact: this.#artifact,
|
|
87
107
|
knownEntries: this.#knownSet,
|
|
88
108
|
discovery: this.#discovery,
|
|
89
109
|
command: this.#command,
|
|
90
110
|
}).build();
|
|
91
|
-
this.#app.verbose(`[build-all] ${
|
|
111
|
+
this.#app.verbose(`[build-all] bundled ${delta.newEntries.length} entries (${Date.now() - started}ms)`);
|
|
92
112
|
return delta;
|
|
93
113
|
}
|
|
94
114
|
|
|
95
|
-
#
|
|
115
|
+
#mergeDelta(delta: BuildRouteClientResult): void {
|
|
96
116
|
for (const [key, row] of Object.entries(delta.manifestDelta)) this.#merged.clientManifest[key] = row;
|
|
97
117
|
for (const [url, byName] of Object.entries(delta.ssrManifestDelta.moduleMap)) {
|
|
98
118
|
this.#merged.ssrManifest.moduleMap[url] = byName;
|
|
99
119
|
}
|
|
100
120
|
for (const abs of delta.newEntries) this.#knownSet.add(abs);
|
|
101
|
-
this.#routeIds.push(routeId);
|
|
102
121
|
}
|
|
103
122
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { CssCandidateCache } from "./cssCandidateCache";
|
|
6
|
+
|
|
7
|
+
const scan = async (source: string) => {
|
|
8
|
+
const dir = await mkdtemp(path.join(tmpdir(), "css-candidate-"));
|
|
9
|
+
const file = path.join(dir, "probe.tsx");
|
|
10
|
+
await writeFile(file, source);
|
|
11
|
+
return await new CssCandidateCache(path.join(dir, "cache.json")).load().then((c) => c.candidatesFor(file));
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("CssCandidateCache token extraction", () => {
|
|
15
|
+
test("keeps plain utilities, variants and arbitrary values whole", async () => {
|
|
16
|
+
const candidates = await scan(`const c = "w-full hover:bg-muted md:w-1/2 text-[#fff]";`);
|
|
17
|
+
expect(candidates).toEqual(expect.arrayContaining(["w-full", "hover:bg-muted", "md:w-1/2", "text-[#fff]"]));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// A candidate that opens with `[` used to be torn into `_td` + `px-3`, so every descendant-variant
|
|
21
|
+
// rule in the framework compiled to nothing and its element rendered unstyled with no diagnostic.
|
|
22
|
+
test("keeps an arbitrary variant that starts with a bracket", async () => {
|
|
23
|
+
const candidates = await scan(`const c = "[&_td]:px-3 [&>*]:gap-2 [&_tbody_tr]:border-t";`);
|
|
24
|
+
expect(candidates).toEqual(expect.arrayContaining(["[&_td]:px-3", "[&>*]:gap-2", "[&_tbody_tr]:border-t"]));
|
|
25
|
+
expect(candidates).not.toContain("_td");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// A labelled tuple type looks like an arbitrary property, and compiles to a rule whose declaration is
|
|
29
|
+
// TypeScript. `optimize()` cannot parse that, and drops far more than the offending rule on its way
|
|
30
|
+
// out — every variant in the build went missing, so nothing hovered, focused or responded to a
|
|
31
|
+
// breakpoint. Tailwind writes a space in an arbitrary value as `_`, so whitespace rules the token out.
|
|
32
|
+
test("ignores a labelled tuple type that only looks like an arbitrary property", async () => {
|
|
33
|
+
const candidates = await scan(
|
|
34
|
+
`type A = [key: string, make: () => unknown][];\ntype B = [setQueryArgs: (a: Args) => Args, options?: Policy];`,
|
|
35
|
+
);
|
|
36
|
+
expect(candidates.filter((candidate) => candidate.startsWith("["))).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { stat } from "node:fs/promises";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Every identifier-ish token in a source file is a potential tailwind class. The leading alternation
|
|
5
|
+
* matters: an arbitrary variant starts with `[` (`[&_td]:px-3`, `[&>*]:gap-2`), and a pattern anchored
|
|
6
|
+
* on a word character tears it into `_td` plus `px-3` instead — both meaningless, so the rule compiles
|
|
7
|
+
* to nothing and the element silently renders unstyled.
|
|
8
|
+
*
|
|
9
|
+
* Brackets must hold no whitespace. Tailwind spells a space in an arbitrary value `_`, so a bracket
|
|
10
|
+
* group containing one is never a class — but it is exactly the shape of a labelled tuple type
|
|
11
|
+
* (`[key: string, make: () => unknown]`). Scanned as a candidate, that compiles to an arbitrary
|
|
12
|
+
* *property* rule carrying TypeScript as its declaration, which the lightningcss pass in `optimize()`
|
|
13
|
+
* cannot parse; its error recovery then drops a whole span of the stylesheet — in practice every
|
|
14
|
+
* variant rule in the build, leaving hover, focus, responsive and dark styles silently absent.
|
|
15
|
+
*/
|
|
16
|
+
const CANDIDATE_RE = /-?(?:[\w@]|\[[^\]\s]+\])[\w:/.-]*(?:\[[^\]\s]+\][\w:/.-]*)*/g;
|
|
5
17
|
|
|
6
18
|
interface CachedFile {
|
|
7
19
|
mtimeMs: number;
|
|
@@ -27,7 +39,7 @@ interface CacheFile {
|
|
|
27
39
|
*/
|
|
28
40
|
export class CssCandidateCache {
|
|
29
41
|
/** Bump when the token regex or the entry shape changes, so stale extractions are not reused. */
|
|
30
|
-
static readonly #version =
|
|
42
|
+
static readonly #version = 3;
|
|
31
43
|
readonly #path: string;
|
|
32
44
|
readonly #entries = new Map<string, CachedFile>();
|
|
33
45
|
#dirty = false;
|
|
@@ -46,6 +46,8 @@ export class CssCompiler {
|
|
|
46
46
|
#fileExistsCache = new Map<string, Promise<boolean>>();
|
|
47
47
|
#resolvedFileCache = new Map<string, Promise<string | null>>();
|
|
48
48
|
#resolvedSpecifierCache = new Map<string, Promise<string | null>>();
|
|
49
|
+
/** Every stylesheet this compile reached, entry points and `@import` targets alike. */
|
|
50
|
+
#discoveredCssPaths = new Set<string>();
|
|
49
51
|
|
|
50
52
|
#fileExists(absPath: string): Promise<boolean> {
|
|
51
53
|
let cached = this.#fileExistsCache.get(absPath);
|
|
@@ -76,13 +78,16 @@ export class CssCompiler {
|
|
|
76
78
|
}
|
|
77
79
|
async getCss({ refresh }: { refresh?: boolean } = {}) {
|
|
78
80
|
if (this.#cssText !== null && !refresh) return this.#cssText;
|
|
81
|
+
this.#discoveredCssPaths.clear();
|
|
79
82
|
const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh });
|
|
80
83
|
this.#cssText = await this.compileCss(cssPaths, sourcePaths);
|
|
84
|
+
await this.#warnUnreachableStylesheets();
|
|
81
85
|
return this.#cssText;
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
async getCssByBasePath({ refresh }: { refresh?: boolean } = {}): Promise<Record<string, string>> {
|
|
85
89
|
if (this.#cssTextByBasePath !== null && !refresh) return this.#cssTextByBasePath;
|
|
90
|
+
this.#discoveredCssPaths.clear();
|
|
86
91
|
const akanConfig = await this.#app.getConfig({ refresh });
|
|
87
92
|
const pageKeys = await this.#app.getPageKeys({ refresh });
|
|
88
93
|
const basePaths = [...akanConfig.basePaths];
|
|
@@ -111,9 +116,26 @@ export class CssCompiler {
|
|
|
111
116
|
}),
|
|
112
117
|
]);
|
|
113
118
|
this.#cssTextByBasePath = Object.fromEntries(cssEntries);
|
|
119
|
+
await this.#warnUnreachableStylesheets();
|
|
114
120
|
return this.#cssTextByBasePath;
|
|
115
121
|
}
|
|
116
122
|
|
|
123
|
+
/**
|
|
124
|
+
* A stylesheet under `page/` reaches the build only by being imported from a route source. One that nothing
|
|
125
|
+
* imports compiles to nothing and reports success, which is indistinguishable from an empty theme — so say it
|
|
126
|
+
* out loud once per compile rather than leaving it to be noticed as unstyled elements in the browser.
|
|
127
|
+
*/
|
|
128
|
+
async #warnUnreachableStylesheets() {
|
|
129
|
+
const pageDir = path.join(this.#app.cwdPath, "page");
|
|
130
|
+
const glob = new Bun.Glob("**/*.css");
|
|
131
|
+
for await (const cssPath of glob.scan({ cwd: pageDir, absolute: true })) {
|
|
132
|
+
// `(libs)` is a link farm: the same file is discovered under its real path in `libs/`, never this one.
|
|
133
|
+
if (cssPath.includes(`${path.sep}(libs)${path.sep}`)) continue;
|
|
134
|
+
if (this.#discoveredCssPaths.has(cssPath)) continue;
|
|
135
|
+
this.#logger.warn(`css ${path.relative(this.#app.cwdPath, cssPath)} is imported by no route and never compiled`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
117
139
|
async discoverCss({ refresh }: { refresh?: boolean } = {}): Promise<string[]> {
|
|
118
140
|
const { cssPaths } = await this.discoverCssAndSources({ refresh });
|
|
119
141
|
return cssPaths;
|
|
@@ -180,7 +202,33 @@ export class CssCompiler {
|
|
|
180
202
|
}
|
|
181
203
|
}
|
|
182
204
|
|
|
183
|
-
|
|
205
|
+
const tokenPaths = await this.#libTokenStylesheets(sourceFiles);
|
|
206
|
+
const cssPaths = [...new Set([...tokenPaths, ...cssFiles])];
|
|
207
|
+
for (const cssPath of cssPaths) this.#discoveredCssPaths.add(cssPath);
|
|
208
|
+
return { cssPaths, sourcePaths: [...sourceFiles] };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* `libs/<lib>/ui/tokens.css` of every lib the page graph reached, so a lib can own the fixed colours its own
|
|
213
|
+
* components need instead of each consuming app re-declaring them. Ordered ahead of the app's stylesheets:
|
|
214
|
+
* the app is the last word on any variable both declare.
|
|
215
|
+
*/
|
|
216
|
+
async #libTokenStylesheets(sourceFiles: Set<string>): Promise<string[]> {
|
|
217
|
+
const libsRoot = path.join(this.#app.workspace.workspaceRoot, "libs");
|
|
218
|
+
const libNames = new Set<string>();
|
|
219
|
+
for (const filePath of sourceFiles) {
|
|
220
|
+
const relPath = path.relative(libsRoot, filePath);
|
|
221
|
+
if (relPath.startsWith("..") || path.isAbsolute(relPath)) continue;
|
|
222
|
+
const [libName] = relPath.split(path.sep);
|
|
223
|
+
if (libName) libNames.add(libName);
|
|
224
|
+
}
|
|
225
|
+
const tokenPaths = await Promise.all(
|
|
226
|
+
[...libNames].sort().map(async (libName) => {
|
|
227
|
+
const tokensPath = path.join(libsRoot, libName, "ui/tokens.css");
|
|
228
|
+
return (await this.#fileExists(tokensPath)) ? tokensPath : null;
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
return tokenPaths.filter((tokensPath): tokensPath is string => !!tokensPath);
|
|
184
232
|
}
|
|
185
233
|
async compileCss(cssPaths: string[], sourcePaths: string[]): Promise<string> {
|
|
186
234
|
if (cssPaths.length === 0) return "";
|
|
@@ -222,12 +270,22 @@ export class CssCompiler {
|
|
|
222
270
|
|
|
223
271
|
async #loadStylesheet(id: string, fromBase: string) {
|
|
224
272
|
const p = await this.#resolveCssImport(id, fromBase);
|
|
273
|
+
this.#discoveredCssPaths.add(p);
|
|
225
274
|
const content = await Bun.file(p).text();
|
|
226
275
|
return { path: p, base: path.dirname(p), content };
|
|
227
276
|
}
|
|
228
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Every specifier is verified here, path-shaped ones included. An `@import` the pipeline cannot resolve is
|
|
280
|
+
* a build error and never a no-op: the vocabulary closure means a component whose token declaration failed
|
|
281
|
+
* to load renders unstyled, which nothing downstream can distinguish from a design choice.
|
|
282
|
+
*/
|
|
229
283
|
async #resolveCssImport(id: string, fromBase: string): Promise<string> {
|
|
230
|
-
if (id.startsWith(".") || id.startsWith("/"))
|
|
284
|
+
if (id.startsWith(".") || id.startsWith("/")) {
|
|
285
|
+
const filePath = path.resolve(fromBase, id);
|
|
286
|
+
if (await this.#fileExists(filePath)) return filePath;
|
|
287
|
+
throw new Error(`[css] failed to resolve stylesheet import "${id}" from ${fromBase} (no file at ${filePath})`);
|
|
288
|
+
}
|
|
231
289
|
const resolver = await this.#getCssImportResolver();
|
|
232
290
|
const resolved = await resolver.resolve(id, fromBase);
|
|
233
291
|
if (resolved) return resolved;
|
|
@@ -106,14 +106,15 @@ export class CssImportResolver {
|
|
|
106
106
|
const pkg = await Bun.file(pkgPath).json();
|
|
107
107
|
const subpath = id === pkgName ? "." : `.${id.slice(pkgName.length)}`;
|
|
108
108
|
const exportValue = pkg.exports?.[subpath];
|
|
109
|
-
const
|
|
110
|
-
|
|
109
|
+
const exportedEntry =
|
|
110
|
+
typeof exportValue === "string"
|
|
111
111
|
? exportValue
|
|
112
|
-
: exportValue?.style || exportValue?.import || exportValue?.default
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return await this.#firstExisting(path.resolve(pkgDir,
|
|
112
|
+
: exportValue?.style || exportValue?.import || exportValue?.default;
|
|
113
|
+
if (exportedEntry) return await this.#firstExisting(path.resolve(pkgDir, exportedEntry));
|
|
114
|
+
//* A subpath names a file inside the package, so it resolves literally. Falling back to the package's own
|
|
115
|
+
//* style entry here would load a different stylesheet than the author asked for and report success.
|
|
116
|
+
if (subpath !== ".") return await this.#firstExisting(path.resolve(pkgDir, subpath));
|
|
117
|
+
return await this.#firstExisting(path.resolve(pkgDir, pkg.exports?.["."]?.style || pkg.style || "index.css"));
|
|
117
118
|
} catch {
|
|
118
119
|
return null;
|
|
119
120
|
}
|
|
@@ -391,6 +391,20 @@ describe("CssImportResolver", () => {
|
|
|
391
391
|
expect(await resolver.resolve("@libs/ui/missing", root)).toBeNull();
|
|
392
392
|
});
|
|
393
393
|
|
|
394
|
+
test("never substitutes the package stylesheet for a subpath that does not exist", async () => {
|
|
395
|
+
const root = await makeTempRoot();
|
|
396
|
+
await write(
|
|
397
|
+
path.join(root, "node_modules/vendor/package.json"),
|
|
398
|
+
JSON.stringify({ name: "vendor", style: "index.css" }),
|
|
399
|
+
);
|
|
400
|
+
await write(path.join(root, "node_modules/vendor/index.css"), ".vendor {}\n");
|
|
401
|
+
|
|
402
|
+
const resolver = new CssImportResolver(root, {});
|
|
403
|
+
|
|
404
|
+
expect(await resolver.resolve("vendor", root)).toBe(path.join(root, "node_modules/vendor/index.css"));
|
|
405
|
+
expect(await resolver.resolve("vendor/ui/tokens.css", root)).toBeNull();
|
|
406
|
+
});
|
|
407
|
+
|
|
394
408
|
test("resolves css from single-package Akan workspace subpaths", async () => {
|
|
395
409
|
const root = await makeTempRoot();
|
|
396
410
|
await write(path.join(root, "pkgs/akanjs/ui/styles.css"), "body {}\n");
|
|
@@ -428,4 +442,44 @@ describe("CssCompiler", () => {
|
|
|
428
442
|
|
|
429
443
|
expect(css).toContain(".text-fuchsia-500");
|
|
430
444
|
});
|
|
445
|
+
|
|
446
|
+
test("fails loudly on a stylesheet import that resolves to nothing", async () => {
|
|
447
|
+
const root = await makeTempRoot();
|
|
448
|
+
const cssPath = path.join(root, "apps/demo/page/styles.css");
|
|
449
|
+
await write(cssPath, '@import "../../../libs/shared/ui/tokens.css";\n');
|
|
450
|
+
|
|
451
|
+
const compiler = new CssCompiler({
|
|
452
|
+
workspace: { workspaceRoot: root },
|
|
453
|
+
cwdPath: path.join(root, "apps/demo"),
|
|
454
|
+
getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
|
|
455
|
+
} as never);
|
|
456
|
+
|
|
457
|
+
await expect(compiler.compileCss([cssPath], [])).rejects.toThrow(
|
|
458
|
+
/failed to resolve stylesheet import "\.\.\/\.\.\/\.\.\/libs\/shared\/ui\/tokens\.css"/,
|
|
459
|
+
);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("compiles lib-owned tokens ahead of the app stylesheets that may override them", async () => {
|
|
463
|
+
const root = await makeTempRoot();
|
|
464
|
+
const appDir = path.join(root, "apps/demo");
|
|
465
|
+
await write(
|
|
466
|
+
path.join(appDir, "page/_index.tsx"),
|
|
467
|
+
'import "./styles.css";\nimport { Card } from "@libs/shared/ui";\nexport default () => <Card />;\n',
|
|
468
|
+
);
|
|
469
|
+
await write(path.join(appDir, "page/styles.css"), ":root { --brand: #111111; }\n");
|
|
470
|
+
await write(path.join(root, "libs/shared/ui/index.ts"), "export const Card = () => null;\n");
|
|
471
|
+
await write(path.join(root, "libs/shared/ui/tokens.css"), ":root { --kakao: #fee500; }\n");
|
|
472
|
+
await write(path.join(root, "libs/unused/ui/tokens.css"), ":root { --unused: #000000; }\n");
|
|
473
|
+
|
|
474
|
+
const compiler = new CssCompiler({
|
|
475
|
+
workspace: { workspaceRoot: root },
|
|
476
|
+
cwdPath: appDir,
|
|
477
|
+
getPageKeys: async () => ["./_index.tsx"],
|
|
478
|
+
getConfig: async () => ({ barrelImports: [] }),
|
|
479
|
+
getTsConfig: async () => ({ compilerOptions: { paths: { "@libs/*": ["./libs/*"] } } }),
|
|
480
|
+
} as never);
|
|
481
|
+
const { cssPaths } = await compiler.discoverCssAndSources();
|
|
482
|
+
|
|
483
|
+
expect(cssPaths).toEqual([path.join(root, "libs/shared/ui/tokens.css"), path.join(appDir, "page/styles.css")]);
|
|
484
|
+
});
|
|
431
485
|
});
|