@pithy-sh/i18n 0.12.0 → 0.14.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/capability.d.ts +1 -1
- package/dist/capability.d.ts.map +1 -1
- package/dist/capability.js +1 -1
- package/dist/catalogs/es/errors.d.ts.map +1 -1
- package/dist/catalogs/es/errors.js +18 -0
- package/dist/catalogs/es/screens.d.ts.map +1 -1
- package/dist/catalogs/es/screens.js +79 -0
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +0 -1
- package/dist/native/environment.d.ts +16 -0
- package/dist/native/environment.d.ts.map +1 -0
- package/dist/native/environment.js +56 -0
- package/dist/native/useNativeDirection.d.ts +13 -0
- package/dist/native/useNativeDirection.d.ts.map +1 -0
- package/dist/native/useNativeDirection.js +25 -0
- package/dist/react/browserLocaleEnvironment.d.ts +6 -0
- package/dist/react/browserLocaleEnvironment.d.ts.map +1 -0
- package/dist/react/browserLocaleEnvironment.js +28 -0
- package/dist/react/localeEnvironment.d.ts +29 -0
- package/dist/react/localeEnvironment.d.ts.map +1 -0
- package/dist/react/localeEnvironment.js +2 -0
- package/dist/react/useDocumentLocale.d.ts +10 -0
- package/dist/react/useDocumentLocale.d.ts.map +1 -0
- package/dist/react/useDocumentLocale.js +23 -0
- package/dist/react/useNegotiatedLocale.d.ts +16 -3
- package/dist/react/useNegotiatedLocale.d.ts.map +1 -1
- package/dist/react/useNegotiatedLocale.js +30 -37
- package/dist/resolve/browser.d.ts +2 -1
- package/dist/resolve/browser.d.ts.map +1 -1
- package/dist/resolve/browser.js +2 -1
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +20 -3
- package/src/capability.ts +5 -9
- package/src/catalogs/es/errors.ts +20 -0
- package/src/catalogs/es/screens.ts +94 -1
- package/src/config/config.ts +0 -1
- package/src/native/environment.ts +57 -0
- package/src/native/useNativeDirection.ts +24 -0
- package/src/react/browserLocaleEnvironment.ts +32 -0
- package/src/react/localeEnvironment.ts +31 -0
- package/src/react/useDocumentLocale.ts +18 -0
- package/src/react/useNegotiatedLocale.ts +52 -51
- package/src/resolve/browser.ts +2 -1
- package/src/version.generated.ts +1 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
import { resolveBrowserLocale } from "../resolve/browser.js";
|
|
4
|
-
import { applyDocumentLocale, readBrowserSignals, rememberBrowserLocale } from "../browser/signals.js";
|
|
5
4
|
import { loadKitCatalog } from "../catalogs/browser.js";
|
|
5
|
+
import { browserLocaleEnvironment } from "./browserLocaleEnvironment.js";
|
|
6
6
|
import { localeDirection } from "@pithy-sh/core/src/i18n/locale";
|
|
7
|
-
import {
|
|
7
|
+
import { useRead } from "@pithy-sh/ui-core/src/read";
|
|
8
|
+
import { useCallback, useMemo, useState } from "react";
|
|
8
9
|
//#region src/react/useNegotiatedLocale.ts
|
|
9
10
|
/**
|
|
10
11
|
* What `locale` answers, and what nothing writes to the document, when the capability is not composed.
|
|
@@ -15,8 +16,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
15
16
|
*/
|
|
16
17
|
const UNNEGOTIATED_LOCALE = "en";
|
|
17
18
|
/**
|
|
18
|
-
* Negotiate the reader's locale
|
|
19
|
-
*
|
|
19
|
+
* Negotiate the reader's locale, load that locale's catalog, and keep the platform in step: the document in a
|
|
20
|
+
* browser, `I18nManager` on React Native, through `options.environment`.
|
|
20
21
|
*
|
|
21
22
|
* **It takes what a browser holds.** `projection` is `virtual:pithy/i18n` — locale metadata and nothing
|
|
22
23
|
* else — so a screen reads the chain order **this project** configured rather than one the front end
|
|
@@ -39,50 +40,41 @@ const UNNEGOTIATED_LOCALE = "en";
|
|
|
39
40
|
function useNegotiatedLocale(projection, options = {}) {
|
|
40
41
|
const config = projection.enabled ? projection : null;
|
|
41
42
|
const enabled = projection.enabled;
|
|
42
|
-
const
|
|
43
|
-
|
|
43
|
+
const environment = options.environment ?? browserLocaleEnvironment;
|
|
44
|
+
const signals = environment.useSignals(config);
|
|
45
|
+
const pending = enabled && signals === void 0;
|
|
46
|
+
const resolved = useMemo(() => config === null || signals === void 0 ? null : resolveBrowserLocale({
|
|
47
|
+
...signals,
|
|
44
48
|
account: options.account
|
|
45
|
-
}, config), [
|
|
49
|
+
}, config), [
|
|
50
|
+
config,
|
|
51
|
+
signals,
|
|
52
|
+
options.account
|
|
53
|
+
]);
|
|
46
54
|
const [chosen, setChosen] = useState(null);
|
|
47
|
-
const [kit, setKit] = useState(null);
|
|
48
55
|
const locale = chosen ?? resolved?.catalogLocale ?? UNNEGOTIATED_LOCALE;
|
|
49
56
|
const formattingLocale = chosen ?? resolved?.formattingLocale ?? UNNEGOTIATED_LOCALE;
|
|
50
57
|
const defaultLocale = config?.defaultLocale ?? UNNEGOTIATED_LOCALE;
|
|
51
|
-
|
|
52
|
-
if (!enabled) return;
|
|
53
|
-
let live = true;
|
|
54
|
-
loadKitCatalog(locale).then((catalog) => {
|
|
55
|
-
if (live) setKit({
|
|
56
|
-
locale,
|
|
57
|
-
catalog
|
|
58
|
-
});
|
|
59
|
-
}).catch(() => {
|
|
60
|
-
if (live) setKit({
|
|
61
|
-
locale,
|
|
62
|
-
catalog: {}
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
return () => {
|
|
66
|
-
live = false;
|
|
67
|
-
};
|
|
68
|
-
}, [enabled, locale]);
|
|
69
|
-
const direction = localeDirection(locale);
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
if (!enabled) return;
|
|
72
|
-
applyDocumentLocale(locale, direction);
|
|
73
|
-
}, [
|
|
74
|
-
enabled,
|
|
58
|
+
const kit = useRead(`catalog:${locale}`, async () => ({
|
|
75
59
|
locale,
|
|
76
|
-
|
|
77
|
-
|
|
60
|
+
catalog: await loadKitCatalog(locale).catch(() => ({}))
|
|
61
|
+
}), { skip: !enabled || pending }).value;
|
|
62
|
+
const direction = localeDirection(locale);
|
|
63
|
+
const reloadRequired = environment.useApply(enabled && !pending, locale, direction);
|
|
78
64
|
const persist = options.persist;
|
|
79
65
|
const choose = useCallback((next) => {
|
|
80
66
|
if (config === null) return;
|
|
81
67
|
if (!config.supportedLocales.includes(next)) return;
|
|
82
|
-
|
|
68
|
+
try {
|
|
69
|
+
Promise.resolve(environment.remember(next, config)).catch(() => void 0);
|
|
70
|
+
} catch {}
|
|
83
71
|
setChosen(next);
|
|
84
72
|
Promise.resolve(persist?.(next)).catch(() => void 0);
|
|
85
|
-
}, [
|
|
73
|
+
}, [
|
|
74
|
+
config,
|
|
75
|
+
persist,
|
|
76
|
+
environment
|
|
77
|
+
]);
|
|
86
78
|
return {
|
|
87
79
|
source: useMemo(() => {
|
|
88
80
|
if (kit?.locale !== locale) return null;
|
|
@@ -103,7 +95,8 @@ function useNegotiatedLocale(projection, options = {}) {
|
|
|
103
95
|
defaultLocale
|
|
104
96
|
]),
|
|
105
97
|
locale,
|
|
106
|
-
choose
|
|
98
|
+
choose,
|
|
99
|
+
reloadRequired
|
|
107
100
|
};
|
|
108
101
|
}
|
|
109
102
|
//#endregion
|
|
@@ -60,7 +60,8 @@ export interface BrowserLocaleSignals {
|
|
|
60
60
|
/**
|
|
61
61
|
* The reader's locale, by the configured browser chain.
|
|
62
62
|
*
|
|
63
|
-
* Default order: `?lang=`, the account, local storage, the
|
|
63
|
+
* Default order: `?lang=`, the account, local storage, the browser's own languages, the server's answer,
|
|
64
|
+
* the project default.
|
|
64
65
|
*
|
|
65
66
|
* **The account outranks the device.** `pithy_auth_users.locale` is where a person's locale lives, so a
|
|
66
67
|
* signed-in reader who picks a language on one device must not silently be reading another language on
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../../src/resolve/browser.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,cAAc,EAA4C,MAAM,SAAS,CAAC;AAExG;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,YAAa,SAAQ,SAAS;IAC7C,sEAAsE;IACtE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,+EAA+E;IAC/E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,sGAAsG;IACtG,QAAQ,CAAC,gBAAgB,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9C;AAuBD;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,eAAe,EAAE,CAE/E;AAED;;;;;;GAMG;AACH,MAAM,WAAW,oBAAoB;IACnC,kDAAkD;IAClD,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,0EAA0E;IAC1E,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,uDAAuD;IACvD,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC;;;;;;OAMG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAAC;IAC9C,sEAAsE;IACtE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC;AAED
|
|
1
|
+
{"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../../src/resolve/browser.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,cAAc,EAA4C,MAAM,SAAS,CAAC;AAExG;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,YAAa,SAAQ,SAAS;IAC7C,sEAAsE;IACtE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,+EAA+E;IAC/E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,sGAAsG;IACtG,QAAQ,CAAC,gBAAgB,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9C;AAuBD;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,eAAe,EAAE,CAE/E;AAED;;;;;;GAMG;AACH,MAAM,WAAW,oBAAoB;IACnC,kDAAkD;IAClD,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,0EAA0E;IAC1E,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,uDAAuD;IACvD,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC;;;;;;OAMG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAAC;IAC9C,sEAAsE;IACtE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,oBAAoB,EAAE,MAAM,EAAE,YAAY,GAAG,cAAc,CAIxG"}
|
package/dist/resolve/browser.js
CHANGED
|
@@ -35,7 +35,8 @@ function recognizedResolvers(names) {
|
|
|
35
35
|
/**
|
|
36
36
|
* The reader's locale, by the configured browser chain.
|
|
37
37
|
*
|
|
38
|
-
* Default order: `?lang=`, the account, local storage, the
|
|
38
|
+
* Default order: `?lang=`, the account, local storage, the browser's own languages, the server's answer,
|
|
39
|
+
* the project default.
|
|
39
40
|
*
|
|
40
41
|
* **The account outranks the device.** `pithy_auth_users.locale` is where a person's locale lives, so a
|
|
41
42
|
* signed-in reader who picks a language on one device must not silently be reading another language on
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
/** This package's npm name — the join key against a release feed. */
|
|
4
4
|
export declare const PACKAGE_NAME = "@pithy-sh/i18n";
|
|
5
5
|
/** This package's version, stamped from its own package.json at generation time. */
|
|
6
|
-
export declare const PACKAGE_VERSION = "0.
|
|
6
|
+
export declare const PACKAGE_VERSION = "0.14.0";
|
|
7
7
|
//# sourceMappingURL=version.generated.d.ts.map
|
|
@@ -4,6 +4,6 @@
|
|
|
4
4
|
/** This package's npm name — the join key against a release feed. */
|
|
5
5
|
const PACKAGE_NAME = "@pithy-sh/i18n";
|
|
6
6
|
/** This package's version, stamped from its own package.json at generation time. */
|
|
7
|
-
const PACKAGE_VERSION = "0.
|
|
7
|
+
const PACKAGE_VERSION = "0.14.0";
|
|
8
8
|
//#endregion
|
|
9
9
|
export { PACKAGE_NAME, PACKAGE_VERSION };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pithy-sh/i18n",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
29
|
"build": "tsdown && tsc -p tsconfig.build.json && bun ../../tooling/build/src/stampDeclarations.ts",
|
|
30
|
-
"typecheck": "bun check -p tsconfig.json && bun check -p tsconfig.browser.json",
|
|
30
|
+
"typecheck": "bun check -p tsconfig.json && bun check -p tsconfig.browser.json && bun check -p tsconfig.native.json",
|
|
31
31
|
"test": "vitest run",
|
|
32
32
|
"test:node": "vitest run --project=node --project=dom",
|
|
33
33
|
"test:workers": "vitest run --project=workers",
|
|
@@ -35,14 +35,27 @@
|
|
|
35
35
|
"reset": "bun run clean && rm -rf node_modules"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@pithy-sh/core": "^0.
|
|
38
|
+
"@pithy-sh/core": "^0.14.0",
|
|
39
|
+
"@pithy-sh/ui-core": "^0.1.0",
|
|
40
|
+
"@react-native-async-storage/async-storage": "^2.2.0",
|
|
41
|
+
"expo-localization": "^57.0.2",
|
|
39
42
|
"hono": "^4.13.2",
|
|
40
43
|
"react": "^19.0.0",
|
|
44
|
+
"react-native": "^0.86.3",
|
|
41
45
|
"zod": "^4.4.0"
|
|
42
46
|
},
|
|
43
47
|
"peerDependenciesMeta": {
|
|
48
|
+
"@react-native-async-storage/async-storage": {
|
|
49
|
+
"optional": true
|
|
50
|
+
},
|
|
51
|
+
"expo-localization": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
44
54
|
"react": {
|
|
45
55
|
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"react-native": {
|
|
58
|
+
"optional": true
|
|
46
59
|
}
|
|
47
60
|
},
|
|
48
61
|
"devDependencies": {
|
|
@@ -51,14 +64,18 @@
|
|
|
51
64
|
"@pithy-sh/core": "workspace:*",
|
|
52
65
|
"@pithy-sh/payments": "workspace:*",
|
|
53
66
|
"@pithy-sh/tsconfig": "workspace:*",
|
|
67
|
+
"@pithy-sh/ui-core": "workspace:*",
|
|
68
|
+
"@react-native-async-storage/async-storage": "2.2.0",
|
|
54
69
|
"@types/node": "^22.15.0",
|
|
55
70
|
"@types/react": "^19.2.17",
|
|
56
71
|
"@types/react-dom": "^19.2.3",
|
|
57
72
|
"@vitest/coverage-v8": "^4.1.0",
|
|
73
|
+
"expo-localization": "57.0.2",
|
|
58
74
|
"happy-dom": "^20.11.1",
|
|
59
75
|
"hono": "^4.13.2",
|
|
60
76
|
"react": "^19.2.8",
|
|
61
77
|
"react-dom": "^19.2.8",
|
|
78
|
+
"react-native": "0.86.3",
|
|
62
79
|
"tsdown": "^0.23.0",
|
|
63
80
|
"typescript": "^7.0.2",
|
|
64
81
|
"vitest": "^4.1.0",
|
package/src/capability.ts
CHANGED
|
@@ -47,7 +47,7 @@ export interface I18nCapability extends Capability {
|
|
|
47
47
|
* 1. the adopter's catalog for the resolved locale,
|
|
48
48
|
* 2. the adopter's catalog for the project default,
|
|
49
49
|
* 3. **the kit's translation** for the resolved locale — the `es` this package ships,
|
|
50
|
-
* 4. every composed capability's
|
|
50
|
+
* 4. every composed capability's own copy for the resolved locale,
|
|
51
51
|
* 5. every composed capability's copy for the project default,
|
|
52
52
|
* 6. and their English, which is the language the kit is written in.
|
|
53
53
|
*
|
|
@@ -69,13 +69,9 @@ export function i18n(config: I18nConfigInput = {}): I18nCapability {
|
|
|
69
69
|
KIT_CATALOGS[locale],
|
|
70
70
|
composedMessages[locale],
|
|
71
71
|
composedMessages[resolved.defaultLocale],
|
|
72
|
-
// **The source language, explicitly, and last.**
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// fallback silently stop existing for any project whose `defaultLocale` is not `en`: layers 5 and
|
|
76
|
-
// 6 collapsed into the same Spanish map, and an adopter's own English-only key rendered as its raw
|
|
77
|
-
// key on a Spanish page. English is the language the kit is written in, so it is a layer of its
|
|
78
|
-
// own rather than something another layer resolves to by coincidence.
|
|
72
|
+
// **The source language, explicitly, and last.** A capability may contribute a non-`en` locale, so
|
|
73
|
+
// the default's catalog is not English for a project whose `defaultLocale` is not `en`. English is
|
|
74
|
+
// the language the kit is written in, so it is a layer of its own.
|
|
79
75
|
composedMessages[DEFAULT_LOCALE],
|
|
80
76
|
];
|
|
81
77
|
|
|
@@ -84,7 +80,7 @@ export function i18n(config: I18nConfigInput = {}): I18nCapability {
|
|
|
84
80
|
// The package this capability ships in and the version it ships at, both stamped by
|
|
85
81
|
// `scripts/stampVersions.ts` — a Worker cannot read its own package.json. Reported per capability by
|
|
86
82
|
// the control-plane manifest, and reported together: a release feed is keyed by package name, so the
|
|
87
|
-
// version alone leaves a client guessing the key
|
|
83
|
+
// version alone leaves a client guessing the key.
|
|
88
84
|
version: PACKAGE_VERSION,
|
|
89
85
|
package: PACKAGE_NAME,
|
|
90
86
|
config: I18nConfig,
|
|
@@ -52,6 +52,7 @@ export const esErrors: MessageCatalog = {
|
|
|
52
52
|
// not here: a translating client renders this sentence and the screen shows the reason beside it.
|
|
53
53
|
"auth/account_suspended": "Tu cuenta está suspendida.",
|
|
54
54
|
"auth/account_deleted": "Tu cuenta está eliminada. Aún se puede recuperar durante un tiempo.",
|
|
55
|
+
"auth/handoff_invalid": "Ese código de acceso ya se usó o ha caducado. Vuelve a iniciar sesión.",
|
|
55
56
|
"core/not_found": "No existe el recurso solicitado.",
|
|
56
57
|
"core/conflict": "La solicitud entra en conflicto con el estado actual.",
|
|
57
58
|
"rate_limit/exceeded": "Demasiadas solicitudes. Inténtalo más tarde.",
|
|
@@ -65,6 +66,8 @@ export const esErrors: MessageCatalog = {
|
|
|
65
66
|
"core/unknown_workflow": "La operación solicitada no existe.",
|
|
66
67
|
"core/workflow_failed": "La operación no se ha completado.",
|
|
67
68
|
"core/webhook_unverified": "La firma de la notificación no es válida.",
|
|
69
|
+
"core/presence_ticket_invalid": "Ese pase de presencia no es válido.",
|
|
70
|
+
"core/presence_room_full": "La sala está llena.",
|
|
68
71
|
|
|
69
72
|
// Cloudflare REST. Naming the provider is safe — it is the adopter's own account, not our infrastructure.
|
|
70
73
|
"cloudflare/not_configured": "Este servicio no está disponible ahora.",
|
|
@@ -130,6 +133,9 @@ export const esErrors: MessageCatalog = {
|
|
|
130
133
|
"ledger/insufficient_funds": "El saldo no es suficiente.",
|
|
131
134
|
"ledger/hold_not_open": "Esa reserva de saldo ya está resuelta.",
|
|
132
135
|
"ledger/invalid_amount": "El importe debe ser un número entero positivo.",
|
|
136
|
+
"ledger/entry_not_found": "Ese movimiento no existe.",
|
|
137
|
+
"ledger/entry_not_reversible": "Ese movimiento no se puede revertir.",
|
|
138
|
+
"ledger/ref_conflict": "Esa referencia ya pertenece a otro movimiento.",
|
|
133
139
|
|
|
134
140
|
// Rating.
|
|
135
141
|
"rating/unknown_algorithm": "Ese algoritmo de valoración no existe.",
|
|
@@ -139,6 +145,16 @@ export const esErrors: MessageCatalog = {
|
|
|
139
145
|
"rating/pool_not_found": "Ese grupo de valoración no existe.",
|
|
140
146
|
"rating/record_forbidden": "No puedes registrar resultados valorados.",
|
|
141
147
|
|
|
148
|
+
// Consent.
|
|
149
|
+
"consent/unknown_purpose": "Ese propósito de consentimiento no existe.",
|
|
150
|
+
"consent/stale_notice": "El aviso ha cambiado. Vuelve a leerlo antes de aceptar.",
|
|
151
|
+
"consent/method_below_minimum": "Este consentimiento necesita una firma más fuerte.",
|
|
152
|
+
"consent/withdrawal_below_grant": "Para retirar este consentimiento, fírmalo igual que lo diste.",
|
|
153
|
+
"consent/evidence_missing": "Falta la firma o el documento que este consentimiento necesita.",
|
|
154
|
+
"consent/expiry_passed": "Esa fecha de caducidad ya ha pasado.",
|
|
155
|
+
"consent/document_not_found": "Ese documento firmado no existe.",
|
|
156
|
+
"consent/evidence_store_missing": "No se pudo guardar la firma.",
|
|
157
|
+
|
|
142
158
|
// Matchmaking.
|
|
143
159
|
"matchmaking/room_not_found": "Ese código no corresponde a ninguna sala abierta.",
|
|
144
160
|
"matchmaking/room_full": "Esa sala ya no admite más jugadores.",
|
|
@@ -212,6 +228,10 @@ export const esErrors: MessageCatalog = {
|
|
|
212
228
|
"organization/last_administrator": "La organización se quedaría sin nadie que pueda administrarla.",
|
|
213
229
|
"organization/slug_taken": "Ese identificador ya está en uso.",
|
|
214
230
|
"organization/nomination_invalid": "No hay ninguna oferta de titularidad que puedas aceptar.",
|
|
231
|
+
"organization/last_owner": "Es la única persona titular de la organización. Transfiere la titularidad primero.",
|
|
232
|
+
"organization/already_member": "Esa persona ya es miembro de la organización.",
|
|
233
|
+
"organization/seat_limit_reached": "La organización no tiene plazas libres.",
|
|
234
|
+
"organization/organization_suspended": "Esta organización está suspendida. Tu propia cuenta no se ve afectada.",
|
|
215
235
|
|
|
216
236
|
// Artifacts.
|
|
217
237
|
"artifacts/not_found": "Ese repositorio no existe.",
|
|
@@ -36,7 +36,7 @@ export const esScreens: MessageCatalog = {
|
|
|
36
36
|
"auth/sign_in.provider.label": "Continuar con {provider}",
|
|
37
37
|
"auth/sign_in.provider_unconfigured": "{provider} no está configurado aquí. Usa el enlace.",
|
|
38
38
|
"auth/sign_in.provider_silent": "{provider} no respondió. Usa el enlace.",
|
|
39
|
-
// Ni una palabra sobre si la cuenta existe
|
|
39
|
+
// Ni una palabra sobre si la cuenta existe. El servidor manda un solo código para "tienes
|
|
40
40
|
// cuenta y este proveedor no está conectado a ella" y para "no tienes ninguna", así que una frase que
|
|
41
41
|
// eligiera una de las dos devolvería, escrita, el oráculo que el Worker acaba de cerrar en la cabecera.
|
|
42
42
|
"auth/sign_in.refused.title": "{provider} no te ha dejado entrar.",
|
|
@@ -65,6 +65,12 @@ export const esScreens: MessageCatalog = {
|
|
|
65
65
|
"auth/otp.failed": "Ese código no funcionó. Inténtalo otra vez o pide uno nuevo.",
|
|
66
66
|
"auth/otp.submit": "Entrar",
|
|
67
67
|
"auth/otp.resend": "Enviar un código nuevo",
|
|
68
|
+
// The native sign-in screen on a phone with no universal link, where email signs in with a code.
|
|
69
|
+
"auth/otp.ask.submit": "Envíame un código",
|
|
70
|
+
"auth/otp.ask.provider_unconfigured": "{provider} no está configurado aquí. Usa un código.",
|
|
71
|
+
"auth/otp.ask.provider_silent": "{provider} no respondió. Usa un código.",
|
|
72
|
+
"auth/otp.ask.signup_email_only":
|
|
73
|
+
"Te enviamos un código y se crea una. Los botones de arriba solo entran en cuentas que ya existen.",
|
|
68
74
|
|
|
69
75
|
// ── auth: the magic link's landing screen ──────────────────────────────────
|
|
70
76
|
"auth/callback.title": "Entrando.",
|
|
@@ -81,6 +87,39 @@ export const esScreens: MessageCatalog = {
|
|
|
81
87
|
"auth/deleted.detail": "Hasta entonces se puede recuperar. Te enviamos la fecha a la dirección de tu cuenta.",
|
|
82
88
|
"auth/deleted.contact": "Para recuperarla, responde a ese correo antes de esa fecha.",
|
|
83
89
|
"auth/deleted.sign_out": "Cerrar sesión",
|
|
90
|
+
// ── auth: an application acting as you ─────────────────────────────────────
|
|
91
|
+
"auth/consent.title": "Una aplicación quiere actuar en tu nombre.",
|
|
92
|
+
"auth/consent.reported": "Se presenta como {name}. Ese nombre lo puso ella, y nadie lo ha comprobado.",
|
|
93
|
+
"auth/consent.unnamed": "No dio ningún nombre.",
|
|
94
|
+
"auth/consent.verified": "Comprobado: se publica en",
|
|
95
|
+
"auth/consent.redirect": "El acceso se enviará a",
|
|
96
|
+
"auth/consent.redirect_note": "Aprueba solo si empezaste esto desde esa aplicación.",
|
|
97
|
+
"auth/consent.can_do": "Si apruebas, podrá:",
|
|
98
|
+
"auth/consent.count.one": "{reachable} de {count} operación en {capability}.",
|
|
99
|
+
"auth/consent.count.other": "{reachable} de {count} operaciones en {capability}.",
|
|
100
|
+
"auth/consent.withheld.one": "{count} más necesita un rol que no tienes.",
|
|
101
|
+
"auth/consent.withheld.other": "{count} más necesitan un rol que no tienes.",
|
|
102
|
+
"auth/consent.nothing": "Tu rol todavía no le permite nada aquí. Gana lo que gane tu rol.",
|
|
103
|
+
"auth/consent.refused_scope": "{scope}: {reason}",
|
|
104
|
+
"auth/consent.not_granted.role": "Solicitado, y no concedido, porque tu rol no lo incluye.",
|
|
105
|
+
"auth/consent.not_granted.unknown": "Solicitado, y no concedido, porque esta aplicación no tiene ese permiso.",
|
|
106
|
+
"auth/consent.bound":
|
|
107
|
+
"Si apruebas, esta aplicación actuará en tu nombre. Nunca podrá hacer más de lo que tu rol permite, y pierde todo lo que tu rol pierda.",
|
|
108
|
+
"auth/consent.approve": "Dejar que actúe en mi nombre",
|
|
109
|
+
"auth/consent.deny": "No permitir",
|
|
110
|
+
"auth/consent.expired": "Esta solicitud ha caducado. Empieza de nuevo desde la aplicación.",
|
|
111
|
+
"auth/consent.effect.writes": "cambia datos",
|
|
112
|
+
"auth/consent.effect.sends": "envía un mensaje",
|
|
113
|
+
"auth/consent.effect.charges": "cobra dinero",
|
|
114
|
+
"auth/consent.effect.deletes": "borra datos",
|
|
115
|
+
"auth/connected.title": "Aplicaciones que actúan en tu nombre",
|
|
116
|
+
"auth/connected.empty": "Ninguna. Nada actúa en tu nombre.",
|
|
117
|
+
"auth/connected.unnamed": "Una aplicación sin nombre",
|
|
118
|
+
"auth/connected.verified": "Publicada en {host}. Eso está comprobado.",
|
|
119
|
+
"auth/connected.approved": "Aprobada el {date}. El acceso va a {redirect}.",
|
|
120
|
+
"auth/connected.revoke": "Revocar",
|
|
121
|
+
"auth/connected.failed": "No pudimos cargar tus aplicaciones.",
|
|
122
|
+
"auth/connected.retry": "Reintentar",
|
|
84
123
|
"organization/suspended.title": "Esta organización está suspendida.",
|
|
85
124
|
"organization/suspended.body": "Nadie puede usarla hasta que se reactive. Tu propia cuenta no se ve afectada.",
|
|
86
125
|
"organization/suspended.contact": "A sus propietarios se les dijo el motivo. Pregunta a uno de ellos.",
|
|
@@ -100,6 +139,21 @@ export const esScreens: MessageCatalog = {
|
|
|
100
139
|
"organization/choose.failed": "No hemos podido cargar tus cuentas.",
|
|
101
140
|
"organization/choose.retry": "Reintentar",
|
|
102
141
|
|
|
142
|
+
// ── organization: accepting an invitation ──────────────────────────────────
|
|
143
|
+
"organization/invitation.title": "Únete a {organization}",
|
|
144
|
+
"organization/invitation.invited_by": "{inviter} te invitó como {role}.",
|
|
145
|
+
"organization/invitation.invited": "Te invitaron como {role}.",
|
|
146
|
+
"organization/invitation.expires": "La invitación caduca el {date}.",
|
|
147
|
+
"organization/invitation.as": "Te unirás como {email}.",
|
|
148
|
+
"organization/invitation.accept": "Unirme",
|
|
149
|
+
"organization/invitation.sign_in": "Inicia sesión para aceptar",
|
|
150
|
+
"organization/invitation.invalid.title": "Esta invitación no se puede usar.",
|
|
151
|
+
"organization/invitation.invalid.body":
|
|
152
|
+
"Caducó, ya se usó o se retiró, o se envió a otra dirección. Pide una nueva a quien te invitó.",
|
|
153
|
+
"organization/invitation.failed": "No hemos podido cargar esta invitación.",
|
|
154
|
+
"organization/invitation.join_failed": "No hemos podido terminar de unirte.",
|
|
155
|
+
"organization/invitation.retry": "Reintentar",
|
|
156
|
+
|
|
103
157
|
// ── payments: the paywall ──────────────────────────────────────────────────
|
|
104
158
|
"payments/paywall.title": "Ve más lejos.",
|
|
105
159
|
"payments/paywall.body": "Elige lo que necesitas. Puedes cambiar de idea más tarde.",
|
|
@@ -113,6 +167,10 @@ export const esScreens: MessageCatalog = {
|
|
|
113
167
|
"payments/paywall.done.title": "Todo listo.",
|
|
114
168
|
"payments/paywall.done.body": "Gracias. Tu compra ya está en tu cuenta.",
|
|
115
169
|
"payments/paywall.done.home": "Ir al inicio",
|
|
170
|
+
"payments/paywall.restore": "Restaurar compras",
|
|
171
|
+
"payments/paywall.nothing_to_restore": "La tienda no tiene nada que restaurar para esta cuenta.",
|
|
172
|
+
"payments/paywall.restored_elsewhere": "Tus compras se han restaurado. Ninguna incluye lo que se vende aquí.",
|
|
173
|
+
"payments/paywall.pending": "La tienda aún está procesando esta compra. Aparecerá en tu cuenta cuando se confirme.",
|
|
116
174
|
|
|
117
175
|
// ── payments: the pricing screen ───────────────────────────────────────────
|
|
118
176
|
"payments/pricing.title": "Lo que cuesta.",
|
|
@@ -124,6 +182,12 @@ export const esScreens: MessageCatalog = {
|
|
|
124
182
|
"payments/pricing.sign_in": "Entra para comprar {product}",
|
|
125
183
|
"payments/pricing.buy": "Comprar {product}",
|
|
126
184
|
"payments/pricing.holdings": "¿Qué tengo ya?",
|
|
185
|
+
"payments/pricing.store_body": "Los precios son los de la tienda, para donde estás.",
|
|
186
|
+
"payments/pricing.at_checkout": "Verás el precio al pagar.",
|
|
187
|
+
"payments/pricing.done": "Gracias. Tu compra ya está en tu cuenta.",
|
|
188
|
+
"payments/pricing.pending": "La tienda aún está procesando esta compra. Aparecerá en tu cuenta cuando se confirme.",
|
|
189
|
+
"payments/pricing.empty.rail":
|
|
190
|
+
"Nada de aquí se vende por el método de pago de esta plataforma. Dale a un producto su SKU en ese método en",
|
|
127
191
|
// `{interval}` is Paddle's own word for the billing period and arrives from their API in English, so
|
|
128
192
|
// it is left where it lands rather than half-translated into a sentence that then disagrees with the
|
|
129
193
|
// figure beside it. An adopter who wants `al mes` says so in `i18n({ messages })`.
|
|
@@ -152,6 +216,34 @@ export const esScreens: MessageCatalog = {
|
|
|
152
216
|
"payments/subscription.google": "Comprado en Google Play",
|
|
153
217
|
"payments/subscription.more": "Ver qué más hay",
|
|
154
218
|
|
|
219
|
+
// ── payments: buying in the app ────────────────────────────────────────────
|
|
220
|
+
"payments/checkout.in_page": "Este pago se abre dentro de una página web, y la app no puede mostrarla.",
|
|
221
|
+
"payments/store.unavailable": "La tienda no ha respondido. Inténtalo de nuevo en un momento.",
|
|
222
|
+
"payments/store.refused": "La tienda no ha terminado la compra.",
|
|
223
|
+
"payments/store.no_token":
|
|
224
|
+
"Este dispositivo no ha podido guardar lo que marca una compra como tuya, así que no se ha comprado nada.",
|
|
225
|
+
"payments/store.signed_out": "Inicia sesión antes de comprar.",
|
|
226
|
+
"payments/store.no_answer": "La tienda no ha respondido. Si la compra se completa, llegará a tu cuenta sola.",
|
|
227
|
+
|
|
228
|
+
// ── consent: the reader's privacy choices ─────────────────────────────
|
|
229
|
+
"consent/settings.title": "Tus opciones de privacidad.",
|
|
230
|
+
"consent/settings.loading": "Un momento.",
|
|
231
|
+
"consent/settings.unreadable": "No pudimos cargar tus opciones.",
|
|
232
|
+
"consent/settings.none": "Aquí nada pide tu consentimiento.",
|
|
233
|
+
"consent/settings.on": "Activado.",
|
|
234
|
+
"consent/settings.off": "Desactivado.",
|
|
235
|
+
"consent/settings.renew": "El aviso ha cambiado. Acepta de nuevo para mantenerlo.",
|
|
236
|
+
"consent/settings.expired": "Ha caducado.",
|
|
237
|
+
"consent/settings.agree": "Aceptar",
|
|
238
|
+
"consent/settings.withdraw": "Retirar",
|
|
239
|
+
"consent/settings.signed_elsewhere": "Necesita tu firma en el documento que cubre.",
|
|
240
|
+
"consent/settings.type_name": "Escribe tu nombre completo para retirarlo por escrito",
|
|
241
|
+
"consent/settings.draw_name": "Firma abajo para retirarlo por escrito",
|
|
242
|
+
"consent/settings.clear": "Borrar",
|
|
243
|
+
"consent/settings.signed_with_provider":
|
|
244
|
+
"Lo firmaste con un servicio de firma electrónica. Retíralo donde lo firmaste.",
|
|
245
|
+
"consent/settings.saved": "Guardado.",
|
|
246
|
+
|
|
155
247
|
// ── app: the adopter's own shell and home screen ───────────────────────────
|
|
156
248
|
//
|
|
157
249
|
// Under `app/` rather than a capability name because that is what these are: the router and the home
|
|
@@ -170,6 +262,7 @@ export const esScreens: MessageCatalog = {
|
|
|
170
262
|
"app/home.bare.checking": "comprobando",
|
|
171
263
|
"app/home.bare.unknown": "desconocido",
|
|
172
264
|
"app/home.bare.unreachable": "inaccesible",
|
|
265
|
+
"app/locale.reload_required": "Reinicia la app para terminar de cambiar el idioma.",
|
|
173
266
|
|
|
174
267
|
// ── client: the failures a browser mints for itself, never sent by a Worker ─
|
|
175
268
|
//
|
package/src/config/config.ts
CHANGED
|
@@ -33,7 +33,6 @@ export type BrowserResolver = z.infer<typeof BrowserResolver>;
|
|
|
33
33
|
/** The server chain's default order — explicit param, account, cookie, `Accept-Language`, project default. */
|
|
34
34
|
const DEFAULT_SERVER_RESOLVERS: readonly ServerResolver[] = ["param", "user", "cookie", "header", "default"];
|
|
35
35
|
|
|
36
|
-
/** The browser chain's default order — `?lang=`, account, local storage, the server's answer, default. */
|
|
37
36
|
/**
|
|
38
37
|
* The browser chain's default order — `?lang=`, account, this device's memory, the reader's own
|
|
39
38
|
* browser languages, what the server declared, the project default.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { useRead } from "@pithy-sh/ui-core/src/read";
|
|
5
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
6
|
+
import { useLocales } from "expo-localization";
|
|
7
|
+
import { useMemo } from "react";
|
|
8
|
+
import type { LocaleEnvironment, LocaleKeys } from "../react/localeEnvironment";
|
|
9
|
+
import type { BrowserLocaleSignals } from "../resolve/browser";
|
|
10
|
+
import { useNativeDirection } from "./useNativeDirection";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* React Native's locale environment. Pass it as `useNegotiatedLocale(projection, { environment: nativeLocaleEnvironment })`.
|
|
14
|
+
*
|
|
15
|
+
* - **Starting signals:** the device's preferred languages from `expo-localization`, in the order the reader set
|
|
16
|
+
* them, and a choice remembered in AsyncStorage. `useLocales` re-renders when the operating system's language
|
|
17
|
+
* changes, so the app follows it while it runs. There is no query string and no server-rendered `lang`, so those
|
|
18
|
+
* two links of the chain answer nothing.
|
|
19
|
+
* - **Remember:** AsyncStorage, under the projection's `storageKey`. A locale is not a secret, so it is not kept in
|
|
20
|
+
* the secure store.
|
|
21
|
+
* - **Apply:** `I18nManager`, whose direction takes effect after a reload. `reloadRequired` says when.
|
|
22
|
+
*/
|
|
23
|
+
export const nativeLocaleEnvironment: LocaleEnvironment = {
|
|
24
|
+
useSignals: useNativeSignals,
|
|
25
|
+
remember: (locale, keys) => AsyncStorage.setItem(keys.storageKey, locale),
|
|
26
|
+
useApply: (enabled, _locale, direction) => useNativeDirection(enabled, direction),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The device's languages and this device's remembered choice; `undefined` until the memory has been read. */
|
|
30
|
+
function useNativeSignals(keys: LocaleKeys | null): BrowserLocaleSignals | undefined {
|
|
31
|
+
const device = useLocales();
|
|
32
|
+
const storageKey = keys?.storageKey;
|
|
33
|
+
const memory = useRead(
|
|
34
|
+
`pithy-locale-memory:${storageKey ?? ""}`,
|
|
35
|
+
async (): Promise<string | null> => (storageKey === undefined ? null : readMemory(storageKey)),
|
|
36
|
+
{ skip: storageKey === undefined },
|
|
37
|
+
);
|
|
38
|
+
const navigator = useMemo(() => {
|
|
39
|
+
const tags = device.map((entry) => entry.languageTag).filter((tag) => tag.trim().length > 0);
|
|
40
|
+
return tags.length > 0 ? tags : null;
|
|
41
|
+
}, [device]);
|
|
42
|
+
const remembered = memory.value ?? null;
|
|
43
|
+
const ready = storageKey !== undefined && !memory.loading;
|
|
44
|
+
return useMemo(
|
|
45
|
+
() => (ready ? { query: null, storage: remembered, navigator, server: null } : undefined),
|
|
46
|
+
[ready, remembered, navigator],
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The remembered choice, or `null` when there is none or the store refuses. */
|
|
51
|
+
async function readMemory(storageKey: string): Promise<string | null> {
|
|
52
|
+
try {
|
|
53
|
+
return await AsyncStorage.getItem(storageKey);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
import { I18nManager } from "react-native";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Keep `I18nManager`'s direction in step with the locale in force, and answer whether it needs a reload.
|
|
9
|
+
*
|
|
10
|
+
* React Native reads the direction once, at launch: `forceRTL` changes the next launch, and `isRTL` keeps answering
|
|
11
|
+
* for this one. So the answer is a comparison, not a flag the effect sets. `allowRTL` moves with `forceRTL`, so an
|
|
12
|
+
* English choice on an Arabic device lays out left to right rather than following the device.
|
|
13
|
+
*
|
|
14
|
+
* `enabled` false touches nothing and asks for no reload.
|
|
15
|
+
*/
|
|
16
|
+
export function useNativeDirection(enabled: boolean, direction: "ltr" | "rtl"): boolean {
|
|
17
|
+
const rtl = direction === "rtl";
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!enabled) return;
|
|
20
|
+
I18nManager.allowRTL(rtl);
|
|
21
|
+
I18nManager.forceRTL(rtl);
|
|
22
|
+
}, [enabled, rtl]);
|
|
23
|
+
return enabled && I18nManager.isRTL !== rtl;
|
|
24
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { useMemo } from "react";
|
|
5
|
+
import { readBrowserSignals, rememberBrowserLocale } from "../browser/signals";
|
|
6
|
+
import type { BrowserLocaleSignals } from "../resolve/browser";
|
|
7
|
+
import type { LocaleEnvironment, LocaleKeys } from "./localeEnvironment";
|
|
8
|
+
import { useDocumentLocale } from "./useDocumentLocale";
|
|
9
|
+
|
|
10
|
+
/** The query string, `localStorage`, `navigator` and `<html lang>`, read once per pair of names. */
|
|
11
|
+
function useBrowserSignals(keys: LocaleKeys | null): BrowserLocaleSignals | undefined {
|
|
12
|
+
const queryParam = keys?.queryParam;
|
|
13
|
+
const storageKey = keys?.storageKey;
|
|
14
|
+
return useMemo(
|
|
15
|
+
() =>
|
|
16
|
+
queryParam === undefined || storageKey === undefined ? undefined : readBrowserSignals({ queryParam, storageKey }),
|
|
17
|
+
[queryParam, storageKey],
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `lang` and `dir` on the document. A browser lays out a new direction at once, so no reload is ever needed. */
|
|
22
|
+
function useDocumentApply(enabled: boolean, locale: string, direction: "ltr" | "rtl"): boolean {
|
|
23
|
+
useDocumentLocale(enabled, locale, direction);
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The browser's locale environment: what `useNegotiatedLocale` uses when it is handed none. */
|
|
28
|
+
export const browserLocaleEnvironment: LocaleEnvironment = {
|
|
29
|
+
useSignals: useBrowserSignals,
|
|
30
|
+
remember: rememberBrowserLocale,
|
|
31
|
+
useApply: useDocumentApply,
|
|
32
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { BrowserChain, BrowserLocaleSignals } from "../resolve/browser";
|
|
5
|
+
|
|
6
|
+
/** The two names a client looks its own signals up by. */
|
|
7
|
+
export type LocaleKeys = Pick<BrowserChain, "queryParam" | "storageKey">;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where `useNegotiatedLocale` gets its starting signals, where it remembers a choice, and how it applies the locale.
|
|
11
|
+
*
|
|
12
|
+
* The browser's is the default; `@pithy-sh/i18n/src/native/environment` is React Native's. Both hooks are called on
|
|
13
|
+
* every render, so the environment a component passes must be the same one for the life of that component: a module
|
|
14
|
+
* constant, never an object built in the render.
|
|
15
|
+
*/
|
|
16
|
+
export interface LocaleEnvironment {
|
|
17
|
+
/**
|
|
18
|
+
* The signals this platform holds, as a hook, so a source that changes while the app runs, or answers after the
|
|
19
|
+
* first render, re-renders the caller. `undefined` while a signal is still being read: nothing is negotiated, no
|
|
20
|
+
* catalog is asked for and nothing is applied until it answers. `keys` is `null` when the capability is not
|
|
21
|
+
* composed, and nothing is read.
|
|
22
|
+
*/
|
|
23
|
+
readonly useSignals: (keys: LocaleKeys | null) => BrowserLocaleSignals | undefined;
|
|
24
|
+
/** Remember a choice on this device. Not awaited; a rejection is caught. */
|
|
25
|
+
readonly remember: (locale: string, keys: LocaleKeys) => void | Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Apply the locale in force, as a hook. Answers `true` when the direction it applied takes effect only after the
|
|
28
|
+
* app reloads, which is React Native's rule for `I18nManager`. The browser always answers `false`.
|
|
29
|
+
*/
|
|
30
|
+
readonly useApply: (enabled: boolean, locale: string, direction: "ltr" | "rtl") => boolean;
|
|
31
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
import { applyDocumentLocale } from "../browser/signals";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Keep `lang` and `dir` on the document in step with the locale in force.
|
|
9
|
+
*
|
|
10
|
+
* `enabled` false writes nothing: a project without the capability keeps the `lang` its `index.html` shipped,
|
|
11
|
+
* because restamping the document with a locale no chain chose would declare a language nothing negotiated.
|
|
12
|
+
*/
|
|
13
|
+
export function useDocumentLocale(enabled: boolean, locale: string, direction: "ltr" | "rtl"): void {
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!enabled) return;
|
|
16
|
+
applyDocumentLocale(locale, direction);
|
|
17
|
+
}, [enabled, locale, direction]);
|
|
18
|
+
}
|