@akanjs/devkit 1.0.19 → 2.1.0-rc.0

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 (194) hide show
  1. package/aiEditor.ts +304 -0
  2. package/akanApp/akanApp.host.ts +393 -0
  3. package/akanApp/index.ts +1 -0
  4. package/akanConfig/akanConfig.test.ts +236 -0
  5. package/akanConfig/akanConfig.ts +384 -0
  6. package/akanConfig/index.ts +2 -0
  7. package/akanConfig/types.ts +23 -0
  8. package/applicationBuildReporter.ts +69 -0
  9. package/applicationBuildRunner.ts +302 -0
  10. package/applicationReleasePackager.ts +206 -0
  11. package/artifact/implicitRootLayout.ts +155 -0
  12. package/artifact/index.ts +1 -0
  13. package/artifact/routeSeedIndex.test.ts +98 -0
  14. package/artifact/routeSeedIndex.ts +130 -0
  15. package/auth.ts +41 -0
  16. package/builder.ts +164 -0
  17. package/capacitor.base.config.ts +88 -0
  18. package/capacitorApp.ts +440 -0
  19. package/commandDecorators/argMeta.ts +102 -0
  20. package/commandDecorators/command.ts +343 -0
  21. package/commandDecorators/commandBuilder.ts +224 -0
  22. package/commandDecorators/commandDecorators.test.ts +212 -0
  23. package/commandDecorators/commandMeta.ts +7 -0
  24. package/commandDecorators/dependencyBuilder.ts +100 -0
  25. package/{esm/src/commandDecorators/helpFormatter.js → commandDecorators/helpFormatter.ts} +100 -47
  26. package/{esm/src/commandDecorators/index.js → commandDecorators/index.ts} +4 -2
  27. package/commandDecorators/targetMeta.ts +31 -0
  28. package/commandDecorators/types.ts +10 -0
  29. package/constants.ts +25 -0
  30. package/createTunnel.ts +36 -0
  31. package/dependencyScanner.ts +357 -0
  32. package/devkitUtils.test.ts +259 -0
  33. package/executors.test.ts +315 -0
  34. package/executors.ts +1390 -0
  35. package/{esm/src/extractDeps.js → extractDeps.ts} +26 -20
  36. package/{esm/src/fileEditor.js → fileEditor.ts} +51 -32
  37. package/fileSys.ts +39 -0
  38. package/frontendBuild/allRoutesBuilder.ts +103 -0
  39. package/frontendBuild/buildRouteClient.test.ts +190 -0
  40. package/frontendBuild/clientBuildTypes.ts +114 -0
  41. package/frontendBuild/clientEntriesBundler.ts +303 -0
  42. package/frontendBuild/clientEntryDiscovery.ts +199 -0
  43. package/frontendBuild/csrArtifactBuilder.ts +237 -0
  44. package/frontendBuild/cssCompiler.ts +286 -0
  45. package/frontendBuild/cssImportResolver.ts +116 -0
  46. package/frontendBuild/fontOptimizer.ts +427 -0
  47. package/frontendBuild/frontendBuild.test.ts +204 -0
  48. package/frontendBuild/hmrChangeClassifier.ts +28 -0
  49. package/frontendBuild/hmrWatcher.ts +102 -0
  50. package/frontendBuild/index.ts +18 -0
  51. package/frontendBuild/pagesBundleBuilder.ts +137 -0
  52. package/frontendBuild/pagesEntrySourceGenerator.ts +37 -0
  53. package/frontendBuild/precompressArtifacts.ts +59 -0
  54. package/frontendBuild/routeClientBuilder.ts +290 -0
  55. package/frontendBuild/routesManifestArtifactSerializer.ts +62 -0
  56. package/frontendBuild/ssrBaseArtifactBuilder.ts +139 -0
  57. package/frontendBuild/vendorSpecifiers.ts +16 -0
  58. package/frontendBuild/watchRootResolver.ts +28 -0
  59. package/getCredentials.ts +19 -0
  60. package/getDirname.ts +3 -0
  61. package/getModelFileData.ts +59 -0
  62. package/getRelatedCnsts.ts +313 -0
  63. package/guideline.ts +19 -0
  64. package/incrementalBuilder/incrementalBuilder.host.test.ts +51 -0
  65. package/incrementalBuilder/incrementalBuilder.host.ts +152 -0
  66. package/incrementalBuilder/incrementalBuilder.proc.ts +331 -0
  67. package/incrementalBuilder/index.ts +1 -0
  68. package/{esm/src/index.js → index.ts} +28 -15
  69. package/lint/no-deep-internal-import.grit +25 -0
  70. package/lint/no-import-client-functions.grit +32 -0
  71. package/lint/no-import-external-library.grit +21 -0
  72. package/lint/no-js-private-class-method.grit +42 -0
  73. package/lint/no-use-client-in-server.grit +7 -0
  74. package/lint/non-scalar-props-restricted.grit +13 -0
  75. package/linter.ts +271 -0
  76. package/mobile/index.ts +1 -0
  77. package/mobile/mobileTarget.test.ts +53 -0
  78. package/mobile/mobileTarget.ts +88 -0
  79. package/package.json +48 -31
  80. package/prompter.ts +72 -0
  81. package/scanInfo.ts +606 -0
  82. package/selectModel.ts +11 -0
  83. package/{esm/src/spinner.js → spinner.ts} +22 -28
  84. package/{esm/src/capacitorApp.js → src/capacitorApp.ts} +82 -81
  85. package/sshTunnel.ts +152 -0
  86. package/{esm/src/streamAi.js → streamAi.ts} +18 -12
  87. package/transforms/barrelAnalyzer.ts +278 -0
  88. package/transforms/barrelImportsPlugin.ts +504 -0
  89. package/transforms/externalizeFrameworkPlugin.ts +185 -0
  90. package/transforms/index.ts +5 -0
  91. package/transforms/rscUseClientTransform.ts +59 -0
  92. package/transforms/transforms.test.ts +208 -0
  93. package/transforms/useClientBundlePlugin.ts +47 -0
  94. package/tsconfig.json +37 -0
  95. package/typeChecker.ts +264 -0
  96. package/types.ts +44 -0
  97. package/ui/MultiScrollList.tsx +242 -0
  98. package/ui/ScrollList.tsx +107 -0
  99. package/ui/index.ts +2 -0
  100. package/{esm/src/uploadRelease.js → uploadRelease.ts} +50 -34
  101. package/{esm/src/useStdoutDimensions.js → useStdoutDimensions.ts} +5 -5
  102. package/README.md +0 -1
  103. package/cjs/index.js +0 -21
  104. package/cjs/src/aiEditor.js +0 -311
  105. package/cjs/src/auth.js +0 -72
  106. package/cjs/src/builder.js +0 -114
  107. package/cjs/src/capacitorApp.js +0 -313
  108. package/cjs/src/commandDecorators/argMeta.js +0 -88
  109. package/cjs/src/commandDecorators/command.js +0 -324
  110. package/cjs/src/commandDecorators/commandMeta.js +0 -30
  111. package/cjs/src/commandDecorators/helpFormatter.js +0 -211
  112. package/cjs/src/commandDecorators/index.js +0 -31
  113. package/cjs/src/commandDecorators/targetMeta.js +0 -57
  114. package/cjs/src/commandDecorators/types.js +0 -15
  115. package/cjs/src/constants.js +0 -46
  116. package/cjs/src/createTunnel.js +0 -49
  117. package/cjs/src/dependencyScanner.js +0 -220
  118. package/cjs/src/executors.js +0 -964
  119. package/cjs/src/extractDeps.js +0 -103
  120. package/cjs/src/fileEditor.js +0 -120
  121. package/cjs/src/getCredentials.js +0 -44
  122. package/cjs/src/getDirname.js +0 -38
  123. package/cjs/src/getModelFileData.js +0 -66
  124. package/cjs/src/getRelatedCnsts.js +0 -260
  125. package/cjs/src/guideline.js +0 -15
  126. package/cjs/src/index.js +0 -65
  127. package/cjs/src/linter.js +0 -238
  128. package/cjs/src/prompter.js +0 -85
  129. package/cjs/src/scanInfo.js +0 -491
  130. package/cjs/src/selectModel.js +0 -46
  131. package/cjs/src/spinner.js +0 -93
  132. package/cjs/src/streamAi.js +0 -62
  133. package/cjs/src/typeChecker.js +0 -207
  134. package/cjs/src/types.js +0 -15
  135. package/cjs/src/uploadRelease.js +0 -112
  136. package/cjs/src/useStdoutDimensions.js +0 -43
  137. package/esm/index.js +0 -1
  138. package/esm/src/aiEditor.js +0 -282
  139. package/esm/src/auth.js +0 -42
  140. package/esm/src/builder.js +0 -81
  141. package/esm/src/commandDecorators/argMeta.js +0 -54
  142. package/esm/src/commandDecorators/command.js +0 -290
  143. package/esm/src/commandDecorators/commandMeta.js +0 -7
  144. package/esm/src/commandDecorators/targetMeta.js +0 -33
  145. package/esm/src/commandDecorators/types.js +0 -0
  146. package/esm/src/constants.js +0 -17
  147. package/esm/src/createTunnel.js +0 -26
  148. package/esm/src/dependencyScanner.js +0 -187
  149. package/esm/src/executors.js +0 -928
  150. package/esm/src/getCredentials.js +0 -11
  151. package/esm/src/getDirname.js +0 -5
  152. package/esm/src/getModelFileData.js +0 -33
  153. package/esm/src/getRelatedCnsts.js +0 -221
  154. package/esm/src/guideline.js +0 -0
  155. package/esm/src/linter.js +0 -205
  156. package/esm/src/prompter.js +0 -51
  157. package/esm/src/scanInfo.js +0 -455
  158. package/esm/src/selectModel.js +0 -13
  159. package/esm/src/typeChecker.js +0 -174
  160. package/esm/src/types.js +0 -0
  161. package/index.d.ts +0 -1
  162. package/src/aiEditor.d.ts +0 -50
  163. package/src/auth.d.ts +0 -9
  164. package/src/builder.d.ts +0 -18
  165. package/src/capacitorApp.d.ts +0 -39
  166. package/src/commandDecorators/argMeta.d.ts +0 -67
  167. package/src/commandDecorators/command.d.ts +0 -2
  168. package/src/commandDecorators/commandMeta.d.ts +0 -2
  169. package/src/commandDecorators/helpFormatter.d.ts +0 -3
  170. package/src/commandDecorators/index.d.ts +0 -6
  171. package/src/commandDecorators/targetMeta.d.ts +0 -19
  172. package/src/commandDecorators/types.d.ts +0 -1
  173. package/src/constants.d.ts +0 -26
  174. package/src/createTunnel.d.ts +0 -8
  175. package/src/dependencyScanner.d.ts +0 -23
  176. package/src/executors.d.ts +0 -296
  177. package/src/extractDeps.d.ts +0 -7
  178. package/src/fileEditor.d.ts +0 -16
  179. package/src/getCredentials.d.ts +0 -12
  180. package/src/getDirname.d.ts +0 -1
  181. package/src/getModelFileData.d.ts +0 -16
  182. package/src/getRelatedCnsts.d.ts +0 -53
  183. package/src/guideline.d.ts +0 -19
  184. package/src/index.d.ts +0 -23
  185. package/src/linter.d.ts +0 -109
  186. package/src/prompter.d.ts +0 -14
  187. package/src/scanInfo.d.ts +0 -82
  188. package/src/selectModel.d.ts +0 -1
  189. package/src/spinner.d.ts +0 -20
  190. package/src/streamAi.d.ts +0 -6
  191. package/src/typeChecker.d.ts +0 -52
  192. package/src/types.d.ts +0 -31
  193. package/src/uploadRelease.d.ts +0 -10
  194. package/src/useStdoutDimensions.d.ts +0 -1
