@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.
Files changed (44) hide show
  1. package/README.ko.md +2 -2
  2. package/README.md +2 -2
  3. package/akanConfig/akanConfig.test.ts +58 -38
  4. package/akanConfig/akanConfig.ts +88 -48
  5. package/akanConfig/types.ts +3 -3
  6. package/akanContext.ts +5 -2
  7. package/artifact/implicitRootLayout.test.ts +23 -0
  8. package/artifact/implicitRootLayout.ts +24 -0
  9. package/biome.base.json +26 -0
  10. package/commandDecorators/command.ts +1 -1
  11. package/executors.ts +1 -12
  12. package/incrementalBuilder/buildBatch.proc.ts +7 -1
  13. package/index.ts +0 -2
  14. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  15. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  16. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  17. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  18. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  19. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  20. package/lint/no-document-cookie.grit +29 -0
  21. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  22. package/lint/no-web-storage.grit +30 -0
  23. package/mobile/index.ts +6 -0
  24. package/mobile/mobileTarget.ts +2 -0
  25. package/mobile/nativeApi.test.ts +30 -0
  26. package/mobile/nativeApi.ts +40 -0
  27. package/mobile/nativeApp.test.ts +85 -0
  28. package/mobile/nativeApp.ts +210 -0
  29. package/mobile/nativeConfig.test.ts +199 -0
  30. package/mobile/nativeConfig.ts +184 -0
  31. package/mobile/nativeWebDir.test.ts +80 -0
  32. package/mobile/nativeWebDir.ts +62 -0
  33. package/mobile/placeholderAppId.test.ts +13 -0
  34. package/mobile/placeholderAppId.ts +16 -0
  35. package/mobile/usageDescriptions.test.ts +27 -0
  36. package/mobile/usageDescriptions.ts +24 -0
  37. package/package.json +2 -7
  38. package/qualityScanner.ts +4 -2
  39. package/scanInfo.ts +5 -10
  40. package/workspaceLayout.test.ts +13 -3
  41. package/workspaceLayout.ts +16 -4
  42. package/applicationReleasePackager.ts +0 -189
  43. package/capacitorApp.test.ts +0 -388
  44. 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
- - Capacitor와 mobile release helper.
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
- - Optional peer는 Capacitor integration처럼 해당 기능을 사용할 때만 필요합니다.
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
- - Capacitor and mobile release helpers.
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
- - Optional peers are only needed for the features that use them, such as Capacitor integration.
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 Capacitor build ships that bundle");
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);
@@ -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 Capacitor build ships that bundle. Drop the mobile section or leave CSR on.`,
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
- plugins: {
284
- ...rawMobile.plugins,
285
- ...target.plugins,
286
- },
287
- android: {
288
- ...rawMobile.android,
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
- plugins: rawMobile.plugins,
307
- } as AkanMobileConfig;
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,
@@ -5,10 +5,11 @@ export type {
5
5
  AkanDatabaseConfig,
6
6
  AkanExecutor,
7
7
  AkanMobileConfig,
8
+ AkanMobileNativeConfig,
8
9
  AkanMobileTargetConfig,
9
- AkanNativeContext,
10
+ AkanNativeValue,
10
11
  AkanPlugin,
11
- AkanPluginCapacitorConfig,
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, @trapezedev/project, ssh2 and the cloud stack into every command process.
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, PluginRuntimeContext } from "akanjs";
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 next = await new PagesBundleBuilder(this.#app).build();
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