alepha 0.22.0 → 0.23.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 (111) hide show
  1. package/dist/api/jobs/index.d.ts +20 -20
  2. package/dist/api/jobs/index.d.ts.map +1 -1
  3. package/dist/api/keys/index.d.ts +6 -6
  4. package/dist/api/users/index.d.ts +43 -9
  5. package/dist/api/users/index.d.ts.map +1 -1
  6. package/dist/api/users/index.js +24 -3
  7. package/dist/api/users/index.js.map +1 -1
  8. package/dist/api/verifications/index.d.ts +13 -13
  9. package/dist/cli/core/index.d.ts +46 -40
  10. package/dist/cli/core/index.d.ts.map +1 -1
  11. package/dist/cli/core/index.js +51 -101
  12. package/dist/cli/core/index.js.map +1 -1
  13. package/dist/cli/i18n/index.d.ts +12 -5
  14. package/dist/cli/i18n/index.d.ts.map +1 -1
  15. package/dist/cli/i18n/index.js +45 -11
  16. package/dist/cli/i18n/index.js.map +1 -1
  17. package/dist/cli/platform-lib/index.d.ts +32 -6
  18. package/dist/cli/platform-lib/index.d.ts.map +1 -1
  19. package/dist/cli/platform-lib/index.js +82 -19
  20. package/dist/cli/platform-lib/index.js.map +1 -1
  21. package/dist/command/index.d.ts +1 -1
  22. package/dist/mcp/index.d.ts +9 -0
  23. package/dist/mcp/index.d.ts.map +1 -1
  24. package/dist/mcp/index.js +23 -0
  25. package/dist/mcp/index.js.map +1 -1
  26. package/dist/react/form/index.d.ts +0 -1
  27. package/dist/react/form/index.d.ts.map +1 -1
  28. package/dist/react/form/index.js +16 -15
  29. package/dist/react/form/index.js.map +1 -1
  30. package/dist/react/i18n/index.d.ts +43 -0
  31. package/dist/react/i18n/index.d.ts.map +1 -1
  32. package/dist/react/i18n/index.js +114 -10
  33. package/dist/react/i18n/index.js.map +1 -1
  34. package/dist/react/router/index.browser.js +128 -5
  35. package/dist/react/router/index.browser.js.map +1 -1
  36. package/dist/react/router/index.d.ts +108 -1
  37. package/dist/react/router/index.d.ts.map +1 -1
  38. package/dist/react/router/index.js +184 -6
  39. package/dist/react/router/index.js.map +1 -1
  40. package/dist/react/sitemap/index.browser.js +35 -0
  41. package/dist/react/sitemap/index.browser.js.map +1 -0
  42. package/dist/react/sitemap/index.d.ts +92 -0
  43. package/dist/react/sitemap/index.d.ts.map +1 -0
  44. package/dist/react/sitemap/index.js +131 -0
  45. package/dist/react/sitemap/index.js.map +1 -0
  46. package/dist/server/auth/index.d.ts +105 -1
  47. package/dist/server/auth/index.d.ts.map +1 -1
  48. package/dist/server/auth/index.js +1604 -7
  49. package/dist/server/auth/index.js.map +1 -1
  50. package/dist/server/cookies/index.d.ts +15 -0
  51. package/dist/server/cookies/index.d.ts.map +1 -1
  52. package/dist/server/cookies/index.js +22 -3
  53. package/dist/server/cookies/index.js.map +1 -1
  54. package/dist/server/core/index.d.ts +18 -0
  55. package/dist/server/core/index.d.ts.map +1 -1
  56. package/dist/server/core/index.js +25 -0
  57. package/dist/server/core/index.js.map +1 -1
  58. package/package.json +16 -3
  59. package/src/api/users/controllers/RealmController.ts +1 -0
  60. package/src/api/users/primitives/$realm.ts +26 -0
  61. package/src/api/users/providers/RealmProvider.ts +15 -0
  62. package/src/api/users/schemas/realmConfigSchema.ts +14 -0
  63. package/src/cli/core/atoms/buildOptions.ts +0 -12
  64. package/src/cli/core/commands/build.ts +0 -10
  65. package/src/cli/core/index.ts +0 -3
  66. package/src/cli/core/tasks/BuildCloudflareTask.ts +37 -17
  67. package/src/cli/core/tasks/BuildPrerenderTask.ts +44 -7
  68. package/src/cli/i18n/__tests__/I18nCheckService.spec.ts +48 -0
  69. package/src/cli/i18n/services/I18nCheckService.ts +65 -11
  70. package/src/cli/platform-lib/adapters/CloudflareAdapter.ts +128 -36
  71. package/src/mcp/__tests__/McpServerProvider.spec.ts +71 -0
  72. package/src/mcp/providers/McpServerProvider.ts +55 -0
  73. package/src/react/form/__tests__/FormModel-submit-loading.spec.ts +71 -0
  74. package/src/react/form/__tests__/form-submitting-reactive.browser.spec.tsx +96 -0
  75. package/src/react/form/services/FormModel.ts +57 -39
  76. package/src/react/i18n/__tests__/I18nProvider.spec.ts +89 -0
  77. package/src/react/i18n/__tests__/locale-routing.spec.ts +107 -0
  78. package/src/react/i18n/providers/I18nProvider.ts +171 -12
  79. package/src/react/router/__tests__/RouterLocaleProvider.spec.ts +127 -0
  80. package/src/react/router/index.browser.ts +4 -0
  81. package/src/react/router/index.shared.ts +1 -0
  82. package/src/react/router/index.ts +9 -0
  83. package/src/react/router/providers/ReactBrowserRouterProvider.ts +15 -1
  84. package/src/react/router/providers/ReactPageProvider.ts +12 -1
  85. package/src/react/router/providers/ReactServerProvider.ts +92 -1
  86. package/src/react/router/providers/RootComponentsProvider.ts +13 -0
  87. package/src/react/router/providers/RouterLocaleProvider.ts +125 -0
  88. package/src/react/router/providers/__tests__/RootComponentsProvider.spec.ts +15 -0
  89. package/src/react/router/providers/__tests__/rootComponents.ssr.browser.spec.tsx +67 -0
  90. package/src/react/sitemap/__tests__/$sitemap.spec.ts +131 -0
  91. package/src/react/sitemap/index.browser.ts +21 -0
  92. package/src/react/sitemap/index.ts +25 -0
  93. package/src/react/sitemap/primitives/$sitemap.browser.ts +26 -0
  94. package/src/react/sitemap/primitives/$sitemap.ts +196 -0
  95. package/src/server/auth/__tests__/appleClientSecret.spec.ts +34 -0
  96. package/src/server/auth/__tests__/authFederationClient.spec.ts +40 -0
  97. package/src/server/auth/__tests__/federationAssertion.spec.ts +146 -0
  98. package/src/server/auth/__tests__/federationRedirectReplay.spec.ts +44 -0
  99. package/src/server/auth/helpers/appleClientSecret.ts +24 -0
  100. package/src/server/auth/helpers/federationAssertion.ts +74 -0
  101. package/src/server/auth/helpers/jtiReplayGuard.ts +41 -0
  102. package/src/server/auth/helpers/safeRedirectPath.ts +19 -0
  103. package/src/server/auth/index.ts +4 -0
  104. package/src/server/auth/primitives/$authFederationBroker.ts +273 -0
  105. package/src/server/auth/primitives/$authFederationClient.ts +89 -0
  106. package/src/server/auth/providers/ServerAuthProvider.ts +18 -4
  107. package/src/server/cookies/__tests__/ServerCookiesProvider.spec.ts +70 -0
  108. package/src/server/cookies/providers/ServerCookiesProvider.ts +23 -3
  109. package/src/server/core/interfaces/ServerRequest.ts +8 -0
  110. package/src/server/core/primitives/$route.ts +27 -0
  111. package/src/cli/core/tasks/BuildSitemapTask.ts +0 -130
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "alepha",
3
3
  "description": "Easy-to-use modern TypeScript framework for building many kind of applications.",