@@ -0,0 +1,155 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { App } from "../commandDecorators";
4
+
5
+ export interface PageEntry {
6
+ key: string;
7
+ moduleAbsPath: string;
8
+ seedAbsPaths?: string[];
9
+ }
10
+
11
+ const LAYOUT_KEY_RE = /^\.\/(.+\/)?_layout\.(tsx|ts|jsx|js)$/;
12
+
13
+ async function appHasStModule(appCwdPath: string): Promise<boolean> {
14
+ return Bun.file(path.join(appCwdPath, "lib", "st.ts")).exists();
15
+ }
16
+
17
+ const IMPLICIT_LAYOUT_DIR = path.join(".akan", "generated", "root-layouts");
18
+
19
+ interface RootBoundary {
20
+ sourceKey: string | null;
21
+ sourceAbsPath: string | null;
22
+ segments: string[];
23
+ }
24
+
25
+ function getRootBoundarySegments(key: string): string[] | null {
26
+ const match = LAYOUT_KEY_RE.exec(key);
27
+ if (!match) return null;
28
+ const prefix = match[1]?.replace(/\/$/, "");
29
+ if (!prefix) return [];
30
+ return prefix.split("/").filter(Boolean);
31
+ }
32
+
33
+ function implicitRootLayoutKey(segments: string[]): string {
34
+ return `./${[...segments, "__root_layout"].join("/")}.tsx`;
35
+ }
36
+
37
+ function implicitRootLayoutAbsPath(appCwdPath: string, segments: string[]): string {
38
+ const filename = segments.length ? `${segments.join("__")}__root_layout.tsx` : "__root_layout.tsx";
39
+ return path.join(path.resolve(appCwdPath), IMPLICIT_LAYOUT_DIR, filename);
40
+ }
41
+
42
+ function isRootBoundarySegments(segments: string[], basePaths: Iterable<string>): boolean {
43
+ const firstVisibleIndex = segments.findIndex((segment) => !/^\(.+\)$/.test(segment));
44
+ if (firstVisibleIndex === -1) return segments.length <= 1;
45
+ if (segments.slice(firstVisibleIndex + 1).some((segment) => /^\(.+\)$/.test(segment))) return false;
46
+ const visible = segments.slice(firstVisibleIndex);
47
+ const allowedBasePaths = new Set([...basePaths].map((basePath) => basePath.trim()).filter(Boolean));
48
+ return visible.length === 1 && (firstVisibleIndex > 0 || allowedBasePaths.has(visible[0] ?? ""));
49
+ }
50
+
51
+ function findRootBoundaries(pageKeys: string[], appCwdPath: string, basePaths: Iterable<string>): RootBoundary[] {
52
+ const boundaries = new Map<string, RootBoundary>();
53
+ for (const key of pageKeys) {
54
+ const segments = getRootBoundarySegments(key);
55
+ if (!segments) continue;
56
+ if (!isRootBoundarySegments(segments, basePaths)) continue;
57
+ const id = segments.join("/");
58
+ boundaries.set(id, {
59
+ sourceKey: key,
60
+ sourceAbsPath: path.resolve(appCwdPath, "page", key.replace(/^\.\//, "")),
61
+ segments,
62
+ });
63
+ }
64
+ const hasExplicitRootBoundary = [...boundaries.values()].some((boundary) => boundary.segments.length === 0);
65
+ if (!hasExplicitRootBoundary && boundaries.size === 0) {
66
+ boundaries.set("", { sourceKey: null, sourceAbsPath: null, segments: [] });
67
+ }
68
+ return [...boundaries.values()].sort((a, b) => a.segments.join("/").localeCompare(b.segments.join("/")));
69
+ }
70
+
71
+ function routePrefixForSegments(segments: string[]): string | null {
72
+ const visible = segments.filter((segment) => !/^\(.+\)$/.test(segment));
73
+ return visible[0] ?? null;
74
+ }
75
+
76
+ async function assertEnvClientConvention(appCwdPath: string, appName: string) {
77
+ const envPath = path.join(appCwdPath, "env", "env.client.ts");
78
+ if (!(await Bun.file(envPath).exists())) {
79
+ throw new Error(
80
+ `[route-convention] app "${appName}" must provide env/env.client.ts exporting "env" for generated System.Provider`,
81
+ );
82
+ }
83
+ }
84
+
85
+ async function writeGeneratedRootLayoutFile(opts: {
86
+ appCwdPath: string;
87
+ appName: string;
88
+ boundary: RootBoundary;
89
+ includeStInit: boolean;
90
+ }): Promise<string> {
91
+ await assertEnvClientConvention(opts.appCwdPath, opts.appName);
92
+ const absPath = implicitRootLayoutAbsPath(opts.appCwdPath, opts.boundary.segments);
93
+ await mkdir(path.dirname(absPath), { recursive: true });
94
+ const sourceRel = opts.boundary.sourceAbsPath
95
+ ? path.relative(path.dirname(absPath), opts.boundary.sourceAbsPath).split(path.sep).join("/")
96
+ : null;
97
+ const sourceSpecifier = sourceRel ? (sourceRel.startsWith(".") ? sourceRel : `./${sourceRel}`) : null;
98
+ const clientImport = opts.includeStInit
99
+ ? `import { st } from "@apps/${opts.appName}/client";\nvoid st;\n`
100
+ : `import "@apps/${opts.appName}/client";\n`;
101
+ const prefix = routePrefixForSegments(opts.boundary.segments);
102
+ const userImport = sourceSpecifier
103
+ ? `import UserLayout, * as userLayout from ${JSON.stringify(sourceSpecifier)};\n`
104
+ : "const UserLayout = ({ children }) => children;\nconst userLayout = {};\n";
105
+ const source = `import type { LayoutProps, PageProps } from "akanjs/client";\nimport { loadFonts } from "akanjs/client";\nimport { System } from "akanjs/ui";\nimport { env } from "@apps/${opts.appName}/env/env.client";\n${clientImport}${userImport}\nconst userFonts = userLayout.fonts ?? [];\nconst defaultFonts = userFonts.filter((font) => font.default);\nif (defaultFonts.length > 1) throw new Error("[route-convention] only one default font is allowed per root layout");\nconst defaultFont = defaultFonts[0];\nconst defaultFontClassName = defaultFont ? (defaultFont.className ?? \`font-\${defaultFont.name}\`) : undefined;\n\nexport async function generateHead(props: PageProps) {\n if (userLayout.generateHead) return userLayout.generateHead(props);\n return userLayout.head;\n}\n\nexport default function GeneratedLayout({ children, params, searchParams }: LayoutProps) {\n return (\n <System.Provider\n of={GeneratedLayout as never}\n appName=${JSON.stringify(opts.appName)}\n ${prefix ? `prefix=${JSON.stringify(prefix)}\n ` : ""}params={params}\n manifest={userLayout.manifest}\n env={env}\n theme={userLayout.theme}\n fonts={loadFonts(userFonts)}\n className={defaultFontClassName}\n gaTrackingId={userLayout.gaTrackingId}\n layoutStyle={userLayout.layoutStyle}\n reconnect={userLayout.reconnect ?? false}\n >\n <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>\n </System.Provider>\n );\n}\n`;
106
+ await Bun.write(absPath, source);
107
+ return absPath;
108
+ }
109
+
110
+ /**
111
+ * When no root `page/_layout.*` exists on disk, merge a generated implicit root layout
112
+ * (with generated client runtime registration and optional `void st` when `lib/st.ts` exists).
113
+ */
114
+ export async function resolveSsrPageEntries(opts: {
115
+ appCwdPath: string;
116
+ appName: string;
117
+ pageKeys: string[];
118
+ basePaths?: Iterable<string>;
119
+ }): Promise<PageEntry[]> {
120
+ const absPageDir = path.resolve(opts.appCwdPath, "page");
121
+ const hasSt = await appHasStModule(opts.appCwdPath);
122
+ const basePaths = opts.basePaths ?? [];
123
+ const rootLayoutKeys = new Set(
124
+ opts.pageKeys.filter((key) => {
125
+ const segments = getRootBoundarySegments(key);
126
+ return segments !== null && isRootBoundarySegments(segments, basePaths);
127
+ }),
128
+ );
129
+ const base = opts.pageKeys
130
+ .filter((key) => !rootLayoutKeys.has(key))
131
+ .map((key) => ({
132
+ key,
133
+ moduleAbsPath: path.resolve(absPageDir, key),
134
+ }));
135
+ const generated = await Promise.all(
136
+ findRootBoundaries(opts.pageKeys, opts.appCwdPath, basePaths).map(async (boundary) => ({
137
+ key: implicitRootLayoutKey(boundary.segments),
138
+ moduleAbsPath: await writeGeneratedRootLayoutFile({
139
+ appCwdPath: opts.appCwdPath,
140
+ appName: opts.appName,
141
+ boundary,
142
+ includeStInit: hasSt && boundary.segments.length === 0,
143
+ }),
144
+ seedAbsPaths: boundary.sourceAbsPath ? [boundary.sourceAbsPath] : [],
145
+ })),
146
+ );
147
+ const entries = [...base, ...generated];
148
+ entries.sort((a, b) => a.key.localeCompare(b.key));
149
+ return entries;
150
+ }
151
+
152
+ export async function resolveSsrPageEntriesForApp(app: App, pageKeys: string[]): Promise<PageEntry[]> {
153
+ const config = await app.getConfig();
154
+ return resolveSsrPageEntries({ appCwdPath: app.cwdPath, appName: app.name, pageKeys, basePaths: config.basePaths });
155
+ }
@@ -0,0 +1 @@
1
+ export * from "./routeSeedIndex";
@@ -0,0 +1,98 @@
1
+ import { computeRouteSeedIndex, serializeRouteSeedIndexForArtifact } from "./routeSeedIndex";
2
+
3
+ describe("computeRouteSeedIndex", () => {
4
+ test("uses layouts as route client seeds and skips sibling leaf layout ownership", () => {
5
+ const index = computeRouteSeedIndex([
6
+ {
7
+ key: "./__root_layout.tsx",
8
+ moduleAbsPath: "/app/.akan/generated/root-layouts/__root_layout.tsx",
9
+ seedAbsPaths: ["/app/page/_layout.tsx"],
10
+ },
11
+ {
12
+ key: "./foo/__root_layout.tsx",
13
+ moduleAbsPath: "/app/.akan/generated/root-layouts/foo__root_layout.tsx",
14
+ seedAbsPaths: ["/app/page/foo/_layout.tsx"],
15
+ },
16
+ { key: "./foo.tsx", moduleAbsPath: "/app/page/foo.tsx" },
17
+ { key: "./foo/bar.tsx", moduleAbsPath: "/app/page/foo/bar.tsx" },
18
+ ]);
19
+
20
+ const leaf = index.entries.find((entry) => entry.routeId === "/:lang/foo");
21
+ expect(leaf?.seeds).toContain("/app/page/foo.tsx");
22
+ expect(leaf?.seeds).not.toContain("/app/page/foo/_layout.tsx");
23
+ expect(leaf?.seeds).toContain("/app/.akan/generated/root-layouts/__root_layout.tsx");
24
+ expect(leaf?.seeds).toContain("/app/page/_layout.tsx");
25
+
26
+ expect(() =>
27
+ computeRouteSeedIndex([
28
+ { key: "./foo.tsx", moduleAbsPath: "/app/page/foo.tsx" },
29
+ { key: "./foo/_index.tsx", moduleAbsPath: "/app/page/foo/_index.tsx" },
30
+ ]),
31
+ ).toThrow("route conflict");
32
+ });
33
+
34
+ test("keeps special route seeds outside implicit locale", () => {
35
+ const index = computeRouteSeedIndex([
36
+ {
37
+ key: "./__root_layout.tsx",
38
+ moduleAbsPath: "/app/.akan/generated/root-layouts/__root_layout.tsx",
39
+ seedAbsPaths: ["/app/page/_layout.tsx"],
40
+ },
41
+ { key: "./robots.txt.tsx", moduleAbsPath: "/app/page/robots.txt.tsx" },
42
+ ]);
43
+ const robots = index.entries.find((entry) => entry.routeId === "/robots.txt");
44
+ expect(robots?.seeds).not.toContain("/app/.akan/generated/root-layouts/__root_layout.tsx");
45
+ expect(robots?.seeds).not.toContain("/app/page/_layout.tsx");
46
+ });
47
+
48
+ test("serializes seed paths relative to the artifact directory", () => {
49
+ const artifactDir = "/repo/dist/apps/angelo/.akan/artifact";
50
+ const serialized = serializeRouteSeedIndexForArtifact(
51
+ {
52
+ entries: [
53
+ {
54
+ routeId: "/profile",
55
+ pattern: "/profile",
56
+ seeds: [
57
+ "/repo/dist/apps/angelo/.akan/generated/implicit-root-layout.tsx",
58
+ "/repo/apps/angelo/page/profile.tsx",
59
+ ],
60
+ },
61
+ ],
62
+ globalLayoutFiles: ["/repo/dist/apps/angelo/.akan/generated/implicit-root-layout.tsx"],
63
+ },
64
+ artifactDir,
65
+ );
66
+
67
+ expect(serialized.entries[0]?.seeds).toEqual([
68
+ "../generated/implicit-root-layout.tsx",
69
+ "../../../../../apps/angelo/page/profile.tsx",
70
+ ]);
71
+ expect(serialized.globalLayoutFiles).toEqual(["../generated/implicit-root-layout.tsx"]);
72
+ expect(JSON.stringify(serialized)).not.toContain("/repo/");
73
+ });
74
+
75
+ test("omits source seed metadata for production artifacts", () => {
76
+ const artifactDir = "/repo/dist/apps/angelo/.akan/artifact";
77
+ const serialized = serializeRouteSeedIndexForArtifact(
78
+ {
79
+ entries: [
80
+ {
81
+ routeId: "/:lang/profile",
82
+ pattern: "/:lang/profile",
83
+ seeds: ["/repo/apps/angelo/page/profile.tsx"],
84
+ },
85
+ ],
86
+ globalLayoutFiles: ["/repo/apps/angelo/.akan/generated/implicit-root-layout.tsx"],
87
+ },
88
+ artifactDir,
89
+ { production: true },
90
+ );
91
+
92
+ expect(serialized).toEqual({ entries: [{ routeId: "/:lang/profile" }] });
93
+ expect(JSON.stringify(serialized)).not.toContain("seeds");
94
+ expect(JSON.stringify(serialized)).not.toContain("pattern");
95
+ expect(JSON.stringify(serialized)).not.toContain("globalLayoutFiles");
96
+ expect(JSON.stringify(serialized)).not.toContain("/repo/");
97
+ });
98
+ });
@@ -0,0 +1,130 @@
1
+ import path from "node:path";
2
+ import { assertUniqueRoutePatterns, compareRouteSpecificity, parseRouteModuleKey } from "akanjs/common";
3
+ import type { PageEntry } from "./implicitRootLayout";
4
+
5
+ /**
6
+ * Build-time utilities for the route seed index. `computeRouteSeedIndex`
7
+ * takes resolved `PageEntry` rows (`key` plus absolute module path — files
8
+ * under `app/` or the generated implicit root layout) and produces — for every
9
+ * route — the list of source files that seed the `"use client"` graph walk.
10
+ *
11
+ * The result is serialized to `route-seed-index.json` by
12
+ * `saveRouteSeedIndex` so the runtime server can call
13
+ * `loadRouteSeedIndex` to restore it without re-importing `pages.ts`
14
+ * (which no longer exists) or reparsing loader sources.
15
+ */
16
+
17
+ export interface RouteSeedEntry {
18
+ routeId: string;
19
+ pattern: string;
20
+ seeds: string[];
21
+ }
22
+
23
+ export interface RouteSeedIndex {
24
+ entries: RouteSeedEntry[];
25
+ globalLayoutFiles: string[];
26
+ }
27
+
28
+ export type SerializedRouteSeedEntry = Pick<RouteSeedEntry, "routeId"> &
29
+ Partial<Pick<RouteSeedEntry, "pattern" | "seeds">>;
30
+
31
+ export interface SerializedRouteSeedIndex {
32
+ entries: SerializedRouteSeedEntry[];
33
+ globalLayoutFiles?: string[];
34
+ }
35
+
36
+ /**
37
+ * Compute the route seed index from `PageEntry`s (disk paths or generated
38
+ * implicit root layout absolute paths).
39
+ */
40
+ export function computeRouteSeedIndex(pageEntries: PageEntry[]): RouteSeedIndex {
41
+ const layoutsByPrefix = new Map<string, string[]>();
42
+ const pagesBySegments: Array<{
43
+ key: string;
44
+ pattern: string;
45
+ segments: string[];
46
+ files: string[];
47
+ includeOwnLayout: boolean;
48
+ }> = [];
49
+
50
+ for (const { key, moduleAbsPath, seedAbsPaths } of pageEntries) {
51
+ const parsed = parseRouteModuleKey(key);
52
+ const files = [path.resolve(moduleAbsPath), ...(seedAbsPaths ?? []).map((seed) => path.resolve(seed))];
53
+ if (parsed.kind === "layout") {
54
+ const prefix = parsed.routeSegments.join("/");
55
+ const prev = layoutsByPrefix.get(prefix) ?? [];
56
+ layoutsByPrefix.set(prefix, [...prev, ...files]);
57
+ } else if (parsed.kind === "page") {
58
+ pagesBySegments.push({
59
+ key,
60
+ pattern: parsed.pattern,
61
+ segments: parsed.routeSegments,
62
+ files,
63
+ includeOwnLayout: parsed.leaf === "_index",
64
+ });
65
+ }
66
+ }
67
+ assertUniqueRoutePatterns(pagesBySegments);
68
+
69
+ const rootLayouts = layoutsByPrefix.get("") ?? [];
70
+ const globalLayoutFiles = rootLayouts;
71
+
72
+ const seedEntries: RouteSeedEntry[] = [];
73
+ for (const { pattern, segments, files, includeOwnLayout } of pagesBySegments) {
74
+ const layouts: string[] = [];
75
+ const maxPrefixLength = includeOwnLayout ? segments.length : Math.max(segments.length - 1, 0);
76
+ for (let i = 0; i <= maxPrefixLength; i++) {
77
+ const prefix = segments.slice(0, i).join("/");
78
+ const layoutFiles = layoutsByPrefix.get(prefix);
79
+ if (layoutFiles) layouts.push(...layoutFiles);
80
+ }
81
+ const seeds = Array.from(new Set([...layouts, ...files]));
82
+ const routeId = pattern || "/";
83
+ seedEntries.push({ routeId, pattern: routeId, seeds });
84
+ }
85
+ // Static segments must beat `:param` ones at match time, otherwise
86
+ // `/persona/new` resolves to `[personaId]=new`. Sort once at build time
87
+ // so the runtime matcher can stay first-match-wins.
88
+ seedEntries.sort((a, b) => compareRouteSpecificity(a.pattern, b.pattern));
89
+ return { entries: seedEntries, globalLayoutFiles };
90
+ }
91
+
92
+ export const ROUTE_SEED_INDEX_JSON = "route-seed-index.json";
93
+
94
+ export function serializeRouteSeedIndexForArtifact(
95
+ index: RouteSeedIndex,
96
+ artifactDir: string,
97
+ options: { production?: boolean } = {},
98
+ ): SerializedRouteSeedIndex {
99
+ const normalizedArtifactDir = path.resolve(artifactDir);
100
+ if (options.production) {
101
+ return {
102
+ entries: index.entries.map((entry) => ({ routeId: entry.routeId })),
103
+ };
104
+ }
105
+ return {
106
+ entries: index.entries.map((entry) => ({
107
+ ...entry,
108
+ seeds: entry.seeds.map((seed) => serializeArtifactPath(seed, normalizedArtifactDir)),
109
+ })),
110
+ globalLayoutFiles: index.globalLayoutFiles.map((file) => serializeArtifactPath(file, normalizedArtifactDir)),
111
+ };
112
+ }
113
+
114
+ export async function saveRouteSeedIndex(
115
+ artifactDir: string,
116
+ index: RouteSeedIndex,
117
+ options: { production?: boolean } = {},
118
+ ): Promise<string> {
119
+ const absPath = path.join(path.resolve(artifactDir), ROUTE_SEED_INDEX_JSON);
120
+ await Bun.write(
121
+ absPath,
122
+ `${JSON.stringify(serializeRouteSeedIndexForArtifact(index, artifactDir, options), null, 2)}\n`,
123
+ );
124
+ return absPath;
125
+ }
126
+
127
+ function serializeArtifactPath(artifactPath: string, artifactDir: string): string {
128
+ if (!path.isAbsolute(artifactPath)) return artifactPath;
129
+ return path.relative(artifactDir, artifactPath).split(path.sep).join("/");
130
+ }
package/auth.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { mkdir } from "node:fs/promises";
2
+
3
+ import {
4
+ type AkanGlobalConfig,
5
+ akanCloudHost,
6
+ akanCloudUrl,
7
+ basePath,
8
+ configPath,
9
+ defaultAkanGlobalConfig,
10
+ defaultHostConfig,
11
+ type HostConfig,
12
+ } from "./constants";
13
+ import { FileSys } from "./fileSys";
14
+
15
+ export const getAkanGlobalConfig = async () => {
16
+ const exists = await FileSys.fileExists(configPath);
17
+ const akanConfig = exists ? await FileSys.readJson<AkanGlobalConfig>(configPath) : defaultAkanGlobalConfig;
18
+ return akanConfig;
19
+ };
20
+ export const setAkanGlobalConfig = async (akanConfig: AkanGlobalConfig) => {
21
+ await mkdir(basePath, { recursive: true });
22
+ await Bun.write(configPath, JSON.stringify(akanConfig, null, 2));
23
+ };
24
+ export const getHostConfig = async (host = akanCloudHost) => {
25
+ const akanConfig = await getAkanGlobalConfig();
26
+ return akanConfig.cloudHost[host] ?? defaultHostConfig;
27
+ };
28
+ export const setHostConfig = async (host = akanCloudHost, config: HostConfig = {}) => {
29
+ const akanConfig = await getAkanGlobalConfig();
30
+ akanConfig.cloudHost[host] = config;
31
+ await setAkanGlobalConfig(akanConfig);
32
+ };
33
+ export const getSelf = async (token: string) => {
34
+ try {
35
+ const res = await fetch(`${akanCloudUrl}/user/getSelf`, { headers: { Authorization: `Bearer ${token}` } });
36
+ const user = (await res.json()) as { id: string; nickname: string };
37
+ return user;
38
+ } catch (e) {
39
+ return null;
40
+ }
41
+ };
package/builder.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { Loader } from "bun";
5
+ import type { Executor } from "./executors";
6
+ import { FileSys } from "./fileSys";
7
+ import type { PackageJson } from "./types";
8
+
9
+ /** Relative paths under these directories are not used as Bun.build entrypoints (Bun does not expand `**` in entrypoint strings; we scan with {@link Bun.Glob}). */
10
+ const SKIP_ENTRY_DIR_SET = new Set(["node_modules", "dist", "build", ".git", ".next"]);
11
+
12
+ const assetExtensions = [".css", ".md", ".js", ".png", ".ico", ".svg", ".json", ".template"];
13
+ const assetLoader = Object.fromEntries(assetExtensions.map((ext) => [ext, "file" as const])) satisfies Record<
14
+ string,
15
+ Loader
16
+ >;
17
+
18
+ type BunBuildConfig = Parameters<typeof Bun.build>[0] & { bundle?: boolean };
19
+ type ExportValue = string | string[] | { [condition: string]: ExportValue };
20
+
21
+ interface BuildOptions {
22
+ bundle?: boolean;
23
+ additionalEntryPoints?: string[];
24
+ }
25
+
26
+ interface BuilderOptions {
27
+ executor: Executor;
28
+ distExecutor: Executor;
29
+ pkgJson: PackageJson;
30
+ rootPackageJson: PackageJson;
31
+ }
32
+ export class Builder {
33
+ #executor: Executor;
34
+ #distExecutor: Executor;
35
+ #pkgJson: PackageJson;
36
+
37
+ constructor({ executor, distExecutor, pkgJson }: BuilderOptions) {
38
+ this.#executor = executor;
39
+ this.#distExecutor = distExecutor;
40
+ this.#pkgJson = pkgJson;
41
+ }
42
+ #globEntrypoints(cwd: string, pattern: string): string[] {
43
+ const glob = new Bun.Glob(pattern);
44
+ return Array.from(glob.scanSync({ cwd, onlyFiles: true }))
45
+ .filter((relativePath) => {
46
+ const segments = relativePath.split(path.sep);
47
+ return !segments.some((segment) => SKIP_ENTRY_DIR_SET.has(segment));
48
+ })
49
+ .map((rel) => path.join(cwd, rel));
50
+ }
51
+ #globFiles(cwd: string, pattern = "**/*.*"): string[] {
52
+ const glob = new Bun.Glob(pattern);
53
+ return Array.from(glob.scanSync({ cwd, onlyFiles: true })).filter((relativePath) => {
54
+ const segments = relativePath.split(path.sep);
55
+ return !segments.some((segment) => SKIP_ENTRY_DIR_SET.has(segment));
56
+ });
57
+ }
58
+
59
+ #resolveAdditionalEntrypoints(cwd: string, additionalEntryPoints: string[]): string[] {
60
+ const out: string[] = [];
61
+ for (const p of additionalEntryPoints) {
62
+ if (p.includes("*")) {
63
+ const rel = p.startsWith(`${cwd}/`) || p.startsWith(`${cwd}${path.sep}`) ? p.slice(cwd.length + 1) : p;
64
+ out.push(...this.#globEntrypoints(cwd, rel));
65
+ } else out.push(path.isAbsolute(p) ? p : path.join(cwd, p));
66
+ }
67
+ return out;
68
+ }
69
+ #getBuildOptions({ bundle = false, additionalEntryPoints = [] }: BuildOptions = {}): BunBuildConfig {
70
+ const cwd = this.#executor.cwdPath;
71
+ const entrypoints = [
72
+ ...(bundle ? [path.join(cwd, "index.ts")] : this.#globEntrypoints(cwd, "**/*.{ts,tsx}")),
73
+ ...this.#resolveAdditionalEntrypoints(cwd, additionalEntryPoints),
74
+ ];
75
+ return {
76
+ root: cwd,
77
+ entrypoints,
78
+ bundle,
79
+ packages: "external",
80
+ splitting: false,
81
+ target: this.#pkgJson.bun?.platform,
82
+ format: "esm",
83
+ outdir: this.#distExecutor.cwdPath,
84
+ external: ["react", "react-dom"],
85
+ loader: assetLoader,
86
+ };
87
+ }
88
+ async #copySourceFiles() {
89
+ const cwd = this.#executor.cwdPath;
90
+ for (const relativePath of this.#globFiles(cwd)) {
91
+ if (relativePath === "package.json") continue;
92
+ const sourcePath = path.join(cwd, relativePath);
93
+ const targetPath = path.join(this.#distExecutor.cwdPath, relativePath);
94
+ await mkdir(path.dirname(targetPath), { recursive: true });
95
+ await Bun.write(targetPath, Bun.file(sourcePath));
96
+ }
97
+ }
98
+ #toPublishedPath(publishedPath: string, { useSource }: { useSource: boolean }) {
99
+ const hasDotSlash = publishedPath.startsWith("./");
100
+ const withoutFormatDir = publishedPath.replace(/^(?:\.\/)?(?:esm|cjs)\//, hasDotSlash ? "./" : "");
101
+ if (!useSource) return withoutFormatDir;
102
+ if (!hasDotSlash && withoutFormatDir === publishedPath) return publishedPath;
103
+ const parsed = path.posix.parse(withoutFormatDir);
104
+ if (![".js", ".mjs", ".cjs"].includes(parsed.ext)) return withoutFormatDir;
105
+ const withoutExt = path.posix.join(parsed.dir, parsed.name);
106
+ const sourcePath = withoutExt.startsWith("./") ? withoutExt.slice(2) : withoutExt;
107
+ const sourceCandidates = [`${sourcePath}.ts`, `${sourcePath}.tsx`];
108
+ const matchedSource = sourceCandidates.find((candidate) =>
109
+ existsSync(path.join(this.#executor.cwdPath, candidate)),
110
+ );
111
+ if (!matchedSource) return withoutFormatDir;
112
+ return hasDotSlash ? `./${matchedSource}` : matchedSource;
113
+ }
114
+ #normalizeExports(
115
+ exportsValue: ExportValue | undefined,
116
+ { useSource }: { useSource: boolean },
117
+ ): ExportValue | undefined {
118
+ if (!exportsValue) return exportsValue;
119
+ if (typeof exportsValue === "string") return this.#toPublishedPath(exportsValue, { useSource });
120
+ if (Array.isArray(exportsValue))
121
+ return exportsValue.map((value) => this.#normalizeExports(value, { useSource }) as string);
122
+ if (typeof exportsValue !== "object") return exportsValue;
123
+
124
+ return Object.fromEntries(
125
+ Object.entries(exportsValue)
126
+ .filter(([condition]) => condition !== "require")
127
+ .map(([condition, value]) => [condition, this.#normalizeExports(value, { useSource })])
128
+ .filter((entry): entry is [string, ExportValue] => entry[1] !== undefined),
129
+ );
130
+ }
131
+ #getPackageJson({ bundle = false }: BuildOptions = {}): PackageJson {
132
+ const rootEntry = bundle ? "./index.js" : "./index.ts";
133
+ const normalizedExports = this.#normalizeExports(this.#pkgJson.exports as ExportValue | undefined, {
134
+ useSource: !bundle,
135
+ });
136
+ return {
137
+ ...this.#pkgJson,
138
+ type: "module",
139
+ main: rootEntry,
140
+ bin: this.#normalizeExports(this.#pkgJson.bin as ExportValue | undefined, { useSource: !bundle }),
141
+ exports: {
142
+ ...((typeof normalizedExports === "object" && !Array.isArray(normalizedExports)
143
+ ? normalizedExports
144
+ : {}) as Record<string, ExportValue>),
145
+ ".": {
146
+ import: rootEntry,
147
+ types: bundle ? "./index.d.ts" : "./index.ts",
148
+ },
149
+ },
150
+ };
151
+ }
152
+ async build(options: BuildOptions = {}) {
153
+ if (await FileSys.dirExists(this.#distExecutor.cwdPath))
154
+ await this.#distExecutor.exec(`rm -rf ${this.#distExecutor.cwdPath}`);
155
+
156
+ if (options.bundle) {
157
+ const buildResult = await Bun.build({ ...this.#getBuildOptions(options) });
158
+ if (!buildResult.success) throw new AggregateError(buildResult.logs, "Bundle failed");
159
+ } else await this.#copySourceFiles();
160
+
161
+ const pkgPackageJson = this.#getPackageJson(options);
162
+ await this.#distExecutor.setPackageJson(pkgPackageJson);
163
+ }
164
+ }
@@ -0,0 +1,88 @@
1
+ import os from "node:os";
2
+ import type { CapacitorConfig } from "@capacitor/cli";
3
+ import type { AkanMobileTargetConfig, AppScanResult } from "akanjs";
4
+
5
+ const getLocalIP = () => {
6
+ const interfaces = os.networkInterfaces();
7
+ for (const interfaceName in interfaces) {
8
+ const iface = interfaces[interfaceName];
9
+ if (!iface) continue;
10
+ for (const alias of iface) {
11
+ if (alias.family === "IPv4" && !alias.internal) return alias.address;
12
+ }
13
+ }
14
+ return "127.0.0.1"; // fallback to localhost if no suitable IP found
15
+ };
16
+
17
+ const normalizeBasePath = (basePath: string | undefined) => basePath?.replace(/^\/+|\/+$/g, "");
18
+
19
+ const routeBasePaths = (appInfo: AppScanResult) =>
20
+ new Set(
21
+ appInfo.routes
22
+ .map((route) => route.replace(/^\.\//, "").split("/")[0])
23
+ .filter((segment): segment is string => !!segment && !segment.startsWith("_") && !segment.startsWith("(")),
24
+ );
25
+
26
+ const resolveTarget = (appInfo: AppScanResult, targetName = process.env.AKAN_MOBILE_TARGET) => {
27
+ const targets = appInfo.akanConfig.mobile.targets;
28
+ if (!targets || Object.keys(targets).length === 0) throw new Error("Akan mobile target metadata is missing.");
29
+ if (targetName) {
30
+ const target = targets[targetName];
31
+ if (!target) {
32
+ const basePath = normalizeBasePath(targetName);
33
+ const [template] = Object.values(targets);
34
+ if (basePath && template && routeBasePaths(appInfo).has(basePath))
35
+ return { ...template, name: basePath, basePath };
36
+ throw new Error(`Akan mobile target '${targetName}' was not found.`);
37
+ }
38
+ return target;
39
+ }
40
+ const entries = Object.entries(targets);
41
+ if (entries.length !== 1) throw new Error("AKAN_MOBILE_TARGET is required when multiple mobile targets exist.");
42
+ return entries[0]?.[1] as AkanMobileTargetConfig;
43
+ };
44
+
45
+ const localCsrUrl = (ip: string, target: AkanMobileTargetConfig) => {
46
+ const basePath = normalizeBasePath(target.basePath);
47
+ const port = process.env.AKAN_PUBLIC_CLIENT_PORT ?? process.env.PORT ?? "8282";
48
+ return `http://${ip}:${port}/${basePath ? `${basePath}` : ""}?csr=true`;
49
+ };
50
+
51
+ export const withBase = (
52
+ configImp: (config: CapacitorConfig, target: AkanMobileTargetConfig) => CapacitorConfig = (config) => config,
53
+ appData?: AppScanResult,
54
+ targetName?: string,
55
+ ) => {
56
+ const ip = getLocalIP();
57
+ const appInfo = appData;
58
+ if (!appInfo) throw new Error("withBase requires apps/<app>/akan.app.json metadata.");
59
+ const target = resolveTarget(appInfo, targetName);
60
+ const baseConfig: CapacitorConfig = {
61
+ ...target,
62
+ appId: target.appId,
63
+ appName: target.appName,
64
+ webDir: "dist",
65
+ server:
66
+ process.env.APP_OPERATION_MODE !== "release"
67
+ ? {
68
+ androidScheme: "http",
69
+ url: localCsrUrl(ip, target),
70
+ cleartext: true,
71
+ allowNavigation: [ip, "localhost"],
72
+ }
73
+ : {
74
+ allowNavigation: ["*"],
75
+ },
76
+ plugins: {
77
+ CapacitorCookies: { enabled: true },
78
+ ...target.plugins,
79
+ },
80
+ android: {
81
+ ...target.android,
82
+ },
83
+ ios: {
84
+ ...target.ios,
85
+ },
86
+ };
87
+ return configImp(baseConfig, target);
88
+ };