react-autolocale 0.1.0 → 0.2.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/README.md +106 -5
- package/dist/cli.js +32 -5
- package/dist/index.d.ts +59 -0
- package/dist/index.js +149 -20
- package/dist/overrides-ZQ6meLck.js +557 -0
- package/dist/{redirect-CKNY4ru5.js → suggestion-UZQ8ngd0.js} +29 -3
- package/dist/vite.d.ts +30 -2
- package/dist/vite.js +165 -469
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -71,12 +71,65 @@ Your host must serve `/fr/about/index.html` for `/fr/about`, which static hosts
|
|
|
71
71
|
|
|
72
72
|
## Fixing a translation
|
|
73
73
|
|
|
74
|
+
The quickest way is the overrides file. Run `npx react-autolocale export` after a build: it writes every generated translation into `react-autolocale.overrides.json` in your project root. Edit any value, commit the file and rebuild. Entries in it always win over machine translation and survive rebuilds (edits made inside `dist/` or the cache do not).
|
|
75
|
+
|
|
76
|
+
You can also fix a few strings in code:
|
|
77
|
+
|
|
74
78
|
Machine translation sometimes gets short UI words wrong (a nav link "Home" may come back as "Sommaire"). Correct any string in the plugin options; overrides always win:
|
|
75
79
|
|
|
76
80
|
```ts
|
|
77
81
|
reactAutolocale({ overrides: { fr: { Home: "Accueil" }, es: { Home: "Inicio" } } })
|
|
78
82
|
```
|
|
79
83
|
|
|
84
|
+
## Different values per language (prices, links, and more)
|
|
85
|
+
|
|
86
|
+
Some things should differ by language, not just be translated: a price, a link to a localized page, a phone number.
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
import { useLocalized, Localized, ForLanguages, useFormat } from "react-autolocale";
|
|
90
|
+
|
|
91
|
+
// A value: falls back to `default`, then to the original language
|
|
92
|
+
const href = useLocalized({ en: "/pricing", fr: "/tarifs", es: "/precios", default: "/pricing" });
|
|
93
|
+
<a href={href}>Pricing</a> // "Pricing" itself is still translated for you
|
|
94
|
+
|
|
95
|
+
// A block of UI
|
|
96
|
+
<Localized en={<UsPhone />} fr={<FrPhone />} default={<UsPhone />} />
|
|
97
|
+
|
|
98
|
+
// Content for some languages only (local offers, legal notices)
|
|
99
|
+
<ForLanguages only={["fr", "es"]}>Free shipping in Europe</ForLanguages>
|
|
100
|
+
<ForLanguages except={["ja"]}><CookieNotice /></ForLanguages>
|
|
101
|
+
|
|
102
|
+
// Numbers, prices and dates in the visitor's format
|
|
103
|
+
const format = useFormat();
|
|
104
|
+
format.currency(1234.5, "EUR"); // "€1,234.50" in English, "1 234,50 €" in French
|
|
105
|
+
format.number(1250000); // "1,250,000" / "1 250 000"
|
|
106
|
+
format.percent(0.15); // "15%" / "15 %"
|
|
107
|
+
format.date(new Date()); // "Oct 9, 2026" / "9 oct. 2026"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
All of these are part of the prerendered pages, so `/fr/` and `/en/` ship with their own values in the HTML. Values you pick in code are your own data and are never machine-translated. `basePath` from `useLanguage()` is the current prefix (`/fr`) for building internal links. With React Router, the page you link to must be a route you define (here `/tarifs`); the build does not rename routes by language.
|
|
111
|
+
|
|
112
|
+
## Better translations: glossary and choosing the engine
|
|
113
|
+
|
|
114
|
+
The built-in model is free and runs on your machine, but it translates each string without knowing where it appears. You can improve it:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import reactAutolocale, { ollama } from "react-autolocale/vite";
|
|
118
|
+
|
|
119
|
+
reactAutolocale({
|
|
120
|
+
// Terms that must always translate a given way (applied before the model sees the text)
|
|
121
|
+
glossary: { fr: { Roadmap: "Feuille de route" }, es: { Roadmap: "Hoja de ruta" } },
|
|
122
|
+
|
|
123
|
+
// Swap the engine. Free and local, through Ollama (https://ollama.com):
|
|
124
|
+
engine: ollama({ model: "llama3.1" }),
|
|
125
|
+
|
|
126
|
+
// ...or your own function: DeepL, an LLM API, anything. It is told where each string appears.
|
|
127
|
+
// engine: async ({ texts, contexts, from, to }) => texts.map((t, i) => myTranslate(t, from, to, contexts[i])),
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`contexts[i]` is the element or attribute the string sits in (`"button"`, `"a"`, `"h1"`, `"placeholder"`, ...), which is what lets a language model translate a lone "Home" in a nav bar correctly. The built-in model ignores it. Translations are cached, so you only pay for (or wait for) new and changed text.
|
|
132
|
+
|
|
80
133
|
## Language switcher
|
|
81
134
|
|
|
82
135
|
Switching is just navigation to `/fr/` (instant, no reload; a page load when `seo` is on). You choose the UI:
|
|
@@ -95,7 +148,15 @@ const { language, languages, setLanguage, basePath } = useLanguage();
|
|
|
95
148
|
|
|
96
149
|
## What gets translated
|
|
97
150
|
|
|
98
|
-
Static JSX text, plus `placeholder`, `title`, `alt
|
|
151
|
+
- Static JSX text, plus `placeholder`, `title`, `alt` and `aria-label` on HTML elements.
|
|
152
|
+
- **Text with variables**: `<p>Welcome, {user.name}. You have {count} new messages</p>` is translated as one sentence (`Welcome, {name}. You have {count} new messages`) and your values are filled in at runtime. Variables must be plain names or property paths (`name`, `user.name`, `props.count`). Text mixed with function calls, conditions or nested elements is left as written. There is no plural handling, so write `{count} message(s)` or branch in your code.
|
|
153
|
+
- **Not translated**: anything with `translate="no"` (the standard HTML attribute, inherited by children), `exclude` phrases, `<code>` / `<pre>` / `<script>` / `<style>`, text made only of numbers and symbols, and data that arrives at runtime (API responses, user content).
|
|
154
|
+
- **Numbers** are kept exactly as written. The build checks that every digit (prices, phone numbers, hours, versions) survives translation and re-translates with the number locked if not. If that still fails, the original text is kept instead of shipping a wrong number. Use `useFormat()` (below) for numbers you compute.
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
<p translate="no">Acme Cloud</p> // never translated
|
|
158
|
+
<section translate="no"><Terminal /></section> // nothing inside is translated
|
|
159
|
+
```
|
|
99
160
|
|
|
100
161
|
## Supported languages
|
|
101
162
|
|
|
@@ -144,13 +205,52 @@ Use these codes in `languages={[...]}`. The source (`original`) language can be
|
|
|
144
205
|
|
|
145
206
|
OPUS-MT models are small (about 75 MB each) and are used when the source language is English. NLLB-200 is one larger download (about 600 MB) shared by all the languages marked NLLB, and is also used for any non-English source. Right-to-left languages automatically get `dir="rtl"` on the page.
|
|
146
207
|
|
|
208
|
+
### Quality by language
|
|
209
|
+
|
|
210
|
+
Every supported language was run through the real models on sample UI sentences: all 37 produce output in the right writing system, with no runaway or repeated text. How good that output is varies. French, Spanish, German, Italian, Dutch, Swedish, Danish, Japanese and Korean read naturally on ordinary sentences. Short imperative labels are the weak spot in many languages: "Save 20%" comes back as "save a file" in Russian, Ukrainian, Hungarian, Hindi and Romanian, "Sign in" comes back wrong in Russian, Chinese, Arabic and Vietnamese, and Arabic and Vietnamese could not keep the number in "Save 20% on your first order" (the original English is kept for such strings rather than risking a wrong number). Treat these as drafts: review the languages you ship in `react-autolocale.overrides.json`, add a `glossary` for key terms, or use an `engine` that gets context (see below). You can re-run the check yourself with `RUN_MODELS=1 pnpm exec vitest run test/models.test.ts`.
|
|
211
|
+
|
|
147
212
|
## Translations and caching
|
|
148
213
|
|
|
149
214
|
Translations are generated on first build into `node_modules/.cache/react-autolocale/` and reused; only new or changed strings are translated afterwards. Cache that folder in CI. Models download once into `~/.cache/react-autolocale` (about 75 MB per OPUS-MT language, about 600 MB for NLLB: Japanese, Korean, Portuguese and others). The dev server translates text you add while it runs and reloads.
|
|
150
215
|
|
|
151
216
|
## Automatic language navigation
|
|
152
217
|
|
|
153
|
-
|
|
218
|
+
Whether a visitor is moved depends on the URL they land on:
|
|
219
|
+
|
|
220
|
+
| Where they land | What happens |
|
|
221
|
+
| --- | --- |
|
|
222
|
+
| `/` or `/about` (no language in the URL) | Sent to their saved language, otherwise their browser language, if you support it (a French browser lands on `/fr/about`). |
|
|
223
|
+
| `/en/about` or `/fr/about` (explicit language URL) | Never redirected. The page they asked for is the page they get. |
|
|
224
|
+
|
|
225
|
+
- A language chosen in the switcher is remembered and wins on later visits. The browser language itself is not saved; it is checked each time.
|
|
226
|
+
- The browser's language setting is used, not the visitor's physical location. Unsupported browser languages stay on the original.
|
|
227
|
+
- The root page redirects before it paints, so visitors don't see a flash of the wrong language.
|
|
228
|
+
|
|
229
|
+
This follows common practice. Redirecting the bare root by browser language is widespread, while forcing visitors off an explicit language URL is avoided: shared links and search results should open the page they point to, redirect loops are avoided, and it respects someone who chose a language on purpose. Search engines are told which URL belongs to which language through `hreflang` tags (see SEO below), so a French searcher is normally shown the `/fr/` URL in the first place. Visitors who land on another language's URL can use the language switcher.
|
|
230
|
+
|
|
231
|
+
## Suggesting the visitor's language (optional banner)
|
|
232
|
+
|
|
233
|
+
Instead of forcing a redirect on an explicit URL, you can offer the visitor their browser language: a French visitor on `/en/about` sees "Cette page est également disponible en Français." with a button to switch and one to dismiss. It appears only when the browser language is supported and differs from the page, is shown after the page loads (so it never changes the HTML search engines see), remembers a dismissal, and stays hidden once the visitor has picked a language. The text is shown in the visitor's own language.
|
|
234
|
+
|
|
235
|
+
It is off unless you add it, and you decide how it looks:
|
|
236
|
+
|
|
237
|
+
```tsx
|
|
238
|
+
import { LanguageBanner } from "react-autolocale";
|
|
239
|
+
|
|
240
|
+
<LanguageBanner /> // ready-made bar, style it with className / style
|
|
241
|
+
<LanguageBanner className="my-bar" /> // your own CSS replaces the default look
|
|
242
|
+
|
|
243
|
+
<LanguageBanner> // or build the markup yourself
|
|
244
|
+
{(s) => (
|
|
245
|
+
<aside>
|
|
246
|
+
{s.text} <button onClick={s.accept}>{s.switchLabel}</button>
|
|
247
|
+
<button onClick={s.dismiss}>{s.dismissLabel}</button>
|
|
248
|
+
</aside>
|
|
249
|
+
)}
|
|
250
|
+
</LanguageBanner>
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Prefer to own the component? `npx react-autolocale add banner` copies an editable `LanguageBanner` into your project. For full control use the hook, `useLanguageSuggestion()`, which returns `null` or `{ language, native, text, switchLabel, dismissLabel, accept, dismiss }`.
|
|
154
254
|
|
|
155
255
|
## SEO (opt-in)
|
|
156
256
|
|
|
@@ -171,10 +271,11 @@ With `seo` the build also:
|
|
|
171
271
|
|
|
172
272
|
With `seo`, switching language navigates to the real page (so its head tags are correct) instead of swapping in place. Brand names in the title are translated like any text, so add them to `exclude`.
|
|
173
273
|
|
|
174
|
-
##
|
|
274
|
+
## Limits
|
|
175
275
|
|
|
176
|
-
-
|
|
276
|
+
- Data that arrives at runtime (API responses, user-written content) is not translated: it does not exist at build time. Text you write in JSX around those values is.
|
|
277
|
+
- Variables in text must be plain names or property paths; no plural rules.
|
|
177
278
|
- Dynamic routes (`/users/:id`) only get pages you list in `routes`, or that are linked.
|
|
178
279
|
- `<AutoScale>` props must be literals so the plugin can read them at build time.
|
|
179
280
|
- Use Vite's default `base: "/"`.
|
|
180
|
-
- Machine translation is draft quality.
|
|
281
|
+
- Machine translation is draft quality. Short labels, long sentences with variables and less common languages need a human look: use the overrides file.
|
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { n as exportTranslations, t as OVERRIDES_FILE } from "./overrides-ZQ6meLck.js";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
//#region src/cli/viteConfig.ts
|
|
@@ -30,10 +31,15 @@ function patchViteConfig(source) {
|
|
|
30
31
|
const HELP = `react-autolocale
|
|
31
32
|
|
|
32
33
|
Usage:
|
|
34
|
+
react-autolocale export
|
|
35
|
+
Writes the generated translations to ${OVERRIDES_FILE} so you can edit them.
|
|
36
|
+
Anything in that file always wins over machine translation. Run a build first.
|
|
33
37
|
react-autolocale init
|
|
34
38
|
Adds the plugin to your vite.config for you.
|
|
35
39
|
react-autolocale add switcher [--dir src/components]
|
|
36
40
|
Copies an editable <LanguageSwitcher /> into your project.
|
|
41
|
+
react-autolocale add banner [--dir src/components]
|
|
42
|
+
Copies an editable <LanguageBanner /> (suggests the visitor's browser language) into your project.
|
|
37
43
|
`;
|
|
38
44
|
const TSX = `import { useLanguage } from "react-autolocale";
|
|
39
45
|
|
|
@@ -57,6 +63,22 @@ export function LanguageSwitcher() {
|
|
|
57
63
|
}
|
|
58
64
|
`;
|
|
59
65
|
const JSX = TSX;
|
|
66
|
+
const BANNER_TSX = `import { useLanguageSuggestion } from "react-autolocale";
|
|
67
|
+
|
|
68
|
+
// This file is yours: change the markup, style it, or replace it entirely.
|
|
69
|
+
// It renders nothing unless the visitor's browser language differs from this page's language.
|
|
70
|
+
export function LanguageBanner() {
|
|
71
|
+
const suggestion = useLanguageSuggestion();
|
|
72
|
+
if (!suggestion) return null;
|
|
73
|
+
return (
|
|
74
|
+
<div role="region" aria-label={suggestion.native}>
|
|
75
|
+
<span>{suggestion.text}</span>
|
|
76
|
+
<button onClick={suggestion.accept}>{suggestion.switchLabel}</button>
|
|
77
|
+
<button onClick={suggestion.dismiss}>{suggestion.dismissLabel}</button>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
`;
|
|
60
82
|
function flag(args, name) {
|
|
61
83
|
const i = args.indexOf(name);
|
|
62
84
|
return i >= 0 ? args[i + 1] : void 0;
|
|
@@ -87,19 +109,24 @@ function init(root) {
|
|
|
87
109
|
console.log(patched.changed ? `Added the react-autolocale plugin to ${name}.` : `${name} already uses react-autolocale.`);
|
|
88
110
|
console.log(NEXT_STEP);
|
|
89
111
|
}
|
|
90
|
-
function
|
|
112
|
+
function addComponent(root, args, name) {
|
|
91
113
|
const dir = path.resolve(root, flag(args, "--dir") ?? (fs.existsSync(path.join(root, "src")) ? "src/components" : "components"));
|
|
92
114
|
const ts = fs.existsSync(path.join(root, "tsconfig.json"));
|
|
93
|
-
const file = path.join(dir, ts ?
|
|
115
|
+
const file = path.join(dir, ts ? `${name}.tsx` : `${name}.jsx`);
|
|
94
116
|
if (fs.existsSync(file)) throw new Error(`${path.relative(root, file)} already exists.`);
|
|
95
117
|
fs.mkdirSync(dir, { recursive: true });
|
|
96
|
-
fs.writeFileSync(file, ts ? TSX : JSX);
|
|
97
|
-
console.log(`Created ${path.relative(root, file)}\nImport it anywhere: import {
|
|
118
|
+
fs.writeFileSync(file, name === "LanguageBanner" ? BANNER_TSX : ts ? TSX : JSX);
|
|
119
|
+
console.log(`Created ${path.relative(root, file)}\nImport it anywhere: import { ${name} } from "./components/${name}";`);
|
|
98
120
|
}
|
|
99
121
|
try {
|
|
100
122
|
const [cmd, what, ...rest] = process.argv.slice(2);
|
|
101
123
|
if (cmd === "init") init(process.cwd());
|
|
102
|
-
else if (cmd === "
|
|
124
|
+
else if (cmd === "export") {
|
|
125
|
+
const { file, languages } = exportTranslations(process.cwd());
|
|
126
|
+
console.log(`Wrote ${path.relative(process.cwd(), file)}: ${Object.entries(languages).map(([l, n]) => `${l} ${n}`).join(", ")} strings.`);
|
|
127
|
+
console.log("Edit any value, commit the file, and rebuild. Your edits always win over machine translation.");
|
|
128
|
+
} else if (cmd === "add" && what === "switcher") addComponent(process.cwd(), rest, "LanguageSwitcher");
|
|
129
|
+
else if (cmd === "add" && what === "banner") addComponent(process.cwd(), rest, "LanguageBanner");
|
|
103
130
|
else {
|
|
104
131
|
console.log(HELP);
|
|
105
132
|
if (cmd) process.exitCode = 1;
|
package/dist/index.d.ts
CHANGED
|
@@ -37,6 +37,65 @@ export declare function __T({ s }: {
|
|
|
37
37
|
}): ReactElement;
|
|
38
38
|
/** @internal Inserted by the Vite plugin for translated attributes (placeholder, alt, ...). */
|
|
39
39
|
export declare function __useT(): (s: string) => string;
|
|
40
|
+
/** @internal Inserted by the Vite plugin for text with variables, e.g. `Hello {name}`. */
|
|
41
|
+
export declare function __TD({ s, v }: {
|
|
42
|
+
s: string;
|
|
43
|
+
v: Record<string, unknown>;
|
|
44
|
+
}): ReactElement;
|
|
45
|
+
/**
|
|
46
|
+
* Pick a value by language: `useLocalized({ en: "/pricing", fr: "/tarifs", default: "/pricing" })`.
|
|
47
|
+
* Falls back to `default`, then to the original language's value.
|
|
48
|
+
*/
|
|
49
|
+
export declare function useLocalized<T>(values: Record<string, T | undefined> & {
|
|
50
|
+
default?: T;
|
|
51
|
+
}): T;
|
|
52
|
+
/** Component form of `useLocalized`: `<Localized en={<A />} fr={<B />} default={<A />} />`. */
|
|
53
|
+
export declare function Localized(props: Record<string, ReactNode>): ReactElement;
|
|
54
|
+
export interface ForLanguagesProps {
|
|
55
|
+
/** Show only for these languages. */
|
|
56
|
+
only?: string[];
|
|
57
|
+
/** Show for every language except these. */
|
|
58
|
+
except?: string[];
|
|
59
|
+
children?: ReactNode;
|
|
60
|
+
}
|
|
61
|
+
/** Content that appears for some languages only, such as a local offer or legal notice. */
|
|
62
|
+
export declare function ForLanguages({ only, except, children }: ForLanguagesProps): ReactElement | null;
|
|
63
|
+
export interface Format {
|
|
64
|
+
language: string;
|
|
65
|
+
number: (value: number, options?: Intl.NumberFormatOptions) => string;
|
|
66
|
+
currency: (value: number, currency: string, options?: Intl.NumberFormatOptions) => string;
|
|
67
|
+
percent: (value: number, options?: Intl.NumberFormatOptions) => string;
|
|
68
|
+
date: (value: Date | number | string, options?: Intl.DateTimeFormatOptions) => string;
|
|
69
|
+
}
|
|
70
|
+
/** Numbers, prices and dates formatted for the current language: `format.currency(19.99, "EUR")`. */
|
|
71
|
+
export declare function useFormat(): Format;
|
|
72
|
+
export interface LanguageSuggestion {
|
|
73
|
+
/** Code of the suggested language, e.g. "fr". */
|
|
74
|
+
language: string;
|
|
75
|
+
/** The suggested language's own name, e.g. "Français". */
|
|
76
|
+
native: string;
|
|
77
|
+
/** Full sentence in the visitor's language, e.g. "This page is also available in Français. Switch?" */
|
|
78
|
+
text: string;
|
|
79
|
+
switchLabel: string;
|
|
80
|
+
dismissLabel: string;
|
|
81
|
+
/** Go to this page in the suggested language. */
|
|
82
|
+
accept: () => void;
|
|
83
|
+
/** Hide the suggestion and don't offer this language again. */
|
|
84
|
+
dismiss: () => void;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* For building your own banner. Returns null when there is nothing to suggest: the visitor's browser language
|
|
88
|
+
* is the page's language, is not supported, was dismissed, or they already picked a language.
|
|
89
|
+
*/
|
|
90
|
+
export declare function useLanguageSuggestion(): LanguageSuggestion | null;
|
|
91
|
+
export interface LanguageBannerProps {
|
|
92
|
+
className?: string;
|
|
93
|
+
style?: CSSProperties;
|
|
94
|
+
/** Replace the markup entirely: `{(s) => <MyBar>{s.text}</MyBar>}`. */
|
|
95
|
+
children?: (suggestion: LanguageSuggestion) => ReactNode;
|
|
96
|
+
}
|
|
97
|
+
/** A ready-made suggestion bar. Style it with `className`/`style`, or pass a render function to build your own. */
|
|
98
|
+
export declare function LanguageBanner({ className, style, children }: LanguageBannerProps): ReactElement | null;
|
|
40
99
|
export interface LanguageApi {
|
|
41
100
|
language: string;
|
|
42
101
|
/** Switches language and updates the URL; resolves once translations are loaded. */
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { i as STORAGE_KEY, n as DISMISSED_KEY, o as redirectTarget, r as suggestLanguage, t as BANNER_EN } from "./suggestion-UZQ8ngd0.js";
|
|
2
2
|
import { Fragment, createContext, createElement, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
3
|
-
import { languages, loaders, original, originalNative, seo } from "virtual:react-autolocale";
|
|
3
|
+
import { banner, languages, loaders, original, originalNative, seo } from "virtual:react-autolocale";
|
|
4
4
|
//#region src/runtime/index.ts
|
|
5
5
|
const hasDom = typeof window !== "undefined" && typeof document !== "undefined";
|
|
6
6
|
const codes = /* @__PURE__ */ new Set([original, ...languages.map((l) => l.code)]);
|
|
@@ -32,8 +32,27 @@ function initialState() {
|
|
|
32
32
|
dict: {}
|
|
33
33
|
};
|
|
34
34
|
const boot = window.__AUTOSCALE__;
|
|
35
|
-
const language = boot?.lang ?? prefixOf(window.location.pathname) ?? original;
|
|
36
35
|
if (boot) cache.set(boot.lang, boot.dict);
|
|
36
|
+
const { pathname, search, hash } = window.location;
|
|
37
|
+
const fromUrl = prefixOf(pathname);
|
|
38
|
+
if (fromUrl === null) {
|
|
39
|
+
let saved = null;
|
|
40
|
+
try {
|
|
41
|
+
saved = window.localStorage.getItem(STORAGE_KEY);
|
|
42
|
+
} catch {}
|
|
43
|
+
const to = redirectTarget({
|
|
44
|
+
pathname,
|
|
45
|
+
search,
|
|
46
|
+
hash,
|
|
47
|
+
saved,
|
|
48
|
+
browser: window.navigator.languages?.length ? window.navigator.languages : [window.navigator.language],
|
|
49
|
+
codes: [...codes],
|
|
50
|
+
original
|
|
51
|
+
});
|
|
52
|
+
if (to) window.location.replace(to);
|
|
53
|
+
else window.history.replaceState(window.history.state, "", `/${original}${pathname}${search}${hash}`);
|
|
54
|
+
}
|
|
55
|
+
const language = fromUrl ?? original;
|
|
37
56
|
return {
|
|
38
57
|
language,
|
|
39
58
|
dict: language === original ? {} : cache.get(language) ?? {}
|
|
@@ -68,22 +87,6 @@ function AutoScale({ children }) {
|
|
|
68
87
|
document.documentElement.lang = state.language;
|
|
69
88
|
document.documentElement.dir = languages.find((l) => l.code === state.language)?.rtl ? "rtl" : "ltr";
|
|
70
89
|
}, [state.language]);
|
|
71
|
-
useEffect(() => {
|
|
72
|
-
let saved = null;
|
|
73
|
-
try {
|
|
74
|
-
saved = window.localStorage.getItem(STORAGE_KEY);
|
|
75
|
-
} catch {}
|
|
76
|
-
const to = redirectTarget({
|
|
77
|
-
pathname: window.location.pathname,
|
|
78
|
-
search: window.location.search,
|
|
79
|
-
hash: window.location.hash,
|
|
80
|
-
saved,
|
|
81
|
-
browser: window.navigator.languages?.length ? window.navigator.languages : [window.navigator.language],
|
|
82
|
-
codes: [...codes],
|
|
83
|
-
original
|
|
84
|
-
});
|
|
85
|
-
if (to) window.location.replace(to);
|
|
86
|
-
}, []);
|
|
87
90
|
useEffect(() => {
|
|
88
91
|
if (state.language !== original && !cache.has(state.language)) apply(state.language, false);
|
|
89
92
|
const onPop = () => void apply(prefixOf(window.location.pathname) ?? original, false);
|
|
@@ -106,6 +109,132 @@ function __useT() {
|
|
|
106
109
|
const { dict } = useContext(Context);
|
|
107
110
|
return useCallback((s) => dict[s] ?? s, [dict]);
|
|
108
111
|
}
|
|
112
|
+
/** @internal Inserted by the Vite plugin for text with variables, e.g. `Hello {name}`. */
|
|
113
|
+
function __TD({ s, v }) {
|
|
114
|
+
const { dict } = useContext(Context);
|
|
115
|
+
const parts = (dict[s] ?? s).split(/\{(\w+)\}/g).map((part, i) => i % 2 ? v[part] : part);
|
|
116
|
+
return createElement(Fragment, null, ...parts.map((p, i) => createElement(Fragment, { key: i }, p)));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Pick a value by language: `useLocalized({ en: "/pricing", fr: "/tarifs", default: "/pricing" })`.
|
|
120
|
+
* Falls back to `default`, then to the original language's value.
|
|
121
|
+
*/
|
|
122
|
+
function useLocalized(values) {
|
|
123
|
+
const { language } = useContext(Context);
|
|
124
|
+
return values[language] ?? values.default ?? values[original];
|
|
125
|
+
}
|
|
126
|
+
/** Component form of `useLocalized`: `<Localized en={<A />} fr={<B />} default={<A />} />`. */
|
|
127
|
+
function Localized(props) {
|
|
128
|
+
return createElement(Fragment, null, useLocalized(props));
|
|
129
|
+
}
|
|
130
|
+
/** Content that appears for some languages only, such as a local offer or legal notice. */
|
|
131
|
+
function ForLanguages({ only, except, children }) {
|
|
132
|
+
const { language } = useContext(Context);
|
|
133
|
+
if (only && !only.includes(language)) return null;
|
|
134
|
+
if (except?.includes(language)) return null;
|
|
135
|
+
return createElement(Fragment, null, children);
|
|
136
|
+
}
|
|
137
|
+
/** Numbers, prices and dates formatted for the current language: `format.currency(19.99, "EUR")`. */
|
|
138
|
+
function useFormat() {
|
|
139
|
+
const { language } = useContext(Context);
|
|
140
|
+
return useMemo(() => ({
|
|
141
|
+
language,
|
|
142
|
+
number: (value, options) => new Intl.NumberFormat(language, options).format(value),
|
|
143
|
+
currency: (value, currency, options) => new Intl.NumberFormat(language, {
|
|
144
|
+
style: "currency",
|
|
145
|
+
currency,
|
|
146
|
+
...options
|
|
147
|
+
}).format(value),
|
|
148
|
+
percent: (value, options) => new Intl.NumberFormat(language, {
|
|
149
|
+
style: "percent",
|
|
150
|
+
...options
|
|
151
|
+
}).format(value),
|
|
152
|
+
date: (value, options) => new Intl.DateTimeFormat(language, options ?? { dateStyle: "medium" }).format(new Date(value))
|
|
153
|
+
}), [language]);
|
|
154
|
+
}
|
|
155
|
+
function readDismissed() {
|
|
156
|
+
try {
|
|
157
|
+
const v = JSON.parse(window.localStorage.getItem("react-autolocale:banner-dismissed") ?? "[]");
|
|
158
|
+
return Array.isArray(v) ? v : [];
|
|
159
|
+
} catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* For building your own banner. Returns null when there is nothing to suggest: the visitor's browser language
|
|
165
|
+
* is the page's language, is not supported, was dismissed, or they already picked a language.
|
|
166
|
+
*/
|
|
167
|
+
function useLanguageSuggestion() {
|
|
168
|
+
const { language, setLanguage } = useContext(Context);
|
|
169
|
+
const [code, setCode] = useState(null);
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
let chosen = false;
|
|
172
|
+
try {
|
|
173
|
+
chosen = window.localStorage.getItem(STORAGE_KEY) !== null;
|
|
174
|
+
} catch {}
|
|
175
|
+
setCode(suggestLanguage({
|
|
176
|
+
browser: window.navigator.languages?.length ? window.navigator.languages : [window.navigator.language],
|
|
177
|
+
codes: [...codes],
|
|
178
|
+
current: language,
|
|
179
|
+
dismissed: readDismissed(),
|
|
180
|
+
chosen
|
|
181
|
+
}));
|
|
182
|
+
}, [language]);
|
|
183
|
+
if (!code) return null;
|
|
184
|
+
const text = banner[code] ?? BANNER_EN;
|
|
185
|
+
const native = code === original ? originalNative : languages.find((l) => l.code === code)?.native ?? code;
|
|
186
|
+
return {
|
|
187
|
+
language: code,
|
|
188
|
+
native,
|
|
189
|
+
text: text.prompt.split("{language}").join(native),
|
|
190
|
+
switchLabel: text.switch.split("{language}").join(native),
|
|
191
|
+
dismissLabel: text.dismiss.split("{language}").join(native),
|
|
192
|
+
accept: () => void setLanguage(code),
|
|
193
|
+
dismiss: () => {
|
|
194
|
+
try {
|
|
195
|
+
window.localStorage.setItem(DISMISSED_KEY, JSON.stringify([...readDismissed(), code]));
|
|
196
|
+
} catch {}
|
|
197
|
+
setCode(null);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const BAR = {
|
|
202
|
+
position: "fixed",
|
|
203
|
+
left: 0,
|
|
204
|
+
right: 0,
|
|
205
|
+
bottom: 0,
|
|
206
|
+
display: "flex",
|
|
207
|
+
gap: 12,
|
|
208
|
+
alignItems: "center",
|
|
209
|
+
justifyContent: "center",
|
|
210
|
+
padding: "10px 16px",
|
|
211
|
+
background: "#111",
|
|
212
|
+
color: "#fff",
|
|
213
|
+
font: "14px system-ui, sans-serif",
|
|
214
|
+
zIndex: 2147483e3
|
|
215
|
+
};
|
|
216
|
+
/** A ready-made suggestion bar. Style it with `className`/`style`, or pass a render function to build your own. */
|
|
217
|
+
function LanguageBanner({ className, style, children }) {
|
|
218
|
+
const s = useLanguageSuggestion();
|
|
219
|
+
if (!s) return null;
|
|
220
|
+
if (children) return createElement(Fragment, null, children(s));
|
|
221
|
+
return createElement("div", {
|
|
222
|
+
role: "region",
|
|
223
|
+
"aria-label": s.native,
|
|
224
|
+
className,
|
|
225
|
+
style: className ? style : {
|
|
226
|
+
...BAR,
|
|
227
|
+
...style
|
|
228
|
+
}
|
|
229
|
+
}, createElement("span", null, s.text), createElement("button", {
|
|
230
|
+
type: "button",
|
|
231
|
+
onClick: s.accept,
|
|
232
|
+
lang: s.language
|
|
233
|
+
}, s.switchLabel), createElement("button", {
|
|
234
|
+
type: "button",
|
|
235
|
+
onClick: s.dismiss
|
|
236
|
+
}, s.dismissLabel));
|
|
237
|
+
}
|
|
109
238
|
function useLanguage() {
|
|
110
239
|
const { language, setLanguage } = useContext(Context);
|
|
111
240
|
return useMemo(() => ({
|
|
@@ -133,4 +262,4 @@ function LanguageSwitcher({ className, style, label = "Language" }) {
|
|
|
133
262
|
}, l.native)));
|
|
134
263
|
}
|
|
135
264
|
//#endregion
|
|
136
|
-
export { AutoScale, LanguageSwitcher, __T, __useT, useLanguage };
|
|
265
|
+
export { AutoScale, ForLanguages, LanguageBanner, LanguageSwitcher, Localized, __T, __TD, __useT, useFormat, useLanguage, useLanguageSuggestion, useLocalized };
|