4
4
  "author": "Feunard",
5
- "version": "0.22.0",
5
+ "version": "0.23.0",
6
6
  "type": "module",
7
7
  "engines": {
8
8
  "node": ">=22.0.0",
@@ -30,7 +30,7 @@
30
30
  "drizzle-orm": "^0.45.2",
31
31
  "postgres": "^3.4.9",
32
32
  "s3mini": "^0.9.5",
33
- "tsx": "^4.22.3",
33
+ "tsx": "^4.22.4",
34
34
  "typebox": "^1.1.39",
35
35
  "typescript": "^6.0.3",
36
36
  "vite": "^8.0.14",
@@ -57,7 +57,6 @@
57
57
  "prom-client": "^15.1.3",
58
58
  "react": "^19.2.6",
59
59
  "react-dom": "^19.2.6",
60
- "shadcn": "^4.8.3",
61
60
  "swagger-ui-dist": "^5.32.6",
62
61
  "tailwindcss": "^4.3.0",
63
62
  "tsdown": "^0.22.1"
@@ -396,6 +395,13 @@
396
395
  "import": "./dist/react/router/index.js",
397
396
  "default": "./dist/react/router/index.js"
398
397
  },
398
+ "./react/sitemap": {
399
+ "types": "./dist/react/sitemap/index.d.ts",
400
+ "react-native": "./dist/react/sitemap/index.browser.js",
401
+ "browser": "./dist/react/sitemap/index.browser.js",
402
+ "import": "./dist/react/sitemap/index.js",
403
+ "default": "./dist/react/sitemap/index.js"
404
+ },
399
405
  "./react/testing": {
400
406
  "types": "./dist/react/testing/index.d.ts",
401
407
  "import": "./dist/react/testing/index.js",
@@ -831,6 +837,13 @@
831
837
  "import": "./dist/react/router/index.js",
832
838
  "default": "./dist/react/router/index.js"
833
839
  },
