@pithy-sh/i18n 0.1.2 → 0.1.3
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/adapters/adapters.d.ts +47 -0
- package/dist/adapters/adapters.d.ts.map +1 -0
- package/dist/adapters/adapters.js +87 -0
- package/dist/browser/document.d.ts +36 -0
- package/dist/browser/document.d.ts.map +1 -0
- package/dist/browser/document.js +85 -0
- package/dist/browser/signals.d.ts +36 -0
- package/dist/browser/signals.d.ts.map +1 -0
- package/dist/browser/signals.js +70 -0
- package/dist/capability.d.ts +53 -0
- package/dist/capability.d.ts.map +1 -0
- package/dist/capability.js +110 -0
- package/dist/catalogs/browser.d.ts +13 -0
- package/dist/catalogs/browser.d.ts.map +1 -0
- package/dist/catalogs/browser.js +33 -0
- package/dist/catalogs/es/errors.d.ts +30 -0
- package/dist/catalogs/es/errors.d.ts.map +1 -0
- package/dist/catalogs/es/errors.js +154 -0
- package/dist/catalogs/es/index.d.ts +23 -0
- package/dist/catalogs/es/index.d.ts.map +1 -0
- package/dist/catalogs/es/index.js +29 -0
- package/dist/catalogs/es/screens.d.ts +28 -0
- package/dist/catalogs/es/screens.d.ts.map +1 -0
- package/dist/catalogs/es/screens.js +105 -0
- package/dist/catalogs/kit.d.ts +21 -0
- package/dist/catalogs/kit.d.ts.map +1 -0
- package/dist/catalogs/kit.js +23 -0
- package/dist/client/projection.d.ts +47 -0
- package/dist/client/projection.d.ts.map +1 -0
- package/dist/client/projection.js +0 -0
- package/dist/config/config.d.ts +71 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +103 -0
- package/dist/http/middleware.d.ts +36 -0
- package/dist/http/middleware.d.ts.map +1 -0
- package/dist/http/middleware.js +93 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/react/translator.d.ts +73 -0
- package/dist/react/translator.d.ts.map +1 -0
- package/dist/react/translator.js +81 -0
- package/dist/react/useNegotiatedLocale.d.ts +67 -0
- package/dist/react/useNegotiatedLocale.d.ts.map +1 -0
- package/dist/react/useNegotiatedLocale.js +108 -0
- package/dist/resolve/browser.d.ts +69 -0
- package/dist/resolve/browser.d.ts.map +1 -0
- package/dist/resolve/browser.js +62 -0
- package/dist/resolve/chain.d.ts +52 -0
- package/dist/resolve/chain.d.ts.map +1 -0
- package/dist/resolve/chain.js +49 -0
- package/dist/resolve/server.d.ts +31 -0
- package/dist/resolve/server.d.ts.map +1 -0
- package/dist/resolve/server.js +32 -0
- package/dist/settings/coverage.d.ts +21 -0
- package/dist/settings/coverage.d.ts.map +1 -0
- package/dist/settings/coverage.js +48 -0
- package/dist/version.generated.d.ts +5 -0
- package/dist/version.generated.d.ts.map +1 -0
- package/dist/version.generated.js +7 -0
- package/package.json +18 -10
- package/src/version.generated.ts +1 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { LocaleCatalogs } from "@pithy-sh/core/src/i18n/catalog";
|
|
2
|
+
import { Locale } from "@pithy-sh/core/src/i18n/locale";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
//#region src/config/config.ts
|
|
5
|
+
/**
|
|
6
|
+
* One link of the **server** resolver chain — the order a Worker asks "what language is this request in?".
|
|
7
|
+
*
|
|
8
|
+
* Split from the browser chain because `localStorage` does not exist in a Worker and
|
|
9
|
+
* `navigator.language` inside workerd is the constant `"en"`, carrying no request information at all.
|
|
10
|
+
* Two chains over one contract is honest; one chain with half its links inert is not.
|
|
11
|
+
*/
|
|
12
|
+
const ServerResolver = z.enum([
|
|
13
|
+
"param",
|
|
14
|
+
"user",
|
|
15
|
+
"cookie",
|
|
16
|
+
"header",
|
|
17
|
+
"default"
|
|
18
|
+
]).describe("A link of the server resolver chain: an explicit param, the account, a cookie, the header, the default.");
|
|
19
|
+
/**
|
|
20
|
+
* One link of the **browser** resolver chain.
|
|
21
|
+
*
|
|
22
|
+
* `account` sits above `storage` deliberately: `pithy_auth_users.locale` is where a person's locale
|
|
23
|
+
* lives, so a signed-in reader's choice must not silently diverge per device. See `docs/I18N.md`.
|
|
24
|
+
*/
|
|
25
|
+
const BrowserResolver = z.enum([
|
|
26
|
+
"query",
|
|
27
|
+
"account",
|
|
28
|
+
"storage",
|
|
29
|
+
"navigator",
|
|
30
|
+
"server",
|
|
31
|
+
"default"
|
|
32
|
+
]).describe("A link of the browser resolver chain: `?lang=`, the account, local storage, the browser's own languages, the server, the default.");
|
|
33
|
+
/** The server chain's default order — explicit param, account, cookie, `Accept-Language`, project default. */
|
|
34
|
+
const DEFAULT_SERVER_RESOLVERS = [
|
|
35
|
+
"param",
|
|
36
|
+
"user",
|
|
37
|
+
"cookie",
|
|
38
|
+
"header",
|
|
39
|
+
"default"
|
|
40
|
+
];
|
|
41
|
+
/** The browser chain's default order — `?lang=`, account, local storage, the server's answer, default. */
|
|
42
|
+
/**
|
|
43
|
+
* The browser chain's default order — `?lang=`, account, this device's memory, the reader's own
|
|
44
|
+
* browser languages, what the server declared, the project default.
|
|
45
|
+
*
|
|
46
|
+
* **`navigator` sits above `server`, and that ordering is the whole reason the link exists.** The
|
|
47
|
+
* `server` link reads `document.documentElement.lang`, and a scaffolded SPA is served from a static
|
|
48
|
+
* `index.html` that says `lang="en"` with no substitution token — so without a browser link, a
|
|
49
|
+
* first-time Spanish visitor to a project shipping `es` resolved to the default and read English on
|
|
50
|
+
* every screen. Automatic negotiation worked on the server and nowhere else.
|
|
51
|
+
*
|
|
52
|
+
* Placing it above `server` costs a genuinely server-rendered app nothing: if that server negotiated,
|
|
53
|
+
* it negotiated from `Accept-Language`, which is the same preference `navigator.languages` reports —
|
|
54
|
+
* and anything it knew that the browser does not, the reader's account, is already ranked higher.
|
|
55
|
+
*/
|
|
56
|
+
const DEFAULT_BROWSER_RESOLVERS = [
|
|
57
|
+
"query",
|
|
58
|
+
"account",
|
|
59
|
+
"storage",
|
|
60
|
+
"navigator",
|
|
61
|
+
"server",
|
|
62
|
+
"default"
|
|
63
|
+
];
|
|
64
|
+
/** The locale the kit writes in, and what every unconfigured project falls back to. */
|
|
65
|
+
const DEFAULT_LOCALE = "en";
|
|
66
|
+
/**
|
|
67
|
+
* The i18n capability's configuration.
|
|
68
|
+
*
|
|
69
|
+
* Everything an adopter decides about language lives here, including their own catalogs — **a config
|
|
70
|
+
* object passed to the capability, never files discovered on disk.** Two reasons, and both are hard
|
|
71
|
+
* constraints rather than preferences. `configEntrypoints.test.ts` imports every capability's config
|
|
72
|
+
* entry point in a plain Node process, so a catalog loader doing `readFileSync` at module scope breaks
|
|
73
|
+
* `pithy upgrade`, `migrate` and `deploy` for every project composing it. And a catalog file copied
|
|
74
|
+
* into an adopter's repository is a fork on the day they scaffold: a typo fix or a new locale could
|
|
75
|
+
* never reach them again.
|
|
76
|
+
*/
|
|
77
|
+
const I18nConfig = z.object({
|
|
78
|
+
supportedLocales: z.array(Locale).min(1).default([DEFAULT_LOCALE]).describe("Every locale this project serves. Ship the least specific tag that is true — `es`, not `es-ES`."),
|
|
79
|
+
defaultLocale: Locale.default(DEFAULT_LOCALE).describe("The locale served when nothing else answers. Must be one of `supportedLocales`."),
|
|
80
|
+
messages: LocaleCatalogs.default({}).describe("This project's own catalogs. Overriding one kit key is one entry; everything unmentioned keeps flowing from the package."),
|
|
81
|
+
exceptions: z.record(z.string().describe("The language range a reader sends that no truncation of it would match."), Locale.describe("The supported locale it means.")).default({}).transform((exceptions) => Object.fromEntries(Object.entries(exceptions).map(([range, locale]) => [range.toLowerCase(), locale]))).describe("Language ranges the matcher cannot derive — historical pairs like `nb` meaning `no`."),
|
|
82
|
+
cookie: z.string().min(1).default("pithy_locale").describe("The cookie the server chain's `cookie` link reads a chosen locale from."),
|
|
83
|
+
queryParam: z.string().min(1).default("lang").describe("The query parameter an explicit choice arrives on, on both the server and the browser."),
|
|
84
|
+
storageKey: z.string().min(1).default("pithy.locale").describe("The `localStorage` key the browser chain's `storage` link reads and writes."),
|
|
85
|
+
serverResolvers: z.array(ServerResolver).min(1).default([...DEFAULT_SERVER_RESOLVERS]).describe("The server chain, in the order it is asked. Reorder or shorten it; `default` is the last resort."),
|
|
86
|
+
browserResolvers: z.array(BrowserResolver).min(1).default([...DEFAULT_BROWSER_RESOLVERS]).describe("The browser chain, in the order it is asked.")
|
|
87
|
+
}).check((ctx) => {
|
|
88
|
+
const { supportedLocales, defaultLocale } = ctx.value;
|
|
89
|
+
if (!supportedLocales.includes(defaultLocale)) ctx.issues.push({
|
|
90
|
+
code: "custom",
|
|
91
|
+
input: ctx.value,
|
|
92
|
+
path: ["defaultLocale"],
|
|
93
|
+
message: `\`${defaultLocale}\` is the default locale and is not in \`supportedLocales\`, so nothing would answer when negotiation fails.`
|
|
94
|
+
});
|
|
95
|
+
for (const locale of Object.keys(ctx.value.messages)) if (!supportedLocales.includes(locale)) ctx.issues.push({
|
|
96
|
+
code: "custom",
|
|
97
|
+
input: ctx.value,
|
|
98
|
+
path: ["messages", locale],
|
|
99
|
+
message: `\`${locale}\` carries messages and is not in \`supportedLocales\`, so nothing would ever read them.`
|
|
100
|
+
});
|
|
101
|
+
}).describe("How this project negotiates and renders language, and the catalogs it adds of its own.");
|
|
102
|
+
//#endregion
|
|
103
|
+
export { BrowserResolver, I18nConfig, ServerResolver };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { PithyMiddleware } from "@pithy-sh/core/src/capability/capability";
|
|
2
|
+
import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
|
|
3
|
+
import type { I18nConfig } from "../config/config";
|
|
4
|
+
/** How a request's locale is looked up, given the locale that won. Supplied by the capability. */
|
|
5
|
+
export type LayersFor = (locale: string) => readonly (MessageCatalog | undefined)[];
|
|
6
|
+
/**
|
|
7
|
+
* The one middleware this capability contributes: resolve the request's locale, and put a real
|
|
8
|
+
* translator on it.
|
|
9
|
+
*
|
|
10
|
+
* It **replaces** `c.var.t` rather than filling a null. Core seeds a translator over the baked English
|
|
11
|
+
* for every request whether or not this capability is composed, which is what lets every capability
|
|
12
|
+
* call `c.var.t(...)` with no null check, and what makes a project that never opts in behave byte for
|
|
13
|
+
* byte as it did before.
|
|
14
|
+
*
|
|
15
|
+
* A fresh translator per request, never a cached one. A Worker isolate is reused across requests from
|
|
16
|
+
* different readers, so anything holding a locale across that boundary applies request A's language to
|
|
17
|
+
* request B. It is the same hazard `z.config()` is banned repo-wide for, and it is why the `Intl`
|
|
18
|
+
* formatters are held on the translator — which lives and dies with one request — rather than in a
|
|
19
|
+
* module-level cache.
|
|
20
|
+
*
|
|
21
|
+
* ## Why the query string is read off the URL here, and not through `zValidator`
|
|
22
|
+
*
|
|
23
|
+
* §HTTP's rule is that a route declares its request contract on the route line and a handler reads
|
|
24
|
+
* `c.req.valid(target)`; `plugins/no-raw-request-input.grit` enforces it by making the raw accessors
|
|
25
|
+
* unreadable under every capability's `src/http/` tree. This is a **global middleware over every route**, so there
|
|
26
|
+
* is no route line to declare a validator on and no handler to hand a typed value to — the rule's
|
|
27
|
+
* replacement does not exist at this position.
|
|
28
|
+
*
|
|
29
|
+
* What the rule is *for* still holds, and it holds structurally rather than by care: the value is one
|
|
30
|
+
* language range among four sources, every one of them is matched against `config.supportedLocales`
|
|
31
|
+
* before anything is done with it, and a range that matches nothing falls through to the next link.
|
|
32
|
+
* Nothing downstream ever sees a string this middleware did not already find in the project's own
|
|
33
|
+
* configured set. A reader who sends `?lang=<script>` gets the default locale.
|
|
34
|
+
*/
|
|
35
|
+
export declare function i18nMiddleware(config: I18nConfig, layersFor: LayersFor): PithyMiddleware;
|
|
36
|
+
//# sourceMappingURL=middleware.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/http/middleware.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0CAA0C,CAAC;AAChF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAGtE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAInD,kGAAkG;AAClG,MAAM,MAAM,SAAS,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,SAAS,CAAC,cAAc,GAAG,SAAS,CAAC,EAAE,CAAC;AAEpF;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,GAAG,eAAe,CAwExF"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { resolveServerLocale } from "../resolve/server.js";
|
|
2
|
+
import { createTranslator } from "@pithy-sh/core/src/i18n/translator";
|
|
3
|
+
import { getCookie } from "hono/cookie";
|
|
4
|
+
//#region src/http/middleware.ts
|
|
5
|
+
/**
|
|
6
|
+
* The one middleware this capability contributes: resolve the request's locale, and put a real
|
|
7
|
+
* translator on it.
|
|
8
|
+
*
|
|
9
|
+
* It **replaces** `c.var.t` rather than filling a null. Core seeds a translator over the baked English
|
|
10
|
+
* for every request whether or not this capability is composed, which is what lets every capability
|
|
11
|
+
* call `c.var.t(...)` with no null check, and what makes a project that never opts in behave byte for
|
|
12
|
+
* byte as it did before.
|
|
13
|
+
*
|
|
14
|
+
* A fresh translator per request, never a cached one. A Worker isolate is reused across requests from
|
|
15
|
+
* different readers, so anything holding a locale across that boundary applies request A's language to
|
|
16
|
+
* request B. It is the same hazard `z.config()` is banned repo-wide for, and it is why the `Intl`
|
|
17
|
+
* formatters are held on the translator — which lives and dies with one request — rather than in a
|
|
18
|
+
* module-level cache.
|
|
19
|
+
*
|
|
20
|
+
* ## Why the query string is read off the URL here, and not through `zValidator`
|
|
21
|
+
*
|
|
22
|
+
* §HTTP's rule is that a route declares its request contract on the route line and a handler reads
|
|
23
|
+
* `c.req.valid(target)`; `plugins/no-raw-request-input.grit` enforces it by making the raw accessors
|
|
24
|
+
* unreadable under every capability's `src/http/` tree. This is a **global middleware over every route**, so there
|
|
25
|
+
* is no route line to declare a validator on and no handler to hand a typed value to — the rule's
|
|
26
|
+
* replacement does not exist at this position.
|
|
27
|
+
*
|
|
28
|
+
* What the rule is *for* still holds, and it holds structurally rather than by care: the value is one
|
|
29
|
+
* language range among four sources, every one of them is matched against `config.supportedLocales`
|
|
30
|
+
* before anything is done with it, and a range that matches nothing falls through to the next link.
|
|
31
|
+
* Nothing downstream ever sees a string this middleware did not already find in the project's own
|
|
32
|
+
* configured set. A reader who sends `?lang=<script>` gets the default locale.
|
|
33
|
+
*/
|
|
34
|
+
function i18nMiddleware(config, layersFor) {
|
|
35
|
+
return (app) => {
|
|
36
|
+
app.use("*", async (c, next) => {
|
|
37
|
+
let resolved;
|
|
38
|
+
const locale = () => resolved ??= resolveServerLocale({
|
|
39
|
+
param: queryParam(c.req.url, config.queryParam),
|
|
40
|
+
user: c.var.auth?.locale ?? null,
|
|
41
|
+
cookie: getCookie(c, config.cookie) ?? null,
|
|
42
|
+
header: c.req.header("accept-language") ?? null
|
|
43
|
+
}, config);
|
|
44
|
+
let translator;
|
|
45
|
+
const t = () => translator ??= createTranslator({
|
|
46
|
+
catalogLocale: locale().catalogLocale,
|
|
47
|
+
formattingLocale: locale().formattingLocale,
|
|
48
|
+
layers: layersFor(locale().catalogLocale)
|
|
49
|
+
});
|
|
50
|
+
c.set("locale", {
|
|
51
|
+
get catalogLocale() {
|
|
52
|
+
return locale().catalogLocale;
|
|
53
|
+
},
|
|
54
|
+
get formattingLocale() {
|
|
55
|
+
return locale().formattingLocale;
|
|
56
|
+
},
|
|
57
|
+
get direction() {
|
|
58
|
+
return locale().direction;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
c.set("t", {
|
|
62
|
+
get catalogLocale() {
|
|
63
|
+
return t().catalogLocale;
|
|
64
|
+
},
|
|
65
|
+
get formattingLocale() {
|
|
66
|
+
return t().formattingLocale;
|
|
67
|
+
},
|
|
68
|
+
get direction() {
|
|
69
|
+
return t().direction;
|
|
70
|
+
},
|
|
71
|
+
t: (key, params) => t().t(key, params),
|
|
72
|
+
maybe: (key, params) => t().maybe(key, params),
|
|
73
|
+
plural: (key, count, params) => t().plural(key, count, params),
|
|
74
|
+
formatNumber: (value, options) => t().formatNumber(value, options),
|
|
75
|
+
formatCurrency: (value, currency, options) => t().formatCurrency(value, currency, options),
|
|
76
|
+
formatDate: (value, options) => t().formatDate(value, options),
|
|
77
|
+
formatList: (values, options) => t().formatList(values, options),
|
|
78
|
+
formatRelativeTime: (value, unit, options) => t().formatRelativeTime(value, unit, options)
|
|
79
|
+
});
|
|
80
|
+
await next();
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** One query parameter off a request URL, or `null` — total, so a URL Hono accepted cannot throw here. */
|
|
85
|
+
function queryParam(url, name) {
|
|
86
|
+
try {
|
|
87
|
+
return new URL(url).searchParams.get(name);
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
export { i18nMiddleware };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The package entrypoint — the surface `pithy add i18n` wires into `pithy.config.ts`. Deliberately
|
|
3
|
+
* narrow: the capability factory plus the config types an app declares.
|
|
4
|
+
*
|
|
5
|
+
* The React bindings are a first-class public API of this package and are imported by deep path
|
|
6
|
+
* (`@pithy-sh/i18n/src/react/translator`) at the component that mounts them — the documented contract,
|
|
7
|
+
* not a barrel over the package, and what keeps `react` off the Worker program's import graph. The
|
|
8
|
+
* adapters (`@pithy-sh/i18n/src/adapters/*`) and the browser helpers
|
|
9
|
+
* (`@pithy-sh/i18n/src/browser/signals`) are reached the same way.
|
|
10
|
+
*
|
|
11
|
+
* `Translator` itself lives in `@pithy-sh/core/src/i18n/translator` and is importable **without
|
|
12
|
+
* composing this capability**, so an adopter's own module can type against the seam whether or not
|
|
13
|
+
* they ever opt in.
|
|
14
|
+
*/
|
|
15
|
+
export { type I18nCapability, i18n, isI18nCapability } from "./capability";
|
|
16
|
+
export { BrowserResolver, type I18nConfig, type I18nConfigInput, ServerResolver } from "./config/config";
|
|
17
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,KAAK,cAAc,EAAE,IAAI,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAC3E,OAAO,EAAE,eAAe,EAAE,KAAK,UAAU,EAAE,KAAK,eAAe,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
|
|
2
|
+
import type { Translator } from "@pithy-sh/core/src/i18n/translator";
|
|
3
|
+
import { type ReactNode } from "react";
|
|
4
|
+
/**
|
|
5
|
+
* The React bindings — **a first-class public API of this package**, not something the kit's own
|
|
6
|
+
* templates happen to import.
|
|
7
|
+
*
|
|
8
|
+
* An adopter rendering entirely their own screens has to be able to consume the seam without ever
|
|
9
|
+
* touching a kit template, and after the first day most of them are: the screens are *copied*, so
|
|
10
|
+
* `pithy ui add` reaches a project scaffolded after this landed and no command retrofits one that was
|
|
11
|
+
* not. The seam is what an already-scaffolded adopter consumes. So this is the deliverable, and the
|
|
12
|
+
* templates are one of its consumers.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* What the provider carries: the two locales, and the catalog layers to walk.
|
|
16
|
+
*
|
|
17
|
+
* **The pieces, not a finished `Translator`.** A screen supplies the English it was scaffolded with as
|
|
18
|
+
* a final fallback layer, and that layer can only be appended if the layers are still a list. Handing
|
|
19
|
+
* the context a built translator would make a screen's own English unreachable — which is the one
|
|
20
|
+
* thing that has to keep working, because it is the only catalog that survives being copied.
|
|
21
|
+
*/
|
|
22
|
+
export interface TranslatorSource {
|
|
23
|
+
/** The locale whose catalog answers `t()`. */
|
|
24
|
+
readonly catalogLocale: string;
|
|
25
|
+
/** The locale handed to `Intl`. May be more specific than the catalog locale — `es-AR` over `es`. */
|
|
26
|
+
readonly formattingLocale: string;
|
|
27
|
+
/** The catalogs to walk, most-specific first: the adopter's, then the kit's translation. */
|
|
28
|
+
readonly layers: readonly (MessageCatalog | undefined)[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* What a provider may be given: the pieces, or a translator somebody else already built.
|
|
32
|
+
*
|
|
33
|
+
* The second arm is what makes the adapters reachable. `fromI18next`, `fromIntl` and `fromLingui`
|
|
34
|
+
* return a `Translator` — an adopter's whole message layer, already resolved — and with only the
|
|
35
|
+
* pieces arm they had nowhere to go: an adapted translator could be passed screen by screen as a `t`
|
|
36
|
+
* prop, and `useTranslator()` in the adopter's *own* components would never see it. A stack you plug
|
|
37
|
+
* in that only half the tree can read is not plugged in.
|
|
38
|
+
*/
|
|
39
|
+
export type TranslatorValue = TranslatorSource | Translator;
|
|
40
|
+
/** Mount a resolved locale over a subtree. Everything under it reads the same translator. */
|
|
41
|
+
export declare function TranslatorProvider({ value, children }: {
|
|
42
|
+
value: TranslatorValue;
|
|
43
|
+
children: ReactNode;
|
|
44
|
+
}): import("react").JSX.Element;
|
|
45
|
+
/**
|
|
46
|
+
* The translator for this subtree, with `fallback` as its last layer.
|
|
47
|
+
*
|
|
48
|
+
* **Never throws when there is no provider**, and that is deliberate rather than lenient. A screen
|
|
49
|
+
* copied into an adopter's repository renders in a project that may not compose `i18n` at all, and in
|
|
50
|
+
* that project it must render the English it was scaffolded with, byte for byte. With no provider this
|
|
51
|
+
* is exactly a `bakedTranslator` over `fallback` — no negotiation, no merge, no config.
|
|
52
|
+
*
|
|
53
|
+
* With a provider, `fallback` goes **last**: the adopter's own catalog, then the kit's translation,
|
|
54
|
+
* then the screen's baked English. So a key nobody translated still renders a sentence.
|
|
55
|
+
*
|
|
56
|
+
* That holds for a provider given a finished `Translator` too — an adapted i18next, FormatJS or Lingui
|
|
57
|
+
* instance answers first and the screen's own English is behind it, so plugging your stack in never
|
|
58
|
+
* costs you the sentences the kit already wrote.
|
|
59
|
+
*/
|
|
60
|
+
export declare function useTranslator(fallback?: MessageCatalog): Translator;
|
|
61
|
+
/**
|
|
62
|
+
* The prop every screen that renders copy takes.
|
|
63
|
+
*
|
|
64
|
+
* Optional, and injected the same way `fetch` and `redirect` already are on the kit's screens: a
|
|
65
|
+
* rendered fact no assertion about source text can reach is what earns a prop there, and a
|
|
66
|
+
* locale-dependent render is exactly that. A screen with no `t` passed reads the context, and with no
|
|
67
|
+
* context reads its own English.
|
|
68
|
+
*/
|
|
69
|
+
export interface TranslatorProp {
|
|
70
|
+
/** The translator this screen renders through. Defaults to the context, then to the baked English. */
|
|
71
|
+
t?: Translator;
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=translator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"translator.d.ts","sourceRoot":"","sources":["../../src/react/translator.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAe,KAAK,cAAc,EAAE,MAAM,iCAAiC,CAAC;AACnF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oCAAoC,CAAC;AAErE,OAAO,EAAiB,KAAK,SAAS,EAAuB,MAAM,OAAO,CAAC;AAE3E;;;;;;;;;GASG;AAEH;;;;;;;GAOG;AACH,MAAM,WAAW,gBAAgB;IAC/B,8CAA8C;IAC9C,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,qGAAqG;IACrG,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,4FAA4F;IAC5F,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC,cAAc,GAAG,SAAS,CAAC,EAAE,CAAC;CAC1D;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,eAAe,GAAG,gBAAgB,GAAG,UAAU,CAAC;AA2C5D,6FAA6F;AAC7F,wBAAgB,kBAAkB,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAEtG;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,aAAa,CAAC,QAAQ,CAAC,EAAE,cAAc,GAAG,UAAU,CAWnE;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,cAAc;IAC7B,sGAAsG;IACtG,CAAC,CAAC,EAAE,UAAU,CAAC;CAChB"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { bakedTranslator, createTranslator } from "@pithy-sh/core/src/i18n/translator";
|
|
2
|
+
import { interpolate } from "@pithy-sh/core/src/i18n/catalog";
|
|
3
|
+
import { createContext, useContext, useMemo } from "react";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
//#region src/react/translator.tsx
|
|
6
|
+
const TranslatorContext = createContext(null);
|
|
7
|
+
/** Whether a provider was handed the pieces rather than a finished translator. */
|
|
8
|
+
function isSource(value) {
|
|
9
|
+
return "layers" in value;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* `primary` first, then `fallback` — the screen's own English behind somebody else's message layer.
|
|
13
|
+
*
|
|
14
|
+
* Built by delegation rather than by spreading `primary`, because a `Translator` may carry getters
|
|
15
|
+
* (the request-scoped one does) and spreading would evaluate them once, here, freezing the answer.
|
|
16
|
+
*
|
|
17
|
+
* `t` and `maybe` consult the fallback because `maybe` is exactly the miss signal they need. `plural`
|
|
18
|
+
* has a weaker one — it answers the key on a miss, which is the documented contract — so it falls back
|
|
19
|
+
* only when it sees that, and an adapter whose library selects plurals itself (i18next does) keeps
|
|
20
|
+
* answering for every key it knows.
|
|
21
|
+
*/
|
|
22
|
+
function overlay(primary, fallback) {
|
|
23
|
+
const own = (key, params) => Object.hasOwn(fallback, key) ? interpolate(fallback[key], params) : null;
|
|
24
|
+
return {
|
|
25
|
+
catalogLocale: primary.catalogLocale,
|
|
26
|
+
formattingLocale: primary.formattingLocale,
|
|
27
|
+
direction: primary.direction,
|
|
28
|
+
t: (key, params) => primary.maybe(key, params) ?? own(key, params) ?? key,
|
|
29
|
+
maybe: (key, params) => primary.maybe(key, params) ?? own(key, params),
|
|
30
|
+
plural: (key, count, params) => {
|
|
31
|
+
const answered = primary.plural(key, count, params);
|
|
32
|
+
if (answered !== key) return answered;
|
|
33
|
+
const withCount = {
|
|
34
|
+
count,
|
|
35
|
+
...params
|
|
36
|
+
};
|
|
37
|
+
return own(`${key}.other`, withCount) ?? own(key, withCount) ?? key;
|
|
38
|
+
},
|
|
39
|
+
formatNumber: (value, options) => primary.formatNumber(value, options),
|
|
40
|
+
formatCurrency: (value, currency, options) => primary.formatCurrency(value, currency, options),
|
|
41
|
+
formatDate: (value, options) => primary.formatDate(value, options),
|
|
42
|
+
formatList: (values, options) => primary.formatList(values, options),
|
|
43
|
+
formatRelativeTime: (value, unit, options) => primary.formatRelativeTime(value, unit, options)
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Mount a resolved locale over a subtree. Everything under it reads the same translator. */
|
|
47
|
+
function TranslatorProvider({ value, children }) {
|
|
48
|
+
return /* @__PURE__ */ jsx(TranslatorContext.Provider, {
|
|
49
|
+
value,
|
|
50
|
+
children
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The translator for this subtree, with `fallback` as its last layer.
|
|
55
|
+
*
|
|
56
|
+
* **Never throws when there is no provider**, and that is deliberate rather than lenient. A screen
|
|
57
|
+
* copied into an adopter's repository renders in a project that may not compose `i18n` at all, and in
|
|
58
|
+
* that project it must render the English it was scaffolded with, byte for byte. With no provider this
|
|
59
|
+
* is exactly a `bakedTranslator` over `fallback` — no negotiation, no merge, no config.
|
|
60
|
+
*
|
|
61
|
+
* With a provider, `fallback` goes **last**: the adopter's own catalog, then the kit's translation,
|
|
62
|
+
* then the screen's baked English. So a key nobody translated still renders a sentence.
|
|
63
|
+
*
|
|
64
|
+
* That holds for a provider given a finished `Translator` too — an adapted i18next, FormatJS or Lingui
|
|
65
|
+
* instance answers first and the screen's own English is behind it, so plugging your stack in never
|
|
66
|
+
* costs you the sentences the kit already wrote.
|
|
67
|
+
*/
|
|
68
|
+
function useTranslator(fallback) {
|
|
69
|
+
const source = useContext(TranslatorContext);
|
|
70
|
+
return useMemo(() => {
|
|
71
|
+
if (!source) return bakedTranslator(fallback ?? {});
|
|
72
|
+
if (!isSource(source)) return fallback ? overlay(source, fallback) : source;
|
|
73
|
+
return createTranslator({
|
|
74
|
+
catalogLocale: source.catalogLocale,
|
|
75
|
+
formattingLocale: source.formattingLocale,
|
|
76
|
+
layers: fallback ? [...source.layers, fallback] : source.layers
|
|
77
|
+
});
|
|
78
|
+
}, [source, fallback]);
|
|
79
|
+
}
|
|
80
|
+
//#endregion
|
|
81
|
+
export { TranslatorProvider, useTranslator };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
|
|
2
|
+
import type { I18nClientProjection } from "../client/projection";
|
|
3
|
+
import type { TranslatorSource } from "./translator";
|
|
4
|
+
/** What the hook hands back: what to mount, what was chosen, and how to choose again. */
|
|
5
|
+
export interface NegotiatedLocale {
|
|
6
|
+
/**
|
|
7
|
+
* Pass this straight to `TranslatorProvider`. `null` until the locale's catalog has loaded — and
|
|
8
|
+
* permanently `null` for a project with no `i18n` composed, which is the same branch and needs no
|
|
9
|
+
* second one: render the children untouched and every screen keeps its baked English.
|
|
10
|
+
*/
|
|
11
|
+
readonly source: TranslatorSource | null;
|
|
12
|
+
/** The catalog locale in force — the words the app has. */
|
|
13
|
+
readonly locale: string;
|
|
14
|
+
/** Choose a language. Remembers it on this device and puts `lang`/`dir` on the document. */
|
|
15
|
+
readonly choose: (locale: string) => void;
|
|
16
|
+
}
|
|
17
|
+
/** What the caller knows that the browser cannot work out for itself. */
|
|
18
|
+
export interface NegotiatedLocaleOptions {
|
|
19
|
+
/** The signed-in reader's stored locale, when the app knows it. Outranks this device's memory. */
|
|
20
|
+
readonly account?: string | null;
|
|
21
|
+
/** The adopter's own catalogs, keyed by locale — the top layer, above the kit's translation. */
|
|
22
|
+
readonly messages?: Readonly<Record<string, MessageCatalog | undefined>>;
|
|
23
|
+
/**
|
|
24
|
+
* Write a signed-in reader's choice through to their account. Omit it and a choice is remembered on
|
|
25
|
+
* this device only.
|
|
26
|
+
*
|
|
27
|
+
* **A seam rather than something this package does for you, because it cannot.** `pithy_auth_users.locale`
|
|
28
|
+
* is written through `updateUser` (`@pithy-sh/auth/src/client/api`) — a call `@pithy-sh/auth` owns and
|
|
29
|
+
* this package never imports. What it can do is call you at the moment the choice is made.
|
|
30
|
+
*
|
|
31
|
+
* It matters more than a convenience: `account` outranks `storage` in the browser chain precisely so
|
|
32
|
+
* a reader who picks Spanish on their phone is not reading French on their laptop. Without the
|
|
33
|
+
* write-through, that ordering describes a value nothing updates, and the second device keeps
|
|
34
|
+
* answering from its own older memory forever. Pass this whenever a reader is signed in.
|
|
35
|
+
*
|
|
36
|
+
* **Failures are yours to handle, inside this function.** It is called and not awaited, and a
|
|
37
|
+
* rejection is caught rather than reported: the reader already has the language they asked for and
|
|
38
|
+
* the device already remembers it, so a failed preference write must not become an unhandled
|
|
39
|
+
* rejection in their console or an error in your Worker. If a dropped write is worth knowing about,
|
|
40
|
+
* catch it here where you know what your API meant.
|
|
41
|
+
*/
|
|
42
|
+
readonly persist?: (locale: string) => void | Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Negotiate the reader's locale in the browser, load that locale's catalog, and keep the document in
|
|
46
|
+
* step.
|
|
47
|
+
*
|
|
48
|
+
* **It takes what a browser holds.** `projection` is `virtual:pithy/i18n` — locale metadata and nothing
|
|
49
|
+
* else — so a screen reads the chain order **this project** configured rather than one the front end
|
|
50
|
+
* assumed, and no page is asked for the catalogs, the cookie name or the server chain that only a
|
|
51
|
+
* Worker has. Your own catalogs reach it through `options.messages`, above the kit's translation.
|
|
52
|
+
*
|
|
53
|
+
* The catalog arrives by dynamic import, one Vite chunk per locale, so a reader downloads only their
|
|
54
|
+
* own language. Until it lands, `source` is `null` and every screen renders the English it was
|
|
55
|
+
* scaffolded with — which is the right thing to show for the handful of milliseconds involved, and is
|
|
56
|
+
* also exactly what a reader in the default locale sees permanently.
|
|
57
|
+
*
|
|
58
|
+
* **`{ enabled: false }` is an answer, not an error, and it costs the caller no branch of its own.** A
|
|
59
|
+
* project that never composed `i18n` projects it, and the hook then negotiates nothing, downloads no
|
|
60
|
+
* chunk, writes nothing to `localStorage`, and leaves the `lang` `index.html` declared exactly where it
|
|
61
|
+
* is: `source` stays `null` permanently, `locale` is `en`, and `choose` does nothing. `source === null`
|
|
62
|
+
* is the same null a caller already renders through while a catalog is in flight — so the screen shows
|
|
63
|
+
* the English it was scaffolded with, byte for byte as it did before any of this landed, and removing
|
|
64
|
+
* the capability puts the app back where it started.
|
|
65
|
+
*/
|
|
66
|
+
export declare function useNegotiatedLocale(projection: I18nClientProjection, options?: NegotiatedLocaleOptions): NegotiatedLocale;
|
|
67
|
+
//# sourceMappingURL=useNegotiatedLocale.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useNegotiatedLocale.d.ts","sourceRoot":"","sources":["../../src/react/useNegotiatedLocale.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAKtE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAEjE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,yFAAyF;AACzF,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,CAAC;IACzC,2DAA2D;IAC3D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,4FAA4F;IAC5F,QAAQ,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CAC3C;AAED,yEAAyE;AACzE,MAAM,WAAW,uBAAuB;IACtC,kGAAkG;IAClG,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,gGAAgG;IAChG,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,cAAc,GAAG,SAAS,CAAC,CAAC,CAAC;IACzE;;;;;;;;;;;;;;;;;;OAkBG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7D;AAWD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,mBAAmB,CACjC,UAAU,EAAE,oBAAoB,EAChC,OAAO,GAAE,uBAA4B,GACpC,gBAAgB,CA6GlB"}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { resolveBrowserLocale } from "../resolve/browser.js";
|
|
2
|
+
import { applyDocumentLocale, readBrowserSignals, rememberBrowserLocale } from "../browser/signals.js";
|
|
3
|
+
import { loadKitCatalog } from "../catalogs/browser.js";
|
|
4
|
+
import { localeDirection } from "@pithy-sh/core/src/i18n/locale";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
6
|
+
//#region src/react/useNegotiatedLocale.ts
|
|
7
|
+
/**
|
|
8
|
+
* What `locale` answers, and what nothing writes to the document, when the capability is not composed.
|
|
9
|
+
*
|
|
10
|
+
* The kit writes in English and a scaffolded `templates/index.html` ships `lang="en"` as static text —
|
|
11
|
+
* so this is both the language on screen and the tag already on the document. Nothing negotiated it,
|
|
12
|
+
* which is exactly why the document is left alone rather than restamped with it.
|
|
13
|
+
*/
|
|
14
|
+
const UNNEGOTIATED_LOCALE = "en";
|
|
15
|
+
/**
|
|
16
|
+
* Negotiate the reader's locale in the browser, load that locale's catalog, and keep the document in
|
|
17
|
+
* step.
|
|
18
|
+
*
|
|
19
|
+
* **It takes what a browser holds.** `projection` is `virtual:pithy/i18n` — locale metadata and nothing
|
|
20
|
+
* else — so a screen reads the chain order **this project** configured rather than one the front end
|
|
21
|
+
* assumed, and no page is asked for the catalogs, the cookie name or the server chain that only a
|
|
22
|
+
* Worker has. Your own catalogs reach it through `options.messages`, above the kit's translation.
|
|
23
|
+
*
|
|
24
|
+
* The catalog arrives by dynamic import, one Vite chunk per locale, so a reader downloads only their
|
|
25
|
+
* own language. Until it lands, `source` is `null` and every screen renders the English it was
|
|
26
|
+
* scaffolded with — which is the right thing to show for the handful of milliseconds involved, and is
|
|
27
|
+
* also exactly what a reader in the default locale sees permanently.
|
|
28
|
+
*
|
|
29
|
+
* **`{ enabled: false }` is an answer, not an error, and it costs the caller no branch of its own.** A
|
|
30
|
+
* project that never composed `i18n` projects it, and the hook then negotiates nothing, downloads no
|
|
31
|
+
* chunk, writes nothing to `localStorage`, and leaves the `lang` `index.html` declared exactly where it
|
|
32
|
+
* is: `source` stays `null` permanently, `locale` is `en`, and `choose` does nothing. `source === null`
|
|
33
|
+
* is the same null a caller already renders through while a catalog is in flight — so the screen shows
|
|
34
|
+
* the English it was scaffolded with, byte for byte as it did before any of this landed, and removing
|
|
35
|
+
* the capability puts the app back where it started.
|
|
36
|
+
*/
|
|
37
|
+
function useNegotiatedLocale(projection, options = {}) {
|
|
38
|
+
const config = projection.enabled ? projection : null;
|
|
39
|
+
const enabled = projection.enabled;
|
|
40
|
+
const resolved = useMemo(() => config === null ? null : resolveBrowserLocale({
|
|
41
|
+
...readBrowserSignals(config),
|
|
42
|
+
account: options.account
|
|
43
|
+
}, config), [config, options.account]);
|
|
44
|
+
const [chosen, setChosen] = useState(null);
|
|
45
|
+
const [kit, setKit] = useState(null);
|
|
46
|
+
const locale = chosen ?? resolved?.catalogLocale ?? UNNEGOTIATED_LOCALE;
|
|
47
|
+
const formattingLocale = chosen ?? resolved?.formattingLocale ?? UNNEGOTIATED_LOCALE;
|
|
48
|
+
const defaultLocale = config?.defaultLocale ?? UNNEGOTIATED_LOCALE;
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!enabled) return;
|
|
51
|
+
let live = true;
|
|
52
|
+
loadKitCatalog(locale).then((catalog) => {
|
|
53
|
+
if (live) setKit({
|
|
54
|
+
locale,
|
|
55
|
+
catalog
|
|
56
|
+
});
|
|
57
|
+
}).catch(() => {
|
|
58
|
+
if (live) setKit({
|
|
59
|
+
locale,
|
|
60
|
+
catalog: {}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
return () => {
|
|
64
|
+
live = false;
|
|
65
|
+
};
|
|
66
|
+
}, [enabled, locale]);
|
|
67
|
+
const direction = localeDirection(locale);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!enabled) return;
|
|
70
|
+
applyDocumentLocale(locale, direction);
|
|
71
|
+
}, [
|
|
72
|
+
enabled,
|
|
73
|
+
locale,
|
|
74
|
+
direction
|
|
75
|
+
]);
|
|
76
|
+
const persist = options.persist;
|
|
77
|
+
const choose = useCallback((next) => {
|
|
78
|
+
if (config === null) return;
|
|
79
|
+
if (!config.supportedLocales.includes(next)) return;
|
|
80
|
+
rememberBrowserLocale(next, config);
|
|
81
|
+
setChosen(next);
|
|
82
|
+
Promise.resolve(persist?.(next)).catch(() => void 0);
|
|
83
|
+
}, [config, persist]);
|
|
84
|
+
return {
|
|
85
|
+
source: useMemo(() => {
|
|
86
|
+
if (kit?.locale !== locale) return null;
|
|
87
|
+
return {
|
|
88
|
+
catalogLocale: locale,
|
|
89
|
+
formattingLocale,
|
|
90
|
+
layers: [
|
|
91
|
+
options.messages?.[locale],
|
|
92
|
+
options.messages?.[defaultLocale],
|
|
93
|
+
kit.catalog
|
|
94
|
+
]
|
|
95
|
+
};
|
|
96
|
+
}, [
|
|
97
|
+
kit,
|
|
98
|
+
locale,
|
|
99
|
+
formattingLocale,
|
|
100
|
+
options.messages,
|
|
101
|
+
defaultLocale
|
|
102
|
+
]),
|
|
103
|
+
locale,
|
|
104
|
+
choose
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
export { useNegotiatedLocale };
|