@akanjs/devkit 3.0.0-beta.20 → 3.0.0-beta.21
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 +2 -2
- package/README.md +2 -2
- package/akanConfig/akanConfig.test.ts +58 -38
- package/akanConfig/akanConfig.ts +88 -48
- package/akanConfig/types.ts +3 -3
- package/akanContext.ts +5 -2
- package/artifact/implicitRootLayout.test.ts +23 -0
- package/artifact/implicitRootLayout.ts +24 -0
- package/biome.base.json +26 -0
- package/commandDecorators/command.ts +1 -1
- package/executors.ts +1 -12
- package/incrementalBuilder/buildBatch.proc.ts +7 -1
- package/index.ts +0 -2
- package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
- package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
- package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
- package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
- package/lint/no-document-cookie.grit +29 -0
- package/lint/no-web-only-api-outside-webkit.grit +44 -0
- package/lint/no-web-storage.grit +30 -0
- package/mobile/index.ts +6 -0
- package/mobile/mobileTarget.ts +2 -0
- package/mobile/nativeApi.test.ts +30 -0
- package/mobile/nativeApi.ts +40 -0
- package/mobile/nativeApp.test.ts +85 -0
- package/mobile/nativeApp.ts +210 -0
- package/mobile/nativeConfig.test.ts +199 -0
- package/mobile/nativeConfig.ts +184 -0
- package/mobile/nativeWebDir.test.ts +80 -0
- package/mobile/nativeWebDir.ts +62 -0
- package/mobile/placeholderAppId.test.ts +13 -0
- package/mobile/placeholderAppId.ts +16 -0
- package/mobile/usageDescriptions.test.ts +27 -0
- package/mobile/usageDescriptions.ts +24 -0
- package/package.json +2 -7
- package/qualityScanner.ts +4 -2
- package/scanInfo.ts +5 -10
- package/workspaceLayout.test.ts +13 -3
- package/workspaceLayout.ts +16 -4
- package/applicationReleasePackager.ts +0 -189
- package/capacitorApp.test.ts +0 -388
- package/capacitorApp.ts +0 -1520
package/README.ko.md
CHANGED
|
@@ -44,7 +44,7 @@ await runner.build();
|
|
|
44
44
|
- Frontend build transform과 RSC/SSR artifact builder.
|
|
45
45
|
- `@akanjs/cli`가 사용하는 command/script decorator.
|
|
46
46
|
- AI prompt, guideline, code-generation 지원 utility.
|
|
47
|
-
-
|
|
47
|
+
- `@akanjs/native` 기반 네이티브 모바일 빌드(iOS, Android)와 스토어 release helper.
|
|
48
48
|
|
|
49
49
|
## 패키지 경계
|
|
50
50
|
|
|
@@ -58,7 +58,7 @@ await runner.build();
|
|
|
58
58
|
|
|
59
59
|
- [Bun](https://bun.sh) `>=1.4.0`
|
|
60
60
|
- TypeScript
|
|
61
|
-
-
|
|
61
|
+
- `react`는 optional peer이며, 렌더링하는 기능에서만 필요합니다.
|
|
62
62
|
|
|
63
63
|
## 라이선스
|
|
64
64
|
|
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ await runner.build();
|
|
|
43
43
|
- Frontend build transforms and RSC/SSR artifact builders.
|
|
44
44
|
- Command/script decorators used by `@akanjs/cli`.
|
|
45
45
|
- AI prompt, guideline, and code-generation support utilities.
|
|
46
|
-
-
|
|
46
|
+
- Native mobile builds on `@akanjs/native` (iOS, Android) and store release helpers.
|
|
47
47
|
- The `akan code` agent engine, the tools it is given, and the shipped akan skills (`@akanjs/devkit/codeAgent`).
|
|
48
48
|
|
|
49
49
|
## Dev Server Sizing
|
|
@@ -63,7 +63,7 @@ defaults, the shares they derive from `AKAN_MEMORY_LIMIT`, and what a small cont
|
|
|
63
63
|
|
|
64
64
|
- [Bun](https://bun.sh) `>=1.4.0`
|
|
65
65
|
- TypeScript
|
|
66
|
-
-
|
|
66
|
+
- `react` is an optional peer, needed only by the features that render.
|
|
67
67
|
|
|
68
68
|
## License
|
|
69
69
|
|
|
@@ -175,7 +175,7 @@ describe("AkanAppConfig", () => {
|
|
|
175
175
|
test("refuses a csr-less build that ships a mobile app", () => {
|
|
176
176
|
expect(
|
|
177
177
|
() => new AkanAppConfig(app, [], packageJson, { web: { csr: false }, mobile: { appName: "portal" } }, baseDevEnv),
|
|
178
|
-
).toThrow("the
|
|
178
|
+
).toThrow("the mobile apps ship that bundle");
|
|
179
179
|
});
|
|
180
180
|
|
|
181
181
|
test("installs only ca-certificates and tzdata in the default image", () => {
|
|
@@ -356,43 +356,6 @@ describe("AkanAppConfig", () => {
|
|
|
356
356
|
expect(config.getMissingDatabaseModeDependencySpecs("cluster")).toEqual([
|
|
357
357
|
`postgres@${runtimeDependencies.postgres}`,
|
|
358
358
|
]);
|
|
359
|
-
// The workspace-root install covers the toolchain plus every app Capacitor plugin, deduped (`@capacitor/core`).
|
|
360
|
-
const expectedMobilePackages: string[] = [
|
|
361
|
-
"@capacitor/cli",
|
|
362
|
-
"@capacitor/core",
|
|
363
|
-
"@capacitor/ios",
|
|
364
|
-
"@capacitor/android",
|
|
365
|
-
"@capacitor/assets",
|
|
366
|
-
"@capacitor/app",
|
|
367
|
-
"@capacitor/browser",
|
|
368
|
-
"@capacitor/camera",
|
|
369
|
-
"@capacitor/device",
|
|
370
|
-
"@capacitor/geolocation",
|
|
371
|
-
"@capacitor/haptics",
|
|
372
|
-
"@capacitor/inappbrowser",
|
|
373
|
-
"@capacitor/keyboard",
|
|
374
|
-
"@capacitor/preferences",
|
|
375
|
-
"@capacitor/push-notifications",
|
|
376
|
-
"capacitor-plugin-safe-area",
|
|
377
|
-
];
|
|
378
|
-
expect(config.getMobileRuntimePackages() as string[]).toEqual(expectedMobilePackages);
|
|
379
|
-
expect(config.getMissingMobileDependencySpecs()).toEqual(
|
|
380
|
-
expectedMobilePackages.map((lib) => `${lib}@${runtimeDependencies[lib as keyof typeof runtimeDependencies]}`),
|
|
381
|
-
);
|
|
382
|
-
expect(config.getMobileAppCapacitorPlugins()).toEqual([
|
|
383
|
-
"@capacitor/app",
|
|
384
|
-
"@capacitor/browser",
|
|
385
|
-
"@capacitor/camera",
|
|
386
|
-
"@capacitor/core",
|
|
387
|
-
"@capacitor/device",
|
|
388
|
-
"@capacitor/geolocation",
|
|
389
|
-
"@capacitor/haptics",
|
|
390
|
-
"@capacitor/inappbrowser",
|
|
391
|
-
"@capacitor/keyboard",
|
|
392
|
-
"@capacitor/preferences",
|
|
393
|
-
"@capacitor/push-notifications",
|
|
394
|
-
"capacitor-plugin-safe-area",
|
|
395
|
-
]);
|
|
396
359
|
});
|
|
397
360
|
|
|
398
361
|
test("normalizes multiple mobile targets and validates base paths", () => {
|
|
@@ -459,6 +422,63 @@ describe("AkanAppConfig", () => {
|
|
|
459
422
|
).toThrow("unknown basePath");
|
|
460
423
|
});
|
|
461
424
|
|
|
425
|
+
test("merges the mobile-wide files and native settings into each target, the target winning", () => {
|
|
426
|
+
const config = new AkanAppConfig(
|
|
427
|
+
app,
|
|
428
|
+
[],
|
|
429
|
+
packageJson,
|
|
430
|
+
{
|
|
431
|
+
mobile: {
|
|
432
|
+
fileName: "portal",
|
|
433
|
+
files: { "android/res/raw/chime.mp3": "assets/chime.mp3" },
|
|
434
|
+
native: {
|
|
435
|
+
plugins: ["iap"],
|
|
436
|
+
ios: { infoPlist: { ITSAppUsesNonExemptEncryption: false } },
|
|
437
|
+
android: { manifest: ["<queries/>"], googleServices: "secrets/google-services.json" },
|
|
438
|
+
},
|
|
439
|
+
targets: {
|
|
440
|
+
default: {
|
|
441
|
+
files: { "ios/sound.caf": "assets/sound.caf" },
|
|
442
|
+
native: { plugins: ["iap", "share"], android: { manifest: ["<uses-feature/>"] } },
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
baseDevEnv,
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
expect(config.mobile.targets.default).toMatchObject({
|
|
451
|
+
fileName: "portal",
|
|
452
|
+
files: { "android/res/raw/chime.mp3": "assets/chime.mp3", "ios/sound.caf": "assets/sound.caf" },
|
|
453
|
+
native: {
|
|
454
|
+
plugins: ["iap", "share"],
|
|
455
|
+
ios: { infoPlist: { ITSAppUsesNonExemptEncryption: false } },
|
|
456
|
+
android: { manifest: ["<queries/>", "<uses-feature/>"], googleServices: "secrets/google-services.json" },
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("refuses the Capacitor-era keys with the setting that replaces them", () => {
|
|
462
|
+
const make = (mobile: Record<string, unknown>) => () =>
|
|
463
|
+
new AkanAppConfig(app, [], packageJson, { mobile } as never, baseDevEnv);
|
|
464
|
+
|
|
465
|
+
expect(make({ plugins: { Keyboard: { resize: "none" } } })).toThrow(
|
|
466
|
+
"mobile.plugins in apps/portal/akan.config.ts is a Capacitor setting",
|
|
467
|
+
);
|
|
468
|
+
expect(make({ targets: { default: { ios: { scheme: "App" } } } })).toThrow(
|
|
469
|
+
"mobile.targets.default.ios in apps/portal/akan.config.ts is a Capacitor setting",
|
|
470
|
+
);
|
|
471
|
+
expect(make({ targets: { default: { files: { android: { "app/google-services.json": "x.json" } } } } })).toThrow(
|
|
472
|
+
"google-services.json goes to native.android.googleServices",
|
|
473
|
+
);
|
|
474
|
+
expect(make({ files: { "App/App/Info.plist": "x.plist" } })).toThrow(
|
|
475
|
+
"must land under ios/<path>, android/res/<type>/<file> or android/assets/<path>",
|
|
476
|
+
);
|
|
477
|
+
expect(make({ server: { url: "http://x" } })).toThrow(
|
|
478
|
+
"mobile.server in apps/portal/akan.config.ts is not a mobile setting",
|
|
479
|
+
);
|
|
480
|
+
});
|
|
481
|
+
|
|
462
482
|
test("derives a repo-scoped default appId and records explicit-mobile intent", () => {
|
|
463
483
|
// No mobile section: appId defaults to the repo-scoped reverse-DNS id, and mobile is not explicit.
|
|
464
484
|
const withoutMobile = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv);
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -40,22 +40,6 @@ const DEFAULT_OPTIMIZE_IMPORTS = [
|
|
|
40
40
|
const WORKSPACE_BARREL_FACETS = ["ui", "webkit", "common", "client", "server"] as const;
|
|
41
41
|
const DEFAULT_DOCKER_IMAGE = "oven/bun:1-slim";
|
|
42
42
|
const SSR_RUNTIME_PACKAGES = ["react", "react-dom", "react-server-dom-webpack"] as const;
|
|
43
|
-
// Optional peers only, so a fresh workspace never installs them; the mobile preflight adds them at the root.
|
|
44
|
-
const MOBILE_RUNTIME_PACKAGES = [
|
|
45
|
-
"@capacitor/cli",
|
|
46
|
-
"@capacitor/core",
|
|
47
|
-
"@capacitor/ios",
|
|
48
|
-
"@capacitor/android",
|
|
49
|
-
"@capacitor/assets",
|
|
50
|
-
] as const;
|
|
51
|
-
// `npx cap sync` registers only plugins the app's own package.json declares (else the bridge throws `Capacitor plugin
|
|
52
|
-
// "Device" is not available.`), so they are declared there with "*" and deduped to the root-installed version.
|
|
53
|
-
const MOBILE_APP_CAPACITOR_PLUGINS = [
|
|
54
|
-
..."app browser camera core device geolocation haptics inappbrowser keyboard preferences push-notifications"
|
|
55
|
-
.split(" ")
|
|
56
|
-
.map((name) => `@capacitor/${name}`),
|
|
57
|
-
"capacitor-plugin-safe-area",
|
|
58
|
-
];
|
|
59
43
|
const DEFAULT_AKAN_IMAGE_CONFIG: AkanImageConfig = {
|
|
60
44
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
|
61
45
|
imageSizes: [32, 48, 64, 96, 128, 256, 384],
|
|
@@ -237,7 +221,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
237
221
|
// `akan build-ios` / `build-android` copy `dist/apps/<app>/csr/<target>.html` into the native project.
|
|
238
222
|
if (!resolved.csr && this.hasMobileConfig)
|
|
239
223
|
throw new Error(
|
|
240
|
-
`apps/${this.app.name}/akan.config.ts turns the CSR bundle off but declares mobile targets; the
|
|
224
|
+
`apps/${this.app.name}/akan.config.ts turns the CSR bundle off but declares mobile targets; the mobile apps ship that bundle. Drop the mobile section or leave CSR on.`,
|
|
241
225
|
);
|
|
242
226
|
return resolved;
|
|
243
227
|
}
|
|
@@ -247,10 +231,14 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
247
231
|
indexPath: _indexPath,
|
|
248
232
|
...rawMobile
|
|
249
233
|
} = (mobile ?? {}) as DeepPartial<AkanMobileConfig> & { indexPath?: unknown };
|
|
234
|
+
const configPath = `apps/${this.app.name}/akan.config.ts`;
|
|
235
|
+
AkanAppConfig.#assertMobileKeys(rawMobile, AkanAppConfig.#mobileKeys, "mobile", configPath);
|
|
250
236
|
const appName = rawMobile.appName ?? this.app.name;
|
|
251
237
|
const appId = rawMobile.appId ?? deriveDefaultAppId(this.baseDevEnv.repoName, this.app.name);
|
|
252
238
|
const version = rawMobile.version ?? "0.0.1";
|
|
253
239
|
const buildNum = rawMobile.buildNum ?? 1;
|
|
240
|
+
const files = AkanAppConfig.#resolveMobileFiles(rawMobile.files as AkanMobileConfig["files"], "mobile", configPath);
|
|
241
|
+
const native = rawMobile.native as AkanMobileConfig["native"];
|
|
254
242
|
const defaultTargetName = this.#defaultMobileTargetName(rawTargets);
|
|
255
243
|
const targetEntries = Object.entries(
|
|
256
244
|
rawTargets ?? {
|
|
@@ -259,52 +247,114 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
259
247
|
);
|
|
260
248
|
const targets = Object.fromEntries(
|
|
261
249
|
targetEntries.map(([name, rawTarget]) => {
|
|
262
|
-
const target = rawTarget as DeepPartial<AkanMobileTargetConfig>;
|
|
250
|
+
const target = (rawTarget ?? {}) as DeepPartial<AkanMobileTargetConfig>;
|
|
251
|
+
const where = `mobile.targets.${name}`;
|
|
252
|
+
AkanAppConfig.#assertMobileKeys(target, AkanAppConfig.#targetKeys, where, configPath);
|
|
263
253
|
const fallbackBasePath = !rawTargets && this.basePaths.has(name) ? name : undefined;
|
|
264
254
|
const basePath = (target.basePath ?? fallbackBasePath)?.replace(/^\/+|\/+$/g, "") || undefined;
|
|
265
255
|
const indexPath = normalizeIndexPath(target.indexPath as string | undefined);
|
|
266
256
|
const deepLinks = normalizeDeepLinks(target.deepLinks);
|
|
267
257
|
if (basePath && !this.basePaths.has(basePath)) {
|
|
268
|
-
throw new Error(
|
|
269
|
-
`Mobile target '${name}' uses unknown basePath '${basePath}' in apps/${this.app.name}/akan.config.ts`,
|
|
270
|
-
);
|
|
258
|
+
throw new Error(`Mobile target '${name}' uses unknown basePath '${basePath}' in ${configPath}`);
|
|
271
259
|
}
|
|
260
|
+
const targetFiles = AkanAppConfig.#resolveMobileFiles(
|
|
261
|
+
target.files as AkanMobileTargetConfig["files"],
|
|
262
|
+
where,
|
|
263
|
+
configPath,
|
|
264
|
+
);
|
|
265
|
+
const fileName = (target.fileName ?? rawMobile.fileName) as string | undefined;
|
|
272
266
|
const resolved = {
|
|
273
|
-
...rawMobile,
|
|
274
|
-
...target,
|
|
275
267
|
name,
|
|
276
268
|
basePath,
|
|
277
269
|
indexPath,
|
|
278
270
|
deepLinks,
|
|
279
271
|
appName: target.appName ?? appName,
|
|
280
272
|
appId: target.appId ?? appId,
|
|
273
|
+
...(fileName ? { fileName } : {}),
|
|
281
274
|
version: target.version ?? version,
|
|
282
275
|
buildNum: target.buildNum ?? buildNum,
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
...target.android,
|
|
290
|
-
},
|
|
291
|
-
ios: {
|
|
292
|
-
...rawMobile.ios,
|
|
293
|
-
...target.ios,
|
|
294
|
-
},
|
|
276
|
+
...(target.assets ? { assets: target.assets as AkanMobileTargetConfig["assets"] } : {}),
|
|
277
|
+
...(target.permissions ? { permissions: target.permissions as AkanMobileTargetConfig["permissions"] } : {}),
|
|
278
|
+
...(files || targetFiles ? { files: { ...files, ...targetFiles } } : {}),
|
|
279
|
+
...(native || target.native
|
|
280
|
+
? { native: AkanAppConfig.#mergeNative(native, target.native as AkanMobileTargetConfig["native"]) }
|
|
281
|
+
: {}),
|
|
295
282
|
} satisfies AkanMobileTargetConfig;
|
|
296
283
|
return [name, resolved];
|
|
297
284
|
}),
|
|
298
285
|
);
|
|
299
286
|
return {
|
|
300
|
-
...rawMobile,
|
|
301
287
|
appName,
|
|
302
288
|
appId,
|
|
289
|
+
...(rawMobile.fileName ? { fileName: rawMobile.fileName as string } : {}),
|
|
303
290
|
version,
|
|
304
291
|
buildNum,
|
|
292
|
+
...(files ? { files } : {}),
|
|
293
|
+
...(native ? { native } : {}),
|
|
305
294
|
targets,
|
|
306
|
-
|
|
307
|
-
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
static readonly #mobileKeys = ["appName", "appId", "fileName", "version", "buildNum", "files", "native"];
|
|
298
|
+
static readonly #targetKeys = [
|
|
299
|
+
"basePath",
|
|
300
|
+
"indexPath",
|
|
301
|
+
"appName",
|
|
302
|
+
"appId",
|
|
303
|
+
"fileName",
|
|
304
|
+
"version",
|
|
305
|
+
"buildNum",
|
|
306
|
+
"assets",
|
|
307
|
+
"permissions",
|
|
308
|
+
"deepLinks",
|
|
309
|
+
"files",
|
|
310
|
+
"native",
|
|
311
|
+
];
|
|
312
|
+
//* The Capacitor keys used to pass straight through; the native runtime takes typed ones, so a leftover is refused.
|
|
313
|
+
static #assertMobileKeys(section: object, known: string[], where: string, configPath: string) {
|
|
314
|
+
for (const key of Object.keys(section)) {
|
|
315
|
+
if (known.includes(key)) continue;
|
|
316
|
+
if (key === "plugins" || key === "ios" || key === "android")
|
|
317
|
+
throw new Error(
|
|
318
|
+
`${where}.${key} in ${configPath} is a Capacitor setting. The native runtime reads native.plugins, native.ios.{infoPlist,entitlements} and native.android.{manifest,application,activity,googleServices} instead.`,
|
|
319
|
+
);
|
|
320
|
+
throw new Error(`${where}.${key} in ${configPath} is not a mobile setting. Known: ${known.join(", ")}.`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
static readonly #fileTargetPattern = /^(?:ios\/.+|android\/res\/[^/]+\/[^/]+|android\/assets\/.+)$/;
|
|
324
|
+
static #resolveMobileFiles(files: AkanMobileTargetConfig["files"] | undefined, where: string, configPath: string) {
|
|
325
|
+
if (!files) return undefined;
|
|
326
|
+
for (const [to, from] of Object.entries(files)) {
|
|
327
|
+
if (typeof from !== "string")
|
|
328
|
+
throw new Error(
|
|
329
|
+
`${where}.files in ${configPath} maps where a file lands to its source now, e.g. { "android/res/raw/chime.mp3": "assets/chime.mp3" }. google-services.json goes to native.android.googleServices, and GoogleService-Info.plist is no longer needed.`,
|
|
330
|
+
);
|
|
331
|
+
if (!AkanAppConfig.#fileTargetPattern.test(to))
|
|
332
|
+
throw new Error(
|
|
333
|
+
`${where}.files["${to}"] in ${configPath} must land under ios/<path>, android/res/<type>/<file> or android/assets/<path>.`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
return files;
|
|
337
|
+
}
|
|
338
|
+
static #mergeNative(
|
|
339
|
+
base: AkanMobileTargetConfig["native"],
|
|
340
|
+
override: AkanMobileTargetConfig["native"],
|
|
341
|
+
): NonNullable<AkanMobileTargetConfig["native"]> {
|
|
342
|
+
const plugins = [...new Set([...(base?.plugins ?? []), ...(override?.plugins ?? [])])];
|
|
343
|
+
const ios = { ...base?.ios, ...override?.ios };
|
|
344
|
+
const android: NonNullable<NonNullable<AkanMobileTargetConfig["native"]>["android"]> = {
|
|
345
|
+
...base?.android,
|
|
346
|
+
...override?.android,
|
|
347
|
+
};
|
|
348
|
+
for (const key of ["manifest", "application", "activity"] as const) {
|
|
349
|
+
const merged = [...(base?.android?.[key] ?? []), ...(override?.android?.[key] ?? [])];
|
|
350
|
+
if (merged.length) android[key] = merged;
|
|
351
|
+
else delete android[key];
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
...(plugins.length ? { plugins } : {}),
|
|
355
|
+
...(Object.keys(ios).length ? { ios } : {}),
|
|
356
|
+
...(Object.keys(android).length ? { android } : {}),
|
|
357
|
+
};
|
|
308
358
|
}
|
|
309
359
|
#defaultMobileTargetName(rawTargets: DeepPartial<AkanMobileConfig>["targets"] | undefined) {
|
|
310
360
|
if (rawTargets && Object.keys(rawTargets).length > 0) return Object.keys(rawTargets)[0] as string;
|
|
@@ -480,16 +530,6 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
|
|
|
480
530
|
const modes = declared?.length ? declared : ["single"];
|
|
481
531
|
return { modes: DatabaseModes.parseList(modes.join(","), `database.modes in apps/${app.name}/akan.config.ts`) };
|
|
482
532
|
}
|
|
483
|
-
getMobileRuntimePackages() {
|
|
484
|
-
// Installed at the root too, so the app's "*" declarations resolve to the hoisted, peer-pinned version.
|
|
485
|
-
return [...new Set([...MOBILE_RUNTIME_PACKAGES, ...MOBILE_APP_CAPACITOR_PLUGINS])];
|
|
486
|
-
}
|
|
487
|
-
getMissingMobileDependencySpecs() {
|
|
488
|
-
return this.#getMissingDependencySpecs(this.getMobileRuntimePackages());
|
|
489
|
-
}
|
|
490
|
-
getMobileAppCapacitorPlugins() {
|
|
491
|
-
return [...MOBILE_APP_CAPACITOR_PLUGINS];
|
|
492
|
-
}
|
|
493
533
|
#getMissingDependencySpecs(libs: readonly string[]) {
|
|
494
534
|
const rootDependencies = {
|
|
495
535
|
...this.rootPackageJson.dependencies,
|
package/akanConfig/types.ts
CHANGED
|
@@ -5,10 +5,11 @@ export type {
|
|
|
5
5
|
AkanDatabaseConfig,
|
|
6
6
|
AkanExecutor,
|
|
7
7
|
AkanMobileConfig,
|
|
8
|
+
AkanMobileNativeConfig,
|
|
8
9
|
AkanMobileTargetConfig,
|
|
9
|
-
|
|
10
|
+
AkanNativeValue,
|
|
10
11
|
AkanPlugin,
|
|
11
|
-
|
|
12
|
+
AkanPluginNativeConfig,
|
|
12
13
|
AkanRouteConfig,
|
|
13
14
|
AkanScanInfo,
|
|
14
15
|
AkanSyncContext,
|
|
@@ -35,7 +36,6 @@ export type {
|
|
|
35
36
|
MobileEnv,
|
|
36
37
|
MobilePermission,
|
|
37
38
|
PkgScanResult,
|
|
38
|
-
PluginRuntimeContext,
|
|
39
39
|
ScanResult,
|
|
40
40
|
WorkspaceScanResult,
|
|
41
41
|
} from "akanjs";
|
package/akanContext.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
workflowRunArtifactPath,
|
|
17
17
|
workflowSyncDir,
|
|
18
18
|
} from "./workflow";
|
|
19
|
-
import { isScannedRootEntry, rootAllowedDirs, rootAllowedFiles } from "./workspaceLayout";
|
|
19
|
+
import { isScannedRootEntry, rootAllowedDirs, rootAllowedFiles, rootEntryHintOf } from "./workspaceLayout";
|
|
20
20
|
|
|
21
21
|
export type AkanContextFormat = "json" | "markdown";
|
|
22
22
|
export type AkanModuleKind = "domain" | "service" | "scalar";
|
|
@@ -657,13 +657,16 @@ export class AkanContextAnalyzer {
|
|
|
657
657
|
const allowed = entry.isDirectory()
|
|
658
658
|
? rootAllowedDirs[sys.type].has(entry.name)
|
|
659
659
|
: rootAllowedFiles[sys.type].has(entry.name);
|
|
660
|
+
const rootEntryHint = rootEntryHintOf(sys.type, entry.name);
|
|
660
661
|
if (!allowed)
|
|
661
662
|
report(
|
|
662
663
|
{
|
|
663
664
|
severity: "error",
|
|
664
665
|
code: `${sys.type}-root-unknown-entry`,
|
|
665
666
|
path: `${sys.path}/${entry.name}`,
|
|
666
|
-
message: `Unexpected ${entry.isDirectory() ? "folder" : "file"} in ${sys.type} root: ${sys.path}/${entry.name}
|
|
667
|
+
message: `Unexpected ${entry.isDirectory() ? "folder" : "file"} in ${sys.type} root: ${sys.path}/${entry.name}${
|
|
668
|
+
rootEntryHint ? ` (${rootEntryHint})` : ""
|
|
669
|
+
}`,
|
|
667
670
|
},
|
|
668
671
|
repairAction(
|
|
669
672
|
"module-shape",
|
|
@@ -150,4 +150,27 @@ describe("resolveSsrPageEntries", () => {
|
|
|
150
150
|
expect(generatedSource).toContain("? await UserLayout({ params, searchParams, children })");
|
|
151
151
|
expect(generatedSource).toContain(" return layout;\n}");
|
|
152
152
|
});
|
|
153
|
+
|
|
154
|
+
test("prunes generated wrappers whose route file moved away", async () => {
|
|
155
|
+
const appRoot = await makeTempRoot();
|
|
156
|
+
const pageRoot = path.join(appRoot, "page");
|
|
157
|
+
await write(path.join(appRoot, "env", "env.client.ts"), "export const env = {};\n");
|
|
158
|
+
await write(path.join(pageRoot, "a", "_overrides.tsx"), "export default {};\n");
|
|
159
|
+
await write(path.join(pageRoot, "b", "_overrides.tsx"), "export default {};\n");
|
|
160
|
+
await write(path.join(pageRoot, "(home)", "_layout.tsx"), "export default ({ children }) => children;\n");
|
|
161
|
+
const resolve = async (pageKeys: string[]) =>
|
|
162
|
+
await resolveSsrPageEntries({ appCwdPath: appRoot, appName: "demo", pageKeys });
|
|
163
|
+
const generatedPaths = async (pageKeys: string[]) =>
|
|
164
|
+
(await resolve(pageKeys))
|
|
165
|
+
.filter((entry) => entry.moduleAbsPath.includes(`${path.sep}.akan${path.sep}`))
|
|
166
|
+
.map((entry) => entry.moduleAbsPath);
|
|
167
|
+
|
|
168
|
+
const before = await generatedPaths(["./(home)/_layout.tsx", "./a/_overrides.tsx", "./a/x.tsx"]);
|
|
169
|
+
const after = await generatedPaths(["./b/_overrides.tsx", "./b/y.tsx"]);
|
|
170
|
+
|
|
171
|
+
expect(before).toHaveLength(2);
|
|
172
|
+
for (const stale of before) expect(await Bun.file(stale).exists()).toBe(false);
|
|
173
|
+
expect(after).toHaveLength(2);
|
|
174
|
+
for (const fresh of after) expect(await Bun.file(fresh).exists()).toBe(true);
|
|
175
|
+
});
|
|
153
176
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readdir, rm } from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import type { App } from "../commandDecorators";
|
|
3
4
|
import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
|
|
@@ -41,6 +42,17 @@ export default function AkanUiOverridesLayout({ children }: { children?: ReactNo
|
|
|
41
42
|
return absPath;
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
async function pruneGeneratedFiles(dirAbsPath: string, keepAbsPaths: string[]): Promise<void> {
|
|
46
|
+
const keep = new Set(keepAbsPaths);
|
|
47
|
+
const names = await readdir(dirAbsPath).catch(() => [] as string[]);
|
|
48
|
+
await Promise.all(
|
|
49
|
+
names
|
|
50
|
+
.map((name) => path.join(dirAbsPath, name))
|
|
51
|
+
.filter((absPath) => !keep.has(absPath))
|
|
52
|
+
.map((absPath) => rm(absPath, { force: true })),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
interface RootBoundary {
|
|
45
57
|
sourceKey: string | null;
|
|
46
58
|
sourceAbsPath: string | null;
|
|
@@ -283,6 +295,18 @@ export async function resolveSsrPageEntries(opts: {
|
|
|
283
295
|
seedAbsPaths: [...new Set([boundary.sourceAbsPath, rootSourceAbsPath].filter((absPath) => absPath !== null))],
|
|
284
296
|
})),
|
|
285
297
|
);
|
|
298
|
+
//? A moved `_overrides.tsx` or root layout leaves a wrapper importing a file that is gone.
|
|
299
|
+
const appCwdAbsPath = path.resolve(opts.appCwdPath);
|
|
300
|
+
await Promise.all([
|
|
301
|
+
pruneGeneratedFiles(
|
|
302
|
+
path.join(appCwdAbsPath, IMPLICIT_OVERRIDES_DIR),
|
|
303
|
+
base.filter(({ key }) => OVERRIDES_KEY_RE.test(key)).map(({ moduleAbsPath }) => moduleAbsPath),
|
|
304
|
+
),
|
|
305
|
+
pruneGeneratedFiles(
|
|
306
|
+
path.join(appCwdAbsPath, IMPLICIT_LAYOUT_DIR),
|
|
307
|
+
generated.map(({ moduleAbsPath }) => moduleAbsPath),
|
|
308
|
+
),
|
|
309
|
+
]);
|
|
286
310
|
return [...base, ...generated].sort((a, b) => a.key.localeCompare(b.key));
|
|
287
311
|
}
|
|
288
312
|
|
package/biome.base.json
CHANGED
|
@@ -206,6 +206,32 @@
|
|
|
206
206
|
],
|
|
207
207
|
"plugins": ["./node_modules/@akanjs/devkit/lint/no-init-fetch-in-client.grit"]
|
|
208
208
|
},
|
|
209
|
+
{
|
|
210
|
+
"includes": [
|
|
211
|
+
"{apps,libs}/**/*.ts",
|
|
212
|
+
"{apps,libs}/**/*.tsx",
|
|
213
|
+
"!**/*.test.ts",
|
|
214
|
+
"!**/*.test.tsx",
|
|
215
|
+
"!**/*.spec.ts",
|
|
216
|
+
"!**/*.spec.tsx"
|
|
217
|
+
],
|
|
218
|
+
"plugins": [
|
|
219
|
+
"./node_modules/@akanjs/devkit/lint/no-document-cookie.grit",
|
|
220
|
+
"./node_modules/@akanjs/devkit/lint/no-web-storage.grit"
|
|
221
|
+
]
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
"includes": [
|
|
225
|
+
"{apps,libs}/**/*.ts",
|
|
226
|
+
"{apps,libs}/**/*.tsx",
|
|
227
|
+
"!{apps,libs}/**/webkit/**",
|
|
228
|
+
"!**/*.test.ts",
|
|
229
|
+
"!**/*.test.tsx",
|
|
230
|
+
"!**/*.spec.ts",
|
|
231
|
+
"!**/*.spec.tsx"
|
|
232
|
+
],
|
|
233
|
+
"plugins": ["./node_modules/@akanjs/devkit/lint/no-web-only-api-outside-webkit.grit"]
|
|
234
|
+
},
|
|
209
235
|
{
|
|
210
236
|
"includes": [
|
|
211
237
|
"**/ui/**/*.ts",
|
|
@@ -10,7 +10,7 @@ import chalk from "chalk";
|
|
|
10
10
|
import { type Command, program } from "commander";
|
|
11
11
|
import { AppSelectionMemory } from "../appSelectionMemory";
|
|
12
12
|
import { AppExecutor, Executor, LibExecutor, ModuleExecutor, PkgExecutor, WorkspaceExecutor } from "../executors";
|
|
13
|
-
// Never the root barrel: it drags ink,
|
|
13
|
+
// Never the root barrel: it drags ink, ssh2 and the cloud stack into every command process.
|
|
14
14
|
import { FileSys, getDirname } from "../fileSys";
|
|
15
15
|
import type { PackageJson } from "../types";
|
|
16
16
|
import {
|
package/executors.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from "node:fs/promises";
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { pathToFileURL } from "node:url";
|
|
24
|
-
import type { AkanPlugin, AkanSyncContext
|
|
24
|
+
import type { AkanPlugin, AkanSyncContext } from "akanjs";
|
|
25
25
|
import {
|
|
26
26
|
capitalize,
|
|
27
27
|
getPageSourceFileViolation,
|
|
@@ -1480,17 +1480,6 @@ export class AppExecutor extends SysExecutor {
|
|
|
1480
1480
|
return true;
|
|
1481
1481
|
});
|
|
1482
1482
|
}
|
|
1483
|
-
async getPluginRuntimePackages(): Promise<string[]> {
|
|
1484
|
-
const plugins = await this.collectPlugins();
|
|
1485
|
-
const appConfig = await this.getConfig();
|
|
1486
|
-
const ctx: PluginRuntimeContext = {
|
|
1487
|
-
appName: this.name,
|
|
1488
|
-
mobile: appConfig.mobile,
|
|
1489
|
-
hasMobilePermission: (permission) =>
|
|
1490
|
-
Object.values(appConfig.mobile.targets).some((target) => target.permissions?.includes(permission) ?? false),
|
|
1491
|
-
};
|
|
1492
|
-
return [...new Set(plugins.flatMap((plugin) => plugin.runtimePackages?.(ctx) ?? []))];
|
|
1493
|
-
}
|
|
1494
1483
|
async increaseBuildNum() {
|
|
1495
1484
|
await increaseBuildNum(this);
|
|
1496
1485
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { ApplicationBuildReporter } from "@akanjs/devkit/applicationBuildReporter";
|
|
3
|
+
import { resolveSsrPageEntriesForApp } from "@akanjs/devkit/artifact/implicitRootLayout";
|
|
4
|
+
import { computeRouteSeedIndex, saveRouteSeedIndex } from "@akanjs/devkit/artifact/routeSeedIndex";
|
|
3
5
|
import type { App } from "@akanjs/devkit/commandDecorators";
|
|
4
6
|
// Subpath imports only, as few as possible: spawned per generation, this process pays every import on every save.
|
|
5
7
|
import { AppExecutor, WorkspaceExecutor } from "@akanjs/devkit/executors";
|
|
@@ -86,10 +88,14 @@ class BuildBatch {
|
|
|
86
88
|
}
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
// Rewritten with the bundle: the backend rereads it on `pages-updated` to pick up added, moved or deleted routes.
|
|
89
92
|
async #buildPages(): Promise<void> {
|
|
90
93
|
const started = Date.now();
|
|
91
94
|
try {
|
|
92
|
-
const
|
|
95
|
+
const pageEntries = await resolveSsrPageEntriesForApp(this.#app, await this.#app.getPageKeys());
|
|
96
|
+
const seedIndex = computeRouteSeedIndex(pageEntries);
|
|
97
|
+
const next = await new PagesBundleBuilder(this.#app, "start", pageEntries).build();
|
|
98
|
+
await saveRouteSeedIndex(this.#request.artifactDir, seedIndex);
|
|
93
99
|
this.#emit({
|
|
94
100
|
type: "pages-updated",
|
|
95
101
|
data: {
|
package/index.ts
CHANGED
|
@@ -7,10 +7,8 @@ export type * from "./akanContext";
|
|
|
7
7
|
export type * from "./akanMcpContract";
|
|
8
8
|
export type * from "./applicationBuildReporter";
|
|
9
9
|
export type * from "./applicationBuildRunner";
|
|
10
|
-
export type * from "./applicationReleasePackager";
|
|
11
10
|
export type * from "./applicationTestPreload";
|
|
12
11
|
export type * from "./artifact";
|
|
13
|
-
export type * from "./capacitorApp";
|
|
14
12
|
export type * from "./cloud";
|
|
15
13
|
export type * from "./codeAgent";
|
|
16
14
|
export type * from "./commandDecorators";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export const readTheme = () => document.cookie; // @flag
|
|
2
|
+
export const writeTheme = (value: string) => { document.cookie = `theme=${value}`; }; // @flag
|
|
3
|
+
export const throughWindow = () => window.document.cookie; // @flag
|
|
4
|
+
export const throughGlobalThis = () => globalThis.document.cookie; // @flag
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { getCookie, setCookie } from "akanjs/client";
|
|
2
|
+
export const readTheme = () => getCookie("theme"); // @ok
|
|
3
|
+
export const writeTheme = (value: string) => setCookie("theme", value); // @ok
|
|
4
|
+
export const otherDocumentFields = () => document.title; // @ok
|
|
5
|
+
export const aFieldNamedCookie = (req: { cookie: string }) => req.cookie; // @ok
|
|
6
|
+
export const aLiteral = "document.cookie is empty on app://"; // @ok
|
|
7
|
+
export const Docs = () => <p>getCookie reads the request, document.cookie the browser.</p>; // @ok
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const share = (url: string) => navigator.share({ url }); // @flag
|
|
2
|
+
export const canShare = () => typeof navigator.canShare === "function"; // @flag
|
|
3
|
+
export const worker = () => navigator.serviceWorker.register("/sw.js"); // @flag
|
|
4
|
+
export const ask = () => Notification.requestPermission(); // @flag
|
|
5
|
+
export const permission = () => Notification.permission; // @flag
|
|
6
|
+
export const locate = () => navigator.geolocation.getCurrentPosition(() => undefined); // @flag
|
|
7
|
+
export const buzz = () => navigator.vibrate(50); // @flag
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { haptics } from "akanjs/client/native";
|
|
2
|
+
import { useGeoLocation } from "akanjs/webkit";
|
|
3
|
+
export const buzz = () => haptics.impact(); // @ok
|
|
4
|
+
export const locate = () => useGeoLocation(); // @ok
|
|
5
|
+
export const languages = () => navigator.languages; // @ok
|
|
6
|
+
export const clipboard = (text: string) => navigator.clipboard.writeText(text); // @ok
|
|
7
|
+
export const aField = (payload: { share: boolean }) => payload.share; // @ok
|
|
8
|
+
export const aLiteral = "navigator.share is missing on Android"; // @ok
|
|
9
|
+
export const Docs = () => <p>navigator.share is missing on Android.</p>; // @ok
|
|
10
|
+
export const ModelZone = () => <Notification.Zone.Initialize />; // @ok
|
|
11
|
+
export const model = (cnst: { Notification: unknown }) => cnst.Notification; // @ok
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const readDraft = () => localStorage.getItem("draft"); // @flag
|
|
2
|
+
export const writeDraft = (value: string) => localStorage.setItem("draft", value); // @flag
|
|
3
|
+
export const readTab = () => sessionStorage.getItem("tab"); // @flag
|
|
4
|
+
export const throughWindow = () => window.localStorage.getItem("draft"); // @flag
|
|
5
|
+
export const throughGlobalThis = () => globalThis.sessionStorage.clear(); // @flag
|
|
6
|
+
export const guarded = () => (typeof localStorage === "undefined" ? null : 1); // @flag
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { secretStorage, storage } from "akanjs/client";
|
|
2
|
+
export const readDraft = () => storage.getItem("draft"); // @ok
|
|
3
|
+
export const keepToken = (jwt: string) => secretStorage.setItem("jwt", jwt); // @ok
|
|
4
|
+
export const aFieldWithTheName = (cache: { localStorage: string }) => cache.localStorage; // @ok
|
|
5
|
+
export const anObjectKey = { sessionStorage: false }; // @ok
|
|
6
|
+
export const aLiteral = "localStorage works in the shells"; // @ok
|
|
7
|
+
export const Docs = () => <p>window.localStorage and sessionStorage work in the shells.</p>; // @ok
|