840
+ "./react/sitemap": {
841
+ "types": "./dist/react/sitemap/index.d.ts",
842
+ "react-native": "./dist/react/sitemap/index.browser.js",
843
+ "browser": "./dist/react/sitemap/index.browser.js",
844
+ "import": "./dist/react/sitemap/index.js",
845
+ "default": "./dist/react/sitemap/index.js"
846
+ },
834
847
  "./react/testing": {
835
848
  "types": "./dist/react/testing/index.d.ts",
836
849
  "import": "./dist/react/testing/index.js",
@@ -49,6 +49,7 @@ export class RealmController {
49
49
  captchaSiteKey: settings.captchaRequired
50
50
  ? this.captchaProvider.getSiteKey()
51
51
  : undefined,
52
+ federated: realm.federated,
52
53
  };
53
54
  },
54
55
  });
@@ -21,6 +21,7 @@ import {
21
21
  $authApple,
22
22
  $authCredentials,
23
23
  $authFacebook,
24
+ $authFederationClient,
24
25
  $authFranceConnect,
25
26
  $authGithub,
26
27
  $authGoogle,
@@ -306,6 +307,15 @@ export const $realm = (options: RealmOptions = {}): RealmPrimitive => {
306
307
  }
307
308
 
308
309
  alepha.with(() => auth);
310
+
311
+ if (identities.federated) {
312
+ const fed = $authFederationClient({
313
+ realm,
314
+ brokerUrl: identities.federated.brokerUrl,
315
+ publicKeyPem: identities.federated.publicKey,
316
+ });
317
+ alepha.with(() => ({ federationCallback: fed.callback }));
318
+ }
309
319
  }
310
320
 
311
321
  if (features.parameters) {
@@ -431,6 +441,22 @@ export interface RealmOptions {
431
441
  facebook?: true;
432
442
  microsoft?: true;
433
443
  franceconnect?: true;
444
+ /**
445
+ * Federated social login via a central broker (the platform).
446
+ *
447
+ * The broker performs the real OIDC dance with Google/Apple on a single
448
+ * shared OAuth client and hands this realm a short-lived, asymmetric-signed
449
+ * assertion. This realm verifies it (broker `publicKey`), links a local
450
+ * user, and establishes a session — no per-tenant OAuth client required.
451
+ */
452
+ federated?: {
453
+ /** Broker origin (assertion `iss`), e.g. https://alepha.club. */
454
+ brokerUrl: string;
455
+ /** Broker EdDSA public key (SPKI PEM) used to verify assertions. */
456
+ publicKey: string;
457
+ /** Providers to surface as broker login buttons. */
458
+ providers: Array<"google" | "apple">;
459
+ };
434
460
  };
435
461
 
436
462
  /**
@@ -21,6 +21,15 @@ export interface Realm {
21
21
  repositories: RealmRepositories;
22
22
  settings: RealmAuthSettings;
23
23
  features: RealmFeatures;
24
+ /**
25
+ * Federated (broker) login config surfaced to the login UI, when the realm
26
+ * declares `identities.federated`. The broker public key is intentionally
27
+ * omitted — only the broker URL + provider list are client-facing.
28
+ */
29
+ federated?: {
30
+ brokerUrl: string;
31
+ providers: Array<"google" | "apple">;
32
+ };
24
33
  settingsParameter?: ParameterPrimitive<typeof realmAuthSettingsAtom.schema>;
25
34
  getSettings(): Promise<RealmAuthSettings>;
26
35
  }
@@ -77,6 +86,12 @@ export class RealmProvider {
77
86
  },
