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.
- package/dist/api/jobs/index.d.ts +20 -20
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/keys/index.d.ts +6 -6
- package/dist/api/users/index.d.ts +43 -9
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +24 -3
- package/dist/api/users/index.js.map +1 -1
- package/dist/api/verifications/index.d.ts +13 -13
- package/dist/cli/core/index.d.ts +46 -40
- package/dist/cli/core/index.d.ts.map +1 -1
- package/dist/cli/core/index.js +51 -101
- package/dist/cli/core/index.js.map +1 -1
- package/dist/cli/i18n/index.d.ts +12 -5
- package/dist/cli/i18n/index.d.ts.map +1 -1
- package/dist/cli/i18n/index.js +45 -11
- package/dist/cli/i18n/index.js.map +1 -1
- package/dist/cli/platform-lib/index.d.ts +32 -6
- package/dist/cli/platform-lib/index.d.ts.map +1 -1
- package/dist/cli/platform-lib/index.js +82 -19
- package/dist/cli/platform-lib/index.js.map +1 -1
- package/dist/command/index.d.ts +1 -1
- package/dist/mcp/index.d.ts +9 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +23 -0
- package/dist/mcp/index.js.map +1 -1
- package/dist/react/form/index.d.ts +0 -1
- package/dist/react/form/index.d.ts.map +1 -1
- package/dist/react/form/index.js +16 -15
- package/dist/react/form/index.js.map +1 -1
- package/dist/react/i18n/index.d.ts +43 -0
- package/dist/react/i18n/index.d.ts.map +1 -1
- package/dist/react/i18n/index.js +114 -10
- package/dist/react/i18n/index.js.map +1 -1
- package/dist/react/router/index.browser.js +128 -5
- package/dist/react/router/index.browser.js.map +1 -1
- package/dist/react/router/index.d.ts +108 -1
- package/dist/react/router/index.d.ts.map +1 -1
- package/dist/react/router/index.js +184 -6
- package/dist/react/router/index.js.map +1 -1
- package/dist/react/sitemap/index.browser.js +35 -0
- package/dist/react/sitemap/index.browser.js.map +1 -0
- package/dist/react/sitemap/index.d.ts +92 -0
- package/dist/react/sitemap/index.d.ts.map +1 -0
- package/dist/react/sitemap/index.js +131 -0
- package/dist/react/sitemap/index.js.map +1 -0
- package/dist/server/auth/index.d.ts +105 -1
- package/dist/server/auth/index.d.ts.map +1 -1
- package/dist/server/auth/index.js +1604 -7
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/cookies/index.d.ts +15 -0
- package/dist/server/cookies/index.d.ts.map +1 -1
- package/dist/server/cookies/index.js +22 -3
- package/dist/server/cookies/index.js.map +1 -1
- package/dist/server/core/index.d.ts +18 -0
- package/dist/server/core/index.d.ts.map +1 -1
- package/dist/server/core/index.js +25 -0
- package/dist/server/core/index.js.map +1 -1
- package/package.json +16 -3
- package/src/api/users/controllers/RealmController.ts +1 -0
- package/src/api/users/primitives/$realm.ts +26 -0
- package/src/api/users/providers/RealmProvider.ts +15 -0
- package/src/api/users/schemas/realmConfigSchema.ts +14 -0
- package/src/cli/core/atoms/buildOptions.ts +0 -12
- package/src/cli/core/commands/build.ts +0 -10
- package/src/cli/core/index.ts +0 -3
- package/src/cli/core/tasks/BuildCloudflareTask.ts +37 -17
- package/src/cli/core/tasks/BuildPrerenderTask.ts +44 -7
- package/src/cli/i18n/__tests__/I18nCheckService.spec.ts +48 -0
- package/src/cli/i18n/services/I18nCheckService.ts +65 -11
- package/src/cli/platform-lib/adapters/CloudflareAdapter.ts +128 -36
- package/src/mcp/__tests__/McpServerProvider.spec.ts +71 -0
- package/src/mcp/providers/McpServerProvider.ts +55 -0
- package/src/react/form/__tests__/FormModel-submit-loading.spec.ts +71 -0
- package/src/react/form/__tests__/form-submitting-reactive.browser.spec.tsx +96 -0
- package/src/react/form/services/FormModel.ts +57 -39
- package/src/react/i18n/__tests__/I18nProvider.spec.ts +89 -0
- package/src/react/i18n/__tests__/locale-routing.spec.ts +107 -0
- package/src/react/i18n/providers/I18nProvider.ts +171 -12
- package/src/react/router/__tests__/RouterLocaleProvider.spec.ts +127 -0
- package/src/react/router/index.browser.ts +4 -0
- package/src/react/router/index.shared.ts +1 -0
- package/src/react/router/index.ts +9 -0
- package/src/react/router/providers/ReactBrowserRouterProvider.ts +15 -1
- package/src/react/router/providers/ReactPageProvider.ts +12 -1
- package/src/react/router/providers/ReactServerProvider.ts +92 -1
- package/src/react/router/providers/RootComponentsProvider.ts +13 -0
- package/src/react/router/providers/RouterLocaleProvider.ts +125 -0
- package/src/react/router/providers/__tests__/RootComponentsProvider.spec.ts +15 -0
- package/src/react/router/providers/__tests__/rootComponents.ssr.browser.spec.tsx +67 -0
- package/src/react/sitemap/__tests__/$sitemap.spec.ts +131 -0
- package/src/react/sitemap/index.browser.ts +21 -0
- package/src/react/sitemap/index.ts +25 -0
- package/src/react/sitemap/primitives/$sitemap.browser.ts +26 -0
- package/src/react/sitemap/primitives/$sitemap.ts +196 -0
- package/src/server/auth/__tests__/appleClientSecret.spec.ts +34 -0
- package/src/server/auth/__tests__/authFederationClient.spec.ts +40 -0
- package/src/server/auth/__tests__/federationAssertion.spec.ts +146 -0
- package/src/server/auth/__tests__/federationRedirectReplay.spec.ts +44 -0
- package/src/server/auth/helpers/appleClientSecret.ts +24 -0
- package/src/server/auth/helpers/federationAssertion.ts +74 -0
- package/src/server/auth/helpers/jtiReplayGuard.ts +41 -0
- package/src/server/auth/helpers/safeRedirectPath.ts +19 -0
- package/src/server/auth/index.ts +4 -0
- package/src/server/auth/primitives/$authFederationBroker.ts +273 -0
- package/src/server/auth/primitives/$authFederationClient.ts +89 -0
- package/src/server/auth/providers/ServerAuthProvider.ts +18 -4
- package/src/server/cookies/__tests__/ServerCookiesProvider.spec.ts +70 -0
- package/src/server/cookies/providers/ServerCookiesProvider.ts +23 -3
- package/src/server/core/interfaces/ServerRequest.ts +8 -0
- package/src/server/core/primitives/$route.ts +27 -0
- 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.
|
|
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.
|
|
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",
|
|
@@ -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
|
|
package/src/cli/core/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* routes via `static.entries`
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
-
|
|
100
|
-
|
|
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
|
}
|