@mandujs/core 0.31.0 → 0.32.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/package.json +4 -1
- package/src/config/mandu.ts +58 -1
- package/src/config/validate.ts +97 -1
- package/src/filling/context.ts +60 -0
- package/src/guard/check.ts +225 -1
- package/src/guard/define-rule.ts +243 -0
- package/src/guard/index.ts +26 -0
- package/src/guard/rule-presets.ts +379 -0
- package/src/i18n/define.ts +126 -0
- package/src/i18n/index.ts +52 -0
- package/src/i18n/locale-resolver.ts +214 -0
- package/src/i18n/message-registry.ts +173 -0
- package/src/i18n/types.ts +112 -0
- package/src/router/fs-scanner.ts +101 -0
- package/src/router/index.ts +7 -1
- package/src/runtime/server.ts +277 -9
- package/src/runtime/ssr.ts +9 -0
package/src/router/fs-scanner.ts
CHANGED
|
@@ -619,6 +619,107 @@ function escapeRegex(char: string): string {
|
|
|
619
619
|
return /[\\^$.*+?()[\]{}|]/.test(char) ? `\\${char}` : char;
|
|
620
620
|
}
|
|
621
621
|
|
|
622
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
623
|
+
// Phase 18.μ — i18n path-prefix route synthesis
|
|
624
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Options for {@link synthesizeLocaleRoutes}. Mirrors the relevant subset
|
|
628
|
+
* of `I18nDefinition` so callers don't need to pull the whole
|
|
629
|
+
* `@mandujs/core/i18n` surface into pure-router code paths.
|
|
630
|
+
*/
|
|
631
|
+
export interface LocaleSynthesisOptions {
|
|
632
|
+
/** Allow-list of locale codes to materialize. */
|
|
633
|
+
locales: readonly string[];
|
|
634
|
+
/** Default locale — its routes stay unprefixed (Next.js parity). */
|
|
635
|
+
defaultLocale: string;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Phase 18.μ — synthesize per-locale route variants at manifest-build
|
|
640
|
+
* time. Given a set of scanned routes, produces `locales.length` copies
|
|
641
|
+
* for every `page` / `api` route with a locale prefix baked into
|
|
642
|
+
* `id` + `pattern`. The default locale's routes are emitted unprefixed
|
|
643
|
+
* (so legacy links keep working and SEO stays intact).
|
|
644
|
+
*
|
|
645
|
+
* The synthesis is pure: it re-uses existing `FSRouteConfig` objects as
|
|
646
|
+
* source of truth, producing *new* objects with:
|
|
647
|
+
*
|
|
648
|
+
* - `pattern` : `/en/blog/:slug`
|
|
649
|
+
* - `id` : `en::<original-id>`
|
|
650
|
+
* - `module` : unchanged (same loader on disk)
|
|
651
|
+
* - everything else: shallow-copied
|
|
652
|
+
*
|
|
653
|
+
* Metadata routes (sitemap/robots/llms-txt/manifest) are NOT duplicated —
|
|
654
|
+
* they always sit at site root regardless of locale (same SEO rule as
|
|
655
|
+
* Next.js).
|
|
656
|
+
*
|
|
657
|
+
* The caller is responsible for passing the output through
|
|
658
|
+
* {@link sortRoutesByPriority} before writing the manifest.
|
|
659
|
+
*
|
|
660
|
+
* @example
|
|
661
|
+
* ```ts
|
|
662
|
+
* const scan = await scanRoutes(rootDir);
|
|
663
|
+
* const prefixed = synthesizeLocaleRoutes(scan.routes, {
|
|
664
|
+
* locales: ["en", "ko"],
|
|
665
|
+
* defaultLocale: "en",
|
|
666
|
+
* });
|
|
667
|
+
* // scan.routes : [/, /blog, /blog/:slug]
|
|
668
|
+
* // prefixed : [/, /blog, /blog/:slug, /ko, /ko/blog, /ko/blog/:slug]
|
|
669
|
+
* ```
|
|
670
|
+
*/
|
|
671
|
+
export function synthesizeLocaleRoutes(
|
|
672
|
+
routes: FSRouteConfig[],
|
|
673
|
+
options: LocaleSynthesisOptions
|
|
674
|
+
): FSRouteConfig[] {
|
|
675
|
+
const { locales, defaultLocale } = options;
|
|
676
|
+
if (!Array.isArray(locales) || locales.length === 0) return [...routes];
|
|
677
|
+
if (!locales.includes(defaultLocale)) {
|
|
678
|
+
throw new Error(
|
|
679
|
+
`[router] synthesizeLocaleRoutes: defaultLocale "${defaultLocale}" not in locales [${locales.join(", ")}]`
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const out: FSRouteConfig[] = [];
|
|
684
|
+
for (const route of routes) {
|
|
685
|
+
// Metadata routes live at site root; never prefix them.
|
|
686
|
+
if (route.kind === "metadata") {
|
|
687
|
+
out.push(route);
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Default locale: unprefixed copy preserved verbatim (legacy +
|
|
692
|
+
// SEO neutral).
|
|
693
|
+
out.push(route);
|
|
694
|
+
|
|
695
|
+
for (const locale of locales) {
|
|
696
|
+
if (locale === defaultLocale) continue;
|
|
697
|
+
const prefixed = prefixRouteWithLocale(route, locale);
|
|
698
|
+
out.push(prefixed);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return out;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function prefixRouteWithLocale(route: FSRouteConfig, locale: string): FSRouteConfig {
|
|
705
|
+
const prefixedPattern = route.pattern === "/"
|
|
706
|
+
? `/${locale}`
|
|
707
|
+
: `/${locale}${route.pattern.startsWith("/") ? route.pattern : `/${route.pattern}`}`;
|
|
708
|
+
|
|
709
|
+
return {
|
|
710
|
+
...route,
|
|
711
|
+
id: `${locale}::${route.id}`,
|
|
712
|
+
pattern: prefixedPattern,
|
|
713
|
+
// `segments` is used for priority calculation + layout resolution;
|
|
714
|
+
// prepending a static locale segment keeps priority sensible and
|
|
715
|
+
// avoids collisions with real `[param]` segments.
|
|
716
|
+
segments: [
|
|
717
|
+
{ raw: locale, type: "static" },
|
|
718
|
+
...route.segments,
|
|
719
|
+
],
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
622
723
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
623
724
|
// Factory Function
|
|
624
725
|
// ═══════════════════════════════════════════════════════════════════════════
|
package/src/router/index.ts
CHANGED
|
@@ -68,7 +68,13 @@ export {
|
|
|
68
68
|
} from "./fs-patterns";
|
|
69
69
|
|
|
70
70
|
// Scanner
|
|
71
|
-
export {
|
|
71
|
+
export {
|
|
72
|
+
FSScanner,
|
|
73
|
+
createFSScanner,
|
|
74
|
+
scanRoutes,
|
|
75
|
+
synthesizeLocaleRoutes,
|
|
76
|
+
type LocaleSynthesisOptions,
|
|
77
|
+
} from "./fs-scanner";
|
|
72
78
|
|
|
73
79
|
// Generator
|
|
74
80
|
export type { FSGenerateResult, GenerateOptions, RouteChangeCallback, FSRoutesWatcher } from "./fs-routes";
|
package/src/runtime/server.ts
CHANGED
|
@@ -117,6 +117,20 @@ import {
|
|
|
117
117
|
buildPayloadFromError,
|
|
118
118
|
shouldInjectOverlay,
|
|
119
119
|
} from "../dev-error-overlay";
|
|
120
|
+
// Phase 18.μ — i18n dispatch. `resolveLocale()` is pure (no side effects),
|
|
121
|
+
// `createTranslator()` binds a per-request `t()` to the registry.
|
|
122
|
+
import {
|
|
123
|
+
resolveLocale,
|
|
124
|
+
createTranslator,
|
|
125
|
+
isI18nDefinition,
|
|
126
|
+
isMessageRegistry,
|
|
127
|
+
} from "../i18n";
|
|
128
|
+
import type {
|
|
129
|
+
I18nDefinition,
|
|
130
|
+
ResolvedLocale,
|
|
131
|
+
Translator,
|
|
132
|
+
} from "../i18n/types";
|
|
133
|
+
import type { MessageRegistry } from "../i18n/message-registry";
|
|
120
134
|
|
|
121
135
|
export interface RateLimitOptions {
|
|
122
136
|
windowMs?: number;
|
|
@@ -548,6 +562,33 @@ export interface ServerOptions {
|
|
|
548
562
|
jobs?: import("../scheduler").CronDef[];
|
|
549
563
|
disabled?: boolean;
|
|
550
564
|
};
|
|
565
|
+
/**
|
|
566
|
+
* Phase 18.μ — first-class i18n. Threaded from `ManduConfig.i18n`.
|
|
567
|
+
*
|
|
568
|
+
* When populated, the runtime dispatcher:
|
|
569
|
+
* 1. resolves the active locale BEFORE route dispatch (via
|
|
570
|
+
* {@link resolveLocale}) using the configured strategy;
|
|
571
|
+
* 2. attaches `ctx.locale` (ResolvedLocale) + `ctx.t` (typed
|
|
572
|
+
* translator) to every loader / handler invocation;
|
|
573
|
+
* 3. stamps `Vary: Accept-Language` + `Content-Language` on all
|
|
574
|
+
* page responses so upstream caches key on locale;
|
|
575
|
+
* 4. when `strategy === "path-prefix"` and the incoming URL
|
|
576
|
+
* carries no locale prefix AND no override cookie, redirects
|
|
577
|
+
* `/` → `/<defaultLocale>` so browsers reach a locale-scoped
|
|
578
|
+
* URL (Next.js parity).
|
|
579
|
+
*
|
|
580
|
+
* Omitting this block keeps the runtime branch-free — the hot path
|
|
581
|
+
* incurs zero overhead when i18n is disabled.
|
|
582
|
+
*/
|
|
583
|
+
i18n?: I18nDefinition;
|
|
584
|
+
/**
|
|
585
|
+
* Phase 18.μ — optional message registry bound to `ctx.t`. Created via
|
|
586
|
+
* `defineMessages({ en: {...}, ko: {...} })`. When omitted but `i18n`
|
|
587
|
+
* is set, `ctx.locale` is populated but `ctx.t` stays `undefined` —
|
|
588
|
+
* projects that manage their own translation layer (e.g. react-intl)
|
|
589
|
+
* use `ctx.locale.code` and ignore `ctx.t`.
|
|
590
|
+
*/
|
|
591
|
+
messages?: MessageRegistry;
|
|
551
592
|
}
|
|
552
593
|
|
|
553
594
|
export interface ManduServer {
|
|
@@ -736,6 +777,19 @@ export interface ServerRegistrySettings {
|
|
|
736
777
|
* `ServerOptions.middleware` at {@link startServer} time.
|
|
737
778
|
*/
|
|
738
779
|
middlewareChain?: ComposedHandler;
|
|
780
|
+
/**
|
|
781
|
+
* Phase 18.μ — resolved i18n definition. `undefined` means "no i18n
|
|
782
|
+
* configured at boot" (hot path is branch-free). Wired from
|
|
783
|
+
* `ServerOptions.i18n`.
|
|
784
|
+
*/
|
|
785
|
+
i18n?: I18nDefinition;
|
|
786
|
+
/**
|
|
787
|
+
* Phase 18.μ — message registry bound to {@link i18n}. When both are
|
|
788
|
+
* set, the runtime builds a per-request `ctx.t` via
|
|
789
|
+
* `createTranslator()`. When only `i18n` is set, `ctx.t` stays
|
|
790
|
+
* `undefined` and user code is responsible for its own translations.
|
|
791
|
+
*/
|
|
792
|
+
messages?: MessageRegistry;
|
|
739
793
|
}
|
|
740
794
|
|
|
741
795
|
export class ServerRegistry {
|
|
@@ -1609,6 +1663,15 @@ async function handleRequestWithTracing(
|
|
|
1609
1663
|
});
|
|
1610
1664
|
}
|
|
1611
1665
|
}
|
|
1666
|
+
// Phase 18.μ — stamp locale hint on error responses too.
|
|
1667
|
+
const errI18nState = i18nRequestState.get(req);
|
|
1668
|
+
if (errI18nState && !errorResponse.headers.has("Content-Language")) {
|
|
1669
|
+
try {
|
|
1670
|
+
errorResponse.headers.set("Content-Language", errI18nState.locale.code);
|
|
1671
|
+
} catch {
|
|
1672
|
+
// Some adapters freeze headers on error responses — ignore.
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1612
1675
|
return errorResponse;
|
|
1613
1676
|
}
|
|
1614
1677
|
|
|
@@ -1642,6 +1705,28 @@ async function handleRequestWithTracing(
|
|
|
1642
1705
|
}
|
|
1643
1706
|
}
|
|
1644
1707
|
|
|
1708
|
+
// Phase 18.μ — stamp locale-sensitive caching hints onto the final
|
|
1709
|
+
// response. `Vary: Accept-Language, Cookie` ensures upstream caches
|
|
1710
|
+
// key correctly per locale signal; `Content-Language` surfaces the
|
|
1711
|
+
// resolved locale for SEO / screen-reader parity. Only stamped when
|
|
1712
|
+
// i18n is configured AND the request was actually resolved — the hot
|
|
1713
|
+
// path is branch-free when `i18n` is absent.
|
|
1714
|
+
const i18nState = i18nRequestState.get(req);
|
|
1715
|
+
if (i18nState) {
|
|
1716
|
+
const existingVary = result.value.headers.get("Vary");
|
|
1717
|
+
const varyParts = new Set(
|
|
1718
|
+
(existingVary ? existingVary.split(",") : [])
|
|
1719
|
+
.map((s) => s.trim())
|
|
1720
|
+
.filter(Boolean)
|
|
1721
|
+
);
|
|
1722
|
+
varyParts.add("Accept-Language");
|
|
1723
|
+
varyParts.add("Cookie");
|
|
1724
|
+
result.value.headers.set("Vary", [...varyParts].join(", "));
|
|
1725
|
+
if (!result.value.headers.has("Content-Language")) {
|
|
1726
|
+
result.value.headers.set("Content-Language", i18nState.locale.code);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1645
1730
|
return result.value;
|
|
1646
1731
|
}
|
|
1647
1732
|
|
|
@@ -1908,6 +1993,7 @@ async function loadPageData(
|
|
|
1908
1993
|
// Filling의 loader 실행
|
|
1909
1994
|
if (registration.filling?.hasLoader()) {
|
|
1910
1995
|
const ctx = new ManduContext(req, params);
|
|
1996
|
+
attachI18nToContext(ctx, req);
|
|
1911
1997
|
// DX-3: loader may return OR throw a redirect Response. Both are
|
|
1912
1998
|
// short-circuits — if we detect one, skip SSR and hand the Response
|
|
1913
1999
|
// to the caller with pending cookies merged in.
|
|
@@ -2028,6 +2114,7 @@ async function loadPageData(
|
|
|
2028
2114
|
}
|
|
2029
2115
|
if (filling?.hasLoader?.()) {
|
|
2030
2116
|
const ctx = new ManduContext(req, params);
|
|
2117
|
+
attachI18nToContext(ctx, req);
|
|
2031
2118
|
// DX-3 / Phase 6.3: same redirect + notFound handling as the
|
|
2032
2119
|
// PageHandler path above. notFound is checked first so both
|
|
2033
2120
|
// short-circuits remain symmetric.
|
|
@@ -2197,6 +2284,7 @@ async function loadLayoutData(
|
|
|
2197
2284
|
const filling = exported as ManduFilling;
|
|
2198
2285
|
if (filling.hasLoader()) {
|
|
2199
2286
|
const ctx = new ManduContext(req, params);
|
|
2287
|
+
attachI18nToContext(ctx, req);
|
|
2200
2288
|
const data = await filling.executeLoader(ctx);
|
|
2201
2289
|
// DX-3: layout loaders are NOT allowed to redirect. They share
|
|
2202
2290
|
// the pipeline with a page loader and we can only honor one
|
|
@@ -2430,15 +2518,27 @@ async function renderPageSSR(
|
|
|
2430
2518
|
? route.streaming
|
|
2431
2519
|
: settings.streaming;
|
|
2432
2520
|
|
|
2433
|
-
// Issue #198 —
|
|
2434
|
-
//
|
|
2435
|
-
//
|
|
2436
|
-
//
|
|
2437
|
-
//
|
|
2438
|
-
//
|
|
2439
|
-
//
|
|
2440
|
-
//
|
|
2441
|
-
|
|
2521
|
+
// Issue #198 / Phase 18.ξ — Async server component resolution policy.
|
|
2522
|
+
//
|
|
2523
|
+
// Non-streaming path (`renderToString`) cannot handle async components,
|
|
2524
|
+
// so we MUST pre-resolve the tree up-front.
|
|
2525
|
+
//
|
|
2526
|
+
// Streaming path (`renderToReadableStream`, React 19) supports async
|
|
2527
|
+
// components natively and is designed around progressive flushing. If
|
|
2528
|
+
// we pre-resolve here, the caller blocks until every async component
|
|
2529
|
+
// settles before the shell can be emitted — defeating the entire point
|
|
2530
|
+
// of streaming (`TTFB` regresses from shell-ready to slowest-component).
|
|
2531
|
+
// So in streaming mode we hand React the raw async tree. Head tags
|
|
2532
|
+
// pushed from async components are captured by `renderToStream`'s
|
|
2533
|
+
// `buildHtmlTail` (late-head injection) via `use-head`. The streaming
|
|
2534
|
+
// shell-gen's `collectStreamingHeadTags` pre-pass uses `renderToString`
|
|
2535
|
+
// internally and will throw on async trees — its try/catch already
|
|
2536
|
+
// handles that case and returns an empty string, so the early shell
|
|
2537
|
+
// emission is still correct; the late-head script fills in any
|
|
2538
|
+
// metadata emitted during the async render.
|
|
2539
|
+
if (!useStreaming) {
|
|
2540
|
+
app = (await resolveAsyncElement(app)) as React.ReactElement;
|
|
2541
|
+
}
|
|
2442
2542
|
|
|
2443
2543
|
if (useStreaming) {
|
|
2444
2544
|
const streamingResponse = await renderStreamingResponse(app, {
|
|
@@ -2667,6 +2767,7 @@ async function renderNotFoundPage(
|
|
|
2667
2767
|
let loaderData: unknown = { message };
|
|
2668
2768
|
if (registration.filling?.hasLoader()) {
|
|
2669
2769
|
const ctx = new ManduContext(req, params);
|
|
2770
|
+
attachI18nToContext(ctx, req);
|
|
2670
2771
|
try {
|
|
2671
2772
|
const returned = await registration.filling.executeLoader(ctx);
|
|
2672
2773
|
loaderData = returned !== undefined ? returned : { message };
|
|
@@ -3243,6 +3344,66 @@ async function tryServePrerendered(
|
|
|
3243
3344
|
return new Response(body, { status: 200, headers });
|
|
3244
3345
|
}
|
|
3245
3346
|
|
|
3347
|
+
// ─── Phase 18.μ — request-scoped i18n state ──────────────────────────────
|
|
3348
|
+
/**
|
|
3349
|
+
* Per-request locale state keyed by the `Request` object. The runtime
|
|
3350
|
+
* dispatcher populates this right before `handleRequestInternal` returns
|
|
3351
|
+
* to route-specific paths; `handlePageRoute` / `handleApiRoute` read it
|
|
3352
|
+
* when creating `ManduContext` and attach via `ctx._setI18n(...)`.
|
|
3353
|
+
*
|
|
3354
|
+
* A WeakMap is used so completed requests release their entries
|
|
3355
|
+
* automatically — no explicit cleanup needed on the hot path.
|
|
3356
|
+
*
|
|
3357
|
+
* @internal
|
|
3358
|
+
*/
|
|
3359
|
+
const i18nRequestState = new WeakMap<
|
|
3360
|
+
Request,
|
|
3361
|
+
{ locale: ResolvedLocale; translator?: Translator }
|
|
3362
|
+
>();
|
|
3363
|
+
|
|
3364
|
+
/**
|
|
3365
|
+
* Phase 18.μ — expose the stashed locale state for `filling` /
|
|
3366
|
+
* `handlePageRoute` / `handleApiRoute`. Returns `undefined` when i18n
|
|
3367
|
+
* is disabled OR the request predates dispatch (internal callers).
|
|
3368
|
+
*
|
|
3369
|
+
* @internal
|
|
3370
|
+
*/
|
|
3371
|
+
export function getRequestI18n(
|
|
3372
|
+
req: Request
|
|
3373
|
+
): { locale: ResolvedLocale; translator?: Translator } | undefined {
|
|
3374
|
+
return i18nRequestState.get(req);
|
|
3375
|
+
}
|
|
3376
|
+
|
|
3377
|
+
/**
|
|
3378
|
+
* Phase 18.μ — attach i18n state to a freshly-constructed `ManduContext`.
|
|
3379
|
+
* No-op when i18n is disabled. Called by every callsite that instantiates
|
|
3380
|
+
* `new ManduContext(req, …)` so `ctx.locale` + `ctx.t` are populated
|
|
3381
|
+
* before loader / handler execution.
|
|
3382
|
+
*
|
|
3383
|
+
* @internal
|
|
3384
|
+
*/
|
|
3385
|
+
function attachI18nToContext(ctx: ManduContext, req: Request): void {
|
|
3386
|
+
const state = i18nRequestState.get(req);
|
|
3387
|
+
if (state) {
|
|
3388
|
+
ctx._setI18n(state.locale, state.translator);
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
/**
|
|
3393
|
+
* Helper: returns the URL's first segment if it matches a known locale.
|
|
3394
|
+
* Extracted so the inline μ dispatch block stays readable.
|
|
3395
|
+
*/
|
|
3396
|
+
function stripLocaleForRedirectCheck(
|
|
3397
|
+
pathname: string,
|
|
3398
|
+
locales: readonly string[]
|
|
3399
|
+
): string | undefined {
|
|
3400
|
+
if (pathname.length < 2) return undefined;
|
|
3401
|
+
const slashIdx = pathname.indexOf("/", 1);
|
|
3402
|
+
const first = slashIdx === -1 ? pathname.slice(1) : pathname.slice(1, slashIdx);
|
|
3403
|
+
return locales.includes(first) ? first : undefined;
|
|
3404
|
+
}
|
|
3405
|
+
// ─── End Phase 18.μ ──────────────────────────────────────────────────────
|
|
3406
|
+
|
|
3246
3407
|
async function handleRequestInternal(
|
|
3247
3408
|
req: Request,
|
|
3248
3409
|
router: Router,
|
|
@@ -3272,6 +3433,92 @@ async function handleRequestInternal(
|
|
|
3272
3433
|
return ok(prerendered);
|
|
3273
3434
|
}
|
|
3274
3435
|
|
|
3436
|
+
// ─── Phase 18.μ — i18n dispatch ─────────────────────────────────────────
|
|
3437
|
+
// Runs AFTER γ's prerendered check (static HTML per-locale is already
|
|
3438
|
+
// handled by path-prefix synthesis at build time) and BEFORE ζ's cache
|
|
3439
|
+
// lookup (cache keys include the resolved locale via `Vary:
|
|
3440
|
+
// Accept-Language`).
|
|
3441
|
+
//
|
|
3442
|
+
// 1. Resolve the active locale via `resolveLocale()` — the strategy
|
|
3443
|
+
// switches determines URL / cookie / header precedence.
|
|
3444
|
+
// 2. For `strategy: 'path-prefix'`: when the incoming URL carries no
|
|
3445
|
+
// locale prefix (root or locale-less path) AND the resolution
|
|
3446
|
+
// came from `default` / `fallback`, we emit a 307 redirect to
|
|
3447
|
+
// `/<locale><rest>` so the user lands on a locale-scoped URL.
|
|
3448
|
+
// Cookie / header signals "win" over the default — no redirect
|
|
3449
|
+
// when the user explicitly preferred a non-default locale.
|
|
3450
|
+
// 3. Stash the resolved locale on the `registry.__perRequest` map
|
|
3451
|
+
// indexed by the request so downstream `handlePageRoute` /
|
|
3452
|
+
// `handleApiRoute` can mount it onto `ctx` via `_setI18n()`.
|
|
3453
|
+
//
|
|
3454
|
+
// Zero overhead when `settings.i18n` is undefined — the `if` falls
|
|
3455
|
+
// through and the hot path runs exactly as Phase 18.λ's baseline.
|
|
3456
|
+
let resolvedLocaleForRequest: ResolvedLocale | undefined;
|
|
3457
|
+
let translatorForRequest: Translator | undefined;
|
|
3458
|
+
if (settings.i18n) {
|
|
3459
|
+
resolvedLocaleForRequest = resolveLocale(req, settings.i18n);
|
|
3460
|
+
|
|
3461
|
+
// Path-prefix strategy: URL has no locale prefix AND resolver picked
|
|
3462
|
+
// a non-default locale from cookie/header → redirect to prefixed URL
|
|
3463
|
+
// so the user lands on a locale-scoped URL (Next.js parity).
|
|
3464
|
+
//
|
|
3465
|
+
// Excluded paths (never redirected, always locale-neutral):
|
|
3466
|
+
// - `/api/*` — API routes use `Accept-Language` header; JSON
|
|
3467
|
+
// clients rarely want an HTML redirect.
|
|
3468
|
+
// - `/_mandu/*`, `/.mandu/*` — framework internals.
|
|
3469
|
+
// - `/sitemap.xml`, `/robots.txt`, etc. — metadata routes live
|
|
3470
|
+
// at site root across all locales.
|
|
3471
|
+
if (
|
|
3472
|
+
settings.i18n.strategy === "path-prefix" &&
|
|
3473
|
+
(req.method === "GET" || req.method === "HEAD") &&
|
|
3474
|
+
!pathname.startsWith("/api/") &&
|
|
3475
|
+
!pathname.startsWith("/_mandu/") &&
|
|
3476
|
+
!pathname.startsWith("/.mandu/") &&
|
|
3477
|
+
!pathname.startsWith("/__kitchen") &&
|
|
3478
|
+
!pathname.startsWith("/__mandu/") &&
|
|
3479
|
+
pathname !== "/sitemap.xml" &&
|
|
3480
|
+
pathname !== "/robots.txt" &&
|
|
3481
|
+
pathname !== "/llms.txt" &&
|
|
3482
|
+
pathname !== "/manifest.webmanifest"
|
|
3483
|
+
) {
|
|
3484
|
+
const urlLocale = stripLocaleForRedirectCheck(pathname, settings.i18n.locales);
|
|
3485
|
+
if (!urlLocale && resolvedLocaleForRequest.code !== settings.i18n.defaultLocale) {
|
|
3486
|
+
const targetPath = `/${resolvedLocaleForRequest.code}${pathname === "/" ? "" : pathname}`;
|
|
3487
|
+
const targetUrl = new URL(targetPath + url.search, req.url);
|
|
3488
|
+
const redirectRes = new Response(null, {
|
|
3489
|
+
status: 307,
|
|
3490
|
+
headers: {
|
|
3491
|
+
Location: targetUrl.toString(),
|
|
3492
|
+
Vary: "Accept-Language, Cookie",
|
|
3493
|
+
"Content-Language": resolvedLocaleForRequest.code,
|
|
3494
|
+
},
|
|
3495
|
+
});
|
|
3496
|
+
return ok(redirectRes);
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
|
|
3500
|
+
// Build translator if a registry is wired up.
|
|
3501
|
+
if (settings.messages) {
|
|
3502
|
+
translatorForRequest = createTranslator(settings.messages, {
|
|
3503
|
+
activeLocale: resolvedLocaleForRequest.code,
|
|
3504
|
+
defaultLocale: settings.i18n.defaultLocale,
|
|
3505
|
+
fallbackLocale: settings.i18n.fallback,
|
|
3506
|
+
});
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
// Stash on request-scoped map so downstream context creation (inside
|
|
3510
|
+
// `handlePageRoute` / `handleApiRoute`) can pick them up without
|
|
3511
|
+
// threading extra args through every call site. `i18nRequestState`
|
|
3512
|
+
// is a WeakMap — no memory retention after the request completes.
|
|
3513
|
+
if (resolvedLocaleForRequest) {
|
|
3514
|
+
i18nRequestState.set(req, {
|
|
3515
|
+
locale: resolvedLocaleForRequest,
|
|
3516
|
+
translator: translatorForRequest,
|
|
3517
|
+
});
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
3520
|
+
// ─── End Phase 18.μ ─────────────────────────────────────────────────────
|
|
3521
|
+
|
|
3275
3522
|
// 1. 정적 파일 서빙 시도 (최우선)
|
|
3276
3523
|
// Edge runtimes (Cloudflare Workers, etc.) have no filesystem — skip and
|
|
3277
3524
|
// let the platform's asset pipeline (Wrangler [assets], Vercel _static, …)
|
|
@@ -3425,6 +3672,7 @@ async function handleRequestInternal(
|
|
|
3425
3672
|
let loaderData: unknown = { message: "Not Found" };
|
|
3426
3673
|
if (registration.filling?.hasLoader()) {
|
|
3427
3674
|
const ctx = new ManduContext(req, {});
|
|
3675
|
+
attachI18nToContext(ctx, req);
|
|
3428
3676
|
try {
|
|
3429
3677
|
const returned = await registration.filling.executeLoader(ctx);
|
|
3430
3678
|
loaderData = returned !== undefined ? returned : { message: "Not Found" };
|
|
@@ -3610,8 +3858,26 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
3610
3858
|
middleware: middlewareOption,
|
|
3611
3859
|
rpc: rpcOption,
|
|
3612
3860
|
scheduler: schedulerOption,
|
|
3861
|
+
i18n: i18nOption,
|
|
3862
|
+
messages: messagesOption,
|
|
3613
3863
|
} = options;
|
|
3614
3864
|
|
|
3865
|
+
// Phase 18.μ — validate i18n + messages shape. Both are branded via
|
|
3866
|
+
// `defineI18n()` / `defineMessages()`; reject any raw object so user
|
|
3867
|
+
// typos fail fast at boot instead of on first request.
|
|
3868
|
+
if (i18nOption !== undefined && !isI18nDefinition(i18nOption)) {
|
|
3869
|
+
throw new Error(
|
|
3870
|
+
"[mandu] ServerOptions.i18n must be the return value of defineI18n(). " +
|
|
3871
|
+
"Import { defineI18n } from '@mandujs/core/i18n'."
|
|
3872
|
+
);
|
|
3873
|
+
}
|
|
3874
|
+
if (messagesOption !== undefined && !isMessageRegistry(messagesOption)) {
|
|
3875
|
+
throw new Error(
|
|
3876
|
+
"[mandu] ServerOptions.messages must be the return value of defineMessages(). " +
|
|
3877
|
+
"Import { defineMessages } from '@mandujs/core/i18n'."
|
|
3878
|
+
);
|
|
3879
|
+
}
|
|
3880
|
+
|
|
3615
3881
|
// Phase 18.ε — build the request-level middleware chain once at boot.
|
|
3616
3882
|
// `compose()` returns a passthrough when the list is empty; storing
|
|
3617
3883
|
// `undefined` for "no middleware" keeps the hot path branch-free.
|
|
@@ -3692,6 +3958,8 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
3692
3958
|
tracer: tracerInstance.enabled ? tracerInstance : undefined,
|
|
3693
3959
|
prerender: prerenderSettings,
|
|
3694
3960
|
middlewareChain,
|
|
3961
|
+
i18n: i18nOption,
|
|
3962
|
+
messages: messagesOption,
|
|
3695
3963
|
};
|
|
3696
3964
|
|
|
3697
3965
|
registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -590,6 +590,15 @@ export async function resolveAsyncElement(node: ReactNode): Promise<ReactNode> {
|
|
|
590
590
|
// Clone with the resolved children. React.cloneElement preserves the
|
|
591
591
|
// element's key, ref, and internal `$$typeof` markers — a plain spread
|
|
592
592
|
// does not.
|
|
593
|
+
//
|
|
594
|
+
// #212 — when resolvedChildren is an array of siblings, spread it back
|
|
595
|
+
// to the variadic form so React's variadic-children heuristic kicks in
|
|
596
|
+
// and does NOT demand keys (the original JSX was variadic too — no key
|
|
597
|
+
// was required). Passing a single array argument would trigger spurious
|
|
598
|
+
// "missing key" warnings for every sibling.
|
|
599
|
+
if (Array.isArray(resolvedChildren)) {
|
|
600
|
+
return React.cloneElement(element, undefined, ...resolvedChildren);
|
|
601
|
+
}
|
|
593
602
|
return React.cloneElement(element, undefined, resolvedChildren);
|
|
594
603
|
}
|
|
595
604
|
|