78
87
  },
79
88
  features,
89
+ federated: realmOptions.identities?.federated
90
+ ? {
91
+ brokerUrl: realmOptions.identities.federated.brokerUrl,
92
+ providers: realmOptions.identities.federated.providers,
93
+ }
94
+ : undefined,
80
95
  getSettings: async function () {
81
96
  if (this.settingsParameter) {
82
97
  return await this.settingsParameter.get();
@@ -12,6 +12,20 @@ export const realmConfigSchema = t.object({
12
12
  "Public site key for the captcha widget (when settings.captchaRequired is true)",
13
13
  }),
14
14
  ),
15
+ /**
16
+ * Federated (broker) social login. When present, the login UI renders one
17
+ * button per provider linking to `{brokerUrl}/auth/federated/start`.
18
+ */
19
+ federated: t.optional(
20
+ t.object({
21
+ brokerUrl: t.string({
22
+ description: "Broker origin that performs the OIDC dance.",
23
+ }),
24
+ providers: t.array(t.union([t.const("google"), t.const("apple")]), {
25
+ description: "Federated providers to surface as login buttons.",
26
+ }),
27
+ }),
28
+ ),
15
29
  });
16
30
 
17
31
  export type RealmConfig = Static<typeof realmConfigSchema>;
@@ -323,18 +323,6 @@ export const buildOptions = $atom({
323
323
  offline: t.optional(t.boolean()),
324
324
  }),
325
325
  ),
326
-
327
- /**
328
- * Sitemap generation configuration.
329
- */
330
- sitemap: t.optional(
331
- t.object({
332
- /**
333
- * Base URL for sitemap entries.
334
- */
335
- hostname: t.string(),
336
- }),
337
- ),
338
326
  }),
339
327
  default: {},
340
328
  });
@@ -19,7 +19,6 @@ import { BuildDockerTask } from "../tasks/BuildDockerTask.ts";
19
19
  import { BuildPrerenderTask } from "../tasks/BuildPrerenderTask.ts";
20
20
  import { BuildPwaTask } from "../tasks/BuildPwaTask.ts";
21
21
  import { BuildServerTask } from "../tasks/BuildServerTask.ts";
22
- import { BuildSitemapTask } from "../tasks/BuildSitemapTask.ts";
23
22
  import { BuildStaticTask } from "../tasks/BuildStaticTask.ts";
24
23
  import type { BuildTaskContext } from "../tasks/BuildTask.ts";
25
24
  import { BuildVercelTask } from "../tasks/BuildVercelTask.ts";
@@ -43,7 +42,6 @@ export class BuildCommand {
43
42
  $inject(BuildClientTask),
44
43
  $inject(BuildServerTask),
45
44
  $inject(BuildAssetsTask),
46
- $inject(BuildSitemapTask),
47
45
  $inject(BuildPwaTask),
48
46
  $inject(BuildPrerenderTask),
49
47
  $inject(BuildVercelTask),
@@ -153,11 +151,6 @@ export class BuildCommand {
153
151
  "Skip the bundle steps (Vite client/server + asset compression). Only regenerates target-specific deploy config (e.g. wrangler.jsonc). Use when `dist/` is already built and you just need the config refreshed.",
154
152
  }),
155
153
  ),
156
- sitemap: t.optional(
157
- t.text({
158
- description: "Generate sitemap.xml with base URL",
159
- }),
160
- ),
161
154
  }),
162
155
  handler: async ({ flags, run, root }) => {
163
156
  process.env.NODE_ENV = "production";
@@ -193,9 +186,6 @@ export class BuildCommand {
193
186
  : false,
194
187
  },
195
188
  }),
196
- ...(flags.sitemap && {
197
- sitemap: { hostname: flags.sitemap },
198
- }),
199
189
  };
200
190
  });
201
191
 
@@ -34,7 +34,6 @@ import { BuildCompressTask } from "./tasks/BuildCompressTask.ts";
34
34
  import { BuildDockerTask } from "./tasks/BuildDockerTask.ts";
35
35
  import { BuildPrerenderTask } from "./tasks/BuildPrerenderTask.ts";
36
36
  import { BuildServerTask } from "./tasks/BuildServerTask.ts";
37
- import { BuildSitemapTask } from "./tasks/BuildSitemapTask.ts";
38
37
  import { BuildStaticTask } from "./tasks/BuildStaticTask.ts";
39
38
  import { BuildVercelTask } from "./tasks/BuildVercelTask.ts";
40
39
 
