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 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`, `aria-label` on HTML elements. `exclude` phrases (brand names) are kept as-is, even inside longer sentences.
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
- Whenever a visitor lands on a URL without a language prefix (like `/` or `/about`), they are sent to the page for their saved language, or otherwise their browser language, if you support it: a French browser lands on `/fr/`. A language chosen in the switcher is remembered and wins on later visits; the browser language itself is not saved, it is simply checked each time. Explicit URLs such as `/fr/about/` or `/en/` are never redirected, and unsupported browser languages stay on the original.
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
- ## v1 limits
274
+ ## Limits
175
275
 
176
- - Dynamic content (`Hello {name}`, API data) is not translated; planned for v1.5. Text next to `{expressions}` stays in the original language.
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 addSwitcher(root, args) {
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 ? "LanguageSwitcher.tsx" : "LanguageSwitcher.jsx");
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 { LanguageSwitcher } from "./components/LanguageSwitcher";`);
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 === "add" && what === "switcher") addSwitcher(process.cwd(), rest);
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 { r as redirectTarget, t as STORAGE_KEY } from "./redirect-CKNY4ru5.js";
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 };