@@ -74,7 +73,6 @@ export * from "./tasks/BuildCompressTask.ts";
74
73
  export * from "./tasks/BuildDockerTask.ts";
75
74
  export * from "./tasks/BuildPrerenderTask.ts";
76
75
  export * from "./tasks/BuildServerTask.ts";
77
- export * from "./tasks/BuildSitemapTask.ts";
78
76
  export * from "./tasks/BuildStaticTask.ts";
79
77
  export * from "./tasks/BuildTask.ts";
80
78
  export * from "./tasks/BuildVercelTask.ts";
@@ -132,7 +130,6 @@ export const AlephaCli = $module({
132
130
  BuildDockerTask,
133
131
  BuildPrerenderTask,
134
132
  BuildServerTask,
135
- BuildSitemapTask,
136
133
  BuildStaticTask,
137
134
  BuildVercelTask,
138
135
  ],
@@ -74,6 +74,14 @@ export interface BuildManifest {
74
74
  * primitives. Empty when `hasCron` is false.
75
75
  */
76
76
  crons: string[];
77
+ /**
78
+ * Cloudflare email binding, captured when the app registers
79
+ * `CloudflareEmailProvider` at artifact-build time. The prebuilt/manifest
80
+ * deploy path (Alepha Rocket `--prebuilt`) has no Vite introspection, so it
81
+ * reads this to re-emit the `send_email` wrangler binding. Absent when the
82
+ * app doesn't use Cloudflare email.
83
+ */
84
+ email?: { binding: string };
77
85
  /**
78
86
  * `$container()` descriptors — image, port, lifecycle settings.
79
87
  * Used both to populate Cloudflare Containers bindings in
@@ -279,6 +287,14 @@ export class BuildCloudflareTask extends BuildTask {
279
287
  env = Object.keys(ctx.alepha.dump().env).sort();
280
288
  } catch {}
281
289
 
290
+ // Capture the CF email binding so manifest-mode deploys (Rocket) can
291
+ // re-emit `send_email` — `enhanceEmail` can't introspect there.
292
+ let email: BuildManifest["email"];
293
+ try {
294
+ ctx.alepha.inject(CLOUDFLARE_EMAIL_PROVIDER_NAME);
295
+ email = { binding: SEND_EMAIL_DEFAULT_BINDING };
296
+ } catch {}
297
+
282
298
  const manifest: BuildManifest = {
283
299
  version: 1,
284
300
  project: name,
@@ -302,6 +318,7 @@ export class BuildCloudflareTask extends BuildTask {
302
318
  instanceType: c.instanceType,
303
319
  maxInstances: c.maxInstances,
304
320
  })),
321
+ email,
305
322
  env,
306
323
  };
307
324
 
@@ -472,25 +489,30 @@ export class BuildCloudflareTask extends BuildTask {
472
489
  ctx: BuildTaskContext,
473
490
  wrangler: WranglerConfig,
474
491
  ): void {
475
- // Manifest mode doesn't capture email provider details yet. Apps
476
- // using CloudflareEmailProvider would need a manifest field added.
477
- // For now, skip non-email apps and apps using non-CF providers
478
- // are unaffected. TODO: add `emailProvider` to BuildManifest.
479
- if (ctx.manifest || !ctx.alepha) {
480
- return;
492
+ // Resolve the CF email binding from whichever source this build path has:
493
+ // - manifest/prebuilt mode (Alepha Rocket `--prebuilt`): no app boot, so
494
+ // read the binding captured into the manifest at artifact-build time.
495
+ // Without this the deploy silently drops `send_email` and the worker
496
+ // boots with email inert (binding not found).
497
+ // - full Vite introspection (`ctx.alepha`, no manifest): probe for the
498
+ // registered CloudflareEmailProvider.
499
+ let binding: string | undefined;
500
+ if (ctx.manifest) {
501
+ binding = ctx.manifest.email?.binding;
502
+ } else if (ctx.alepha) {
503
+ try {
504
+ ctx.alepha.inject(CLOUDFLARE_EMAIL_PROVIDER_NAME);
505
+ binding = SEND_EMAIL_DEFAULT_BINDING;
506
+ } catch {
507
+ // app doesn't use CloudflareEmailProvider — nothing to emit
508
+ }
481
509
  }
482
- try {
483
- ctx.alepha.inject(CLOUDFLARE_EMAIL_PROVIDER_NAME);
484
- } catch {
510
+ if (!binding) {
485
511
  return;
486
512
  }
487
513
 
488
514
  wrangler.send_email = wrangler.send_email || [];
489
- if (
490
- wrangler.send_email.some(
491
- (b: { name: string }) => b.name === SEND_EMAIL_DEFAULT_BINDING,
492
- )
493
- ) {
515
+ if (wrangler.send_email.some((b: { name: string }) => b.name === binding)) {
494
516
  return;
495
517
  }
496
518
 
@@ -504,9 +526,7 @@ export class BuildCloudflareTask extends BuildTask {
504
526
  // sender goes in the message `from` field (see CloudflareEmailProvider.send);
505
527
  // leaving the binding unrestricted lets the worker send to any verified
506
528
  // destination.
507
- const entry: Record<string, unknown> = { name: SEND_EMAIL_DEFAULT_BINDING };
508
-
509
- wrangler.send_email.push(entry);
529
+ wrangler.send_email.push({ name: binding });
510
530
  }
511
531
 
512
532
  /**
@@ -4,11 +4,18 @@ import { FileSystemProvider } from "alepha/system";
4
4
  import { BuildTask, type BuildTaskContext } from "./BuildTask.ts";
5
5
 
6
6
  /**
7
- * Pre-render static pages defined in the Alepha application.
7
+ * Pre-render static pages and routes defined in the Alepha application.
8
8
  *
9
- * Queries all page primitives with `static: true` and generates
10
- * static HTML files for each page. Supports pages with parameterized
11
- * routes via `static.entries` configuration.
9
+ * Two passes, both writing into `dist/public`:
10
+ * - **pages** every `$page` with `static: true` is rendered to an HTML file
11
+ * (supports parameterized routes via `static.entries`).
12
+ * - **routes** — every `static` route primitive (a `$route({ static: true })`
13
+ * or a `$sitemap`) is invoked in-process and its body written verbatim to
14
+ * `{path}` (e.g. `sitemap.xml`, `robots.txt`).
15
+ *
16
+ * Both passes read the primitive registry and call a method on the already
17
+ * created primitive instances — no provider is re-injected, so this works in
18
+ * the build's configured-but-not-started container.
12
19
  */
13
20
  export class BuildPrerenderTask extends BuildTask {
14
21
  protected readonly fs = $inject(FileSystemProvider);
@@ -22,7 +29,8 @@ export class BuildPrerenderTask extends BuildTask {
22
29
  }
23
30
 
24
31
  const pages = this.getStaticPages(ctx);
25
- if (pages.length === 0) {
32
+ const routes = this.getStaticRoutePrimitives(ctx);
33
+ if (pages.length === 0 && routes.length === 0) {
26
34
  return;
27
35
  }
28
36
 
@@ -31,13 +39,18 @@ export class BuildPrerenderTask extends BuildTask {
31
39
  const dist = this.fs.join(ctx.root, distDir, publicDir);
32
40
 
33
41
  await ctx.run({
34
- name: "pre-render pages",
42
+ name: "pre-render",
35
43
  handler: async () => {
36
44
  // TODO: running configure here is a temporary workaround
37
45
  if (!ctx.alepha.isConfigured()) {
38
46
  await ctx.alepha.events.emit("configure", ctx.alepha);
39
47
  }
40
- await this.prerenderFromAlepha(pages, dist);
48
+ if (pages.length > 0) {
49
+ await this.prerenderFromAlepha(pages, dist);
50
+ }
51
+ if (routes.length > 0) {
52
+ await this.prerenderRoutes(routes, dist);
53
+ }
41
54
  },
42
55
  });
43
56
  }
@@ -50,6 +63,18 @@ export class BuildPrerenderTask extends BuildTask {
50
63
  });
51
64
  }
52
65
 
66
+ /**
67
+ * Static route primitives to snapshot: `$route({ static: true })` and every
68
+ * `$sitemap`. Both expose an async `prerender(): { path, body }`.
69
+ */
70
+ protected getStaticRoutePrimitives(ctx: BuildTaskContext): any[] {
71
+ const routes = (ctx.alepha.primitives("route") as any[]).filter(
72
+ (route) => route.options?.static === true,
73
+ );
74
+ const sitemaps = ctx.alepha.primitives("sitemap") as any[];
75
+ return [...routes, ...sitemaps];
76
+ }
77
+
53
78
  protected async prerenderFromAlepha(
54
79
  pages: any[],
55
80
  dist: string,
@@ -77,6 +102,18 @@ export class BuildPrerenderTask extends BuildTask {
77
102
  return count;
78
103
  }
79
104
 
105
+ protected async prerenderRoutes(
106
+ primitives: any[],
107
+ dist: string,
108
+ ): Promise<void> {
109
+ for (const primitive of primitives) {
110
+ const { path, body } = await primitive.prerender();
111
+ const filepath = this.fs.join(dist, path);
112
+ await this.fs.mkdir(dirname(filepath));
113
+ await this.fs.writeFile(filepath, body);
114
+ }
115
+ }
116
+
80
117
  protected async renderFile(
81
118
  page: any,
82
119
  options: any,
@@ -109,6 +109,54 @@ ${body}
109
109
  expect(result.unused).toEqual(["home.title"]);
110
110
  });
111
111
 
112
+ it("extracts keys from lazily-imported per-language files", async () => {
113
+ await env.fs.mkdir(`${ROOT}/src/web/i18n`, { recursive: true });
114
+ // Marker file declares `$dictionary` but the keys live in split,
115
+ // markerless per-language files referenced via `lazy: () => import(...)`.
116
+ // A sibling `$page` lazy import must NOT be mistaken for a key file.
117
+ await env.fs.writeFile(
118
+ `${ROOT}/src/web/Router.ts`,
119
+ `import { $dictionary } from "alepha/react/i18n";
120
+ export class Router {
121
+ fr = $dictionary({ lazy: () => import("./i18n/fr.ts"), lang: "fr" });
122
+ en = $dictionary({ lazy: () => import("./i18n/en.ts"), lang: "en" });
123
+ page = $page({ lazy: () => import("./Home.tsx") });
124
+ }
125
+ `,
126
+ );
127
+ await env.fs.writeFile(
128
+ `${ROOT}/src/web/i18n/fr.ts`,
129
+ `export default { "home.title": "Accueil", "home.unused": "Rien" };`,
130
+ );
131
+ await env.fs.writeFile(
132
+ `${ROOT}/src/web/i18n/en.ts`,
133
+ `export default { "home.title": "Home", "home.unused": "Nothing" };`,
134
+ );
135
+ // The only reference — `home.unused` is dead in both languages. The
136
+ // `"home.title": "…"` lines in the sibling language file must not count
137
+ // as references (key files are excluded from the usage corpus).
138
+ await env.fs.writeFile(
139
+ `${ROOT}/src/web/Home.tsx`,
140
+ `export const Home = () => tr("home.title");`,
141
+ );
142
+
143
+ const result = await env.service.check({
144
+ root: ROOT,
145
+ scan: ["src"],
146
+ dynamicPrefixes: [],
147
+ exclude: [],
148
+ });
149
+
150
+ expect(result.totalKeys).toBe(2);
151
+ expect(result.unused).toEqual(["home.unused"]);
152
+ // The resolved language file is treated as a dictionary; the sibling
153
+ // `$page` lazy import is not (it contributed no keys and stays in the
154
+ // usage corpus). `en.ts` is also a dictionary file but adds no *new*
155
+ // keys, so only the first contributor is listed.
156
+ expect(result.dictionaryFiles).toContain(`${ROOT}/src/web/i18n/fr.ts`);
157
+ expect(result.dictionaryFiles).not.toContain(`${ROOT}/src/web/Home.tsx`);
158
+ });
159
+
112
160
  it("returns totalKeys=0 when no dictionary is found", async () => {
113
161
  await env.fs.mkdir(`${ROOT}/src`, { recursive: true });
114
162
  await env.fs.writeFile(
@@ -35,6 +35,22 @@ const KEY_DECLARATION_RE = /"([\w-]+(?:\.[\w-]+)+)"\s*:/g;
35
35
  */
36
36
  const DICTIONARY_MARKER = "$dictionary";
37
37
 
38
+ /**
39
+ * Captures the module specifier of a lazily-imported dictionary, i.e. the
40
+ * `"./fr.ts"` in `$dictionary({ lazy: () => import("./fr.ts") })`. Apps
41
+ * commonly split each language into its own file so a session only ships the
42
+ * active locale — those key files carry no `$dictionary` marker, so without
43
+ * this the keys would be invisible to the check.
44
+ *
45
+ * `[^{}]*?` keeps the match inside the `$dictionary` call's own object literal
46
+ * (it stops at the first brace), so unrelated lazy imports in the same file —
47
+ * e.g. a sibling `$page({ lazy: () => import("./Page.tsx") })` or a
48
+ * `$dictionary` whose `lazy` returns an inline `({ default: {…} })` object —
49
+ * are never mistaken for dictionary key files.
50
+ */
51
+ const DICTIONARY_LAZY_IMPORT_RE =
52
+ /\$dictionary\s*\(\s*\{[^{}]*?import\s*\(\s*["']([^"']+)["']/g;
53
+
38
54
  export interface I18nCheckOptions {
39
55
  root: string;
40
56
  scan: string[];
@@ -62,11 +78,11 @@ export class I18nCheckService {
62
78
  * Find unused translation keys.
63
79
  *
64
80
  * Discovery is fully static: we walk `scan` dirs, identify files
65
- * that import `$dictionary` (matched via the literal substring),
66
- * extract every `"a.b.c": ...` property key declared in them, then
67
- * grep the remaining source files for a quoted-literal occurrence
68
- * of each key. Anything matching a `dynamicPrefixes` entry is
69
- * exempted.
81
+ * that import `$dictionary` (matched via the literal substring) plus
82
+ * any per-language files they lazily `import(...)`, extract every
83
+ * `"a.b.c": ...` property key declared across them, then grep the
84
+ * remaining source files for a quoted-literal occurrence of each key.
85
+ * Anything matching a `dynamicPrefixes` entry is exempted.
70
86
  */
71
87
  async check(options: I18nCheckOptions): Promise<I18nCheckResult> {
72
88
  const { root, scan, dynamicPrefixes, exclude } = options;
@@ -91,14 +107,31 @@ export class I18nCheckService {
91
107
  }
92
108
  }
93
109
 
94
- const dictionaryFiles: string[] = [];
95
- const allKeys = new Set<string>();
96
110
  const fileContents = new Map<string, string>();
97
-
98
111
  for (const file of allFiles) {
99
- const text = (await this.fs.readFile(file)).toString("utf8");
100
- fileContents.set(file, text);
112
+ fileContents.set(file, (await this.fs.readFile(file)).toString("utf8"));
113
+ }
114
+
115
+ // A file is a dictionary if it declares `$dictionary` OR it is the target
116
+ // of a `$dictionary({ lazy: () => import("…") })` (the split per-language
117
+ // key files, which carry no marker of their own). Resolving the lazy
118
+ // targets first lets their keys be extracted below and keeps them out of
119
+ // the usage corpus (their `"key": "value"` lines aren't references).
120
+ const dictionarySet = new Set<string>();
121
+ for (const [file, text] of fileContents) {
101
122
  if (!text.includes(DICTIONARY_MARKER)) continue;
123
+ dictionarySet.add(file);
124
+ for (const m of text.matchAll(DICTIONARY_LAZY_IMPORT_RE)) {
125
+ const target = this.resolveImport(file, m[1], fileContents);
126
+ if (target) dictionarySet.add(target);
127
+ }
128
+ }
129
+
130
+ const dictionaryFiles: string[] = [];
131
+ const allKeys = new Set<string>();
132
+ for (const file of dictionarySet) {
133
+ const text = fileContents.get(file);
134
+ if (!text) continue;
102
135
  const before = allKeys.size;
103
136
  for (const m of text.matchAll(KEY_DECLARATION_RE)) {
104
137
  allKeys.add(m[1]);
@@ -108,7 +141,6 @@ export class I18nCheckService {
108
141
 
109
142
  // Concatenate every non-dictionary file into one corpus so each
110
143
  // key is tested with a single regex run rather than O(files × keys).
111
- const dictionarySet = new Set(dictionaryFiles);
112
144
  const corpusParts: string[] = [];
113
145
  let scannedFiles = 0;
114
146
  for (const [file, text] of fileContents) {
@@ -141,4 +173,26 @@ export class I18nCheckService {
141
173
  unused: unused.sort(),
142
174
  };
143
175
  }
176
+
177
+ /**
178
+ * Resolve a relative `import("…")` specifier from `fromFile` to an absolute
179
+ * path that was actually scanned. Returns `undefined` for bare/package
180
+ * specifiers or targets outside the scan set. Extensionless specifiers are
181
+ * probed against each supported source extension.
182
+ */
183
+ protected resolveImport(
184
+ fromFile: string,
185
+ spec: string,
186
+ files: Map<string, string>,
187
+ ): string | undefined {
188
+ if (!spec.startsWith(".")) return undefined;
189
+ // `join(file, "..", spec)` drops the filename then applies the relative
190
+ // specifier — i.e. resolves against `fromFile`'s directory.
191
+ const base = this.fs.join(fromFile, "..", spec);
192
+ if (files.has(base)) return base;
193
+ for (const ext of SCAN_EXTS) {
194
+ if (files.has(base + ext)) return base + ext;
195
+ }
196
+ return undefined;
197
+ }
144
198
  }