react-autolocale 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +280 -2
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +138 -0
- package/dist/index.d.ts +120 -0
- package/dist/index.js +265 -0
- package/dist/overrides-ZQ6meLck.js +557 -0
- package/dist/suggestion-UZQ8ngd0.js +52 -0
- package/dist/vite.d.ts +44 -0
- package/dist/vite.js +938 -0
- package/package.json +73 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Williams Joseph
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,281 @@
|
|
|
1
|
-
#
|
|
1
|
+
# react-autolocale
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Wrap your React + Vite app in one component. `vite build` produces a fully translated static page for every language (`/en/`, `/fr/`, `/es/`, ...) using a free local translation model. No `t()`, no keys, no translation files in your repo, and your source code stays untouched.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
**1. Install** (npm, yarn or pnpm)
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install react-autolocale # or: yarn add react-autolocale | pnpm add react-autolocale
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**2. Add the plugin to your Vite config**
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx react-autolocale init # or: yarn react-autolocale init | pnpm exec react-autolocale init
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**3. Wrap your app once**
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
// main.tsx
|
|
23
|
+
import { AutoScale } from "react-autolocale";
|
|
24
|
+
|
|
25
|
+
createRoot(document.getElementById("root")!).render(
|
|
26
|
+
<AutoScale original="en" languages={["fr", "es", "de", "pt"]} exclude={["Acme Cloud"]}>
|
|
27
|
+
<App />
|
|
28
|
+
</AutoScale>,
|
|
29
|
+
);
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**4. Build**
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm run build # or: yarn build | pnpm build
|
|
36
|
+
npm run preview # optional: check the built site locally
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The output folder has one page per language and route (`dist/fr/`, `dist/fr/about/`, ...), deployable anywhere that serves static files. The first build takes a few minutes: it installs the free local translation engine (about 440 MB, once, into `~/.cache/react-autolocale/engine`, shared by all your projects) and downloads the models. Later builds reuse the cache. Prefer to manage the engine yourself? `npm install -D @huggingface/transformers` and it will be used instead.
|
|
40
|
+
|
|
41
|
+
Optional: `npx react-autolocale add switcher` copies an editable language switcher into your project (see below).
|
|
42
|
+
|
|
43
|
+
Write normal JSX; there is no `t()` to call and no translation file to maintain.
|
|
44
|
+
|
|
45
|
+
## Multi-page apps (React Router)
|
|
46
|
+
|
|
47
|
+
Every page is generated, with nothing to configure. The build finds pages two ways: by following the links in each page, and by reading the fixed routes in your router code (`<Route path="/thank-you">`, nested routes, and `createBrowserRouter` objects). Pages written as `/fr/about/index.html` etc. are created for each language.
|
|
48
|
+
|
|
49
|
+
Dynamic routes (`/users/:id`) can't be known from code, so list the real pages you want with the `routes` option, which can also be a function that fetches them:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
reactAutolocale({ routes: async () => (await getUsers()).map((u) => `/users/${u.id}`) })
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Give your router the language as its basename, and remount it when the language changes:
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
function Shell() {
|
|
59
|
+
const { basePath, language } = useLanguage();
|
|
60
|
+
return (
|
|
61
|
+
<BrowserRouter basename={basePath} key={language}>
|
|
62
|
+
<Routes>...</Routes>
|
|
63
|
+
</BrowserRouter>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
<AutoScale original="en" languages={["fr", "es"]}><Shell /></AutoScale>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Your host must serve `/fr/about/index.html` for `/fr/about`, which static hosts do by default.
|
|
71
|
+
|
|
72
|
+
## Fixing a translation
|
|
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
|
+
|
|
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:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
reactAutolocale({ overrides: { fr: { Home: "Accueil" }, es: { Home: "Inicio" } } })
|
|
82
|
+
```
|
|
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
|
+
|
|
133
|
+
## Language switcher
|
|
134
|
+
|
|
135
|
+
Switching is just navigation to `/fr/` (instant, no reload; a page load when `seo` is on). You choose the UI:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npx react-autolocale add switcher # copies an editable LanguageSwitcher into src/components
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
or import the ready-made dropdown: `import { LanguageSwitcher } from "react-autolocale"`.
|
|
142
|
+
|
|
143
|
+
Build your own with the hook:
|
|
144
|
+
|
|
145
|
+
```tsx
|
|
146
|
+
const { language, languages, setLanguage, basePath } = useLanguage();
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## What gets translated
|
|
150
|
+
|
|
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
|
+
```
|
|
160
|
+
|
|
161
|
+
## Supported languages
|
|
162
|
+
|
|
163
|
+
Use these codes in `languages={[...]}`. The source (`original`) language can be any of them.
|
|
164
|
+
|
|
165
|
+
| Code | Language | Model |
|
|
166
|
+
| --- | --- | --- |
|
|
167
|
+
| `fr` | French | OPUS-MT |
|
|
168
|
+
| `es` | Spanish | OPUS-MT |
|
|
169
|
+
| `de` | German | OPUS-MT |
|
|
170
|
+
| `it` | Italian | OPUS-MT |
|
|
171
|
+
| `nl` | Dutch | OPUS-MT |
|
|
172
|
+
| `ru` | Russian | OPUS-MT |
|
|
173
|
+
| `zh` | Chinese (Simplified) | OPUS-MT |
|
|
174
|
+
| `ar` | Arabic (RTL) | OPUS-MT |
|
|
175
|
+
| `hi` | Hindi | OPUS-MT |
|
|
176
|
+
| `sv` | Swedish | OPUS-MT |
|
|
177
|
+
| `da` | Danish | OPUS-MT |
|
|
178
|
+
| `fi` | Finnish | OPUS-MT |
|
|
179
|
+
| `cs` | Czech | OPUS-MT |
|
|
180
|
+
| `hu` | Hungarian | OPUS-MT |
|
|
181
|
+
| `ro` | Romanian | OPUS-MT |
|
|
182
|
+
| `uk` | Ukrainian | OPUS-MT |
|
|
183
|
+
| `vi` | Vietnamese | OPUS-MT |
|
|
184
|
+
| `id` | Indonesian | OPUS-MT |
|
|
185
|
+
| `pt` | Portuguese | NLLB |
|
|
186
|
+
| `ja` | Japanese | NLLB |
|
|
187
|
+
| `ko` | Korean | NLLB |
|
|
188
|
+
| `tr` | Turkish | NLLB |
|
|
189
|
+
| `pl` | Polish | NLLB |
|
|
190
|
+
| `no` | Norwegian | NLLB |
|
|
191
|
+
| `el` | Greek | NLLB |
|
|
192
|
+
| `he` | Hebrew (RTL) | NLLB |
|
|
193
|
+
| `th` | Thai | NLLB |
|
|
194
|
+
| `bn` | Bengali | NLLB |
|
|
195
|
+
| `fa` | Persian (RTL) | NLLB |
|
|
196
|
+
| `ur` | Urdu (RTL) | NLLB |
|
|
197
|
+
| `bg` | Bulgarian | NLLB |
|
|
198
|
+
| `hr` | Croatian | NLLB |
|
|
199
|
+
| `sk` | Slovak | NLLB |
|
|
200
|
+
| `ca` | Catalan | NLLB |
|
|
201
|
+
| `ms` | Malay | NLLB |
|
|
202
|
+
| `sw` | Swahili | NLLB |
|
|
203
|
+
| `tl` | Tagalog | NLLB |
|
|
204
|
+
| `en` | English (default source) | NLLB when used as a target |
|
|
205
|
+
|
|
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.
|
|
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
|
+
|
|
212
|
+
## Translations and caching
|
|
213
|
+
|
|
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.
|
|
215
|
+
|
|
216
|
+
## Automatic language navigation
|
|
217
|
+
|
|
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 }`.
|
|
254
|
+
|
|
255
|
+
## SEO (opt-in)
|
|
256
|
+
|
|
257
|
+
By default react-autolocale does not touch your SEO: the only changes to `<head>` are one inline script holding that page's translations, plus `lang` and `dir` on `<html>`.
|
|
258
|
+
|
|
259
|
+
Turn it on with `seo` on the provider:
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
<AutoScale original="en" languages={["fr", "es"]} seo siteUrl="https://example.com">
|
|
263
|
+
<App />
|
|
264
|
+
</AutoScale>
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
With `seo` the build also:
|
|
268
|
+
- translates `<title>` and the description / Open Graph / Twitter meta tags,
|
|
269
|
+
- adds `<link rel="canonical">` and `hreflang` alternates (plus `x-default`) to every page,
|
|
270
|
+
- writes `sitemap.xml` with the alternates (needs `siteUrl`; reference it from your `robots.txt`).
|
|
271
|
+
|
|
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`.
|
|
273
|
+
|
|
274
|
+
## Limits
|
|
275
|
+
|
|
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.
|
|
278
|
+
- Dynamic routes (`/users/:id`) only get pages you list in `routes`, or that are linked.
|
|
279
|
+
- `<AutoScale>` props must be literals so the plugin can read them at build time.
|
|
280
|
+
- Use Vite's default `base: "/"`.
|
|
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.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { n as exportTranslations, t as OVERRIDES_FILE } from "./overrides-ZQ6meLck.js";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
//#region src/cli/viteConfig.ts
|
|
6
|
+
const IMPORT_LINE = `import reactAutolocale from "react-autolocale/vite";`;
|
|
7
|
+
/** Adds the react-autolocale plugin to a vite.config source string. Returns null if it can't be done safely. */
|
|
8
|
+
function patchViteConfig(source) {
|
|
9
|
+
if (source.includes("react-autolocale/vite")) return {
|
|
10
|
+
code: source,
|
|
11
|
+
changed: false
|
|
12
|
+
};
|
|
13
|
+
let code = source;
|
|
14
|
+
if (/plugins\s*:\s*\[/.test(code)) code = code.replace(/plugins\s*:\s*\[/, "plugins: [reactAutolocale(), ");
|
|
15
|
+
else if (/defineConfig\(\s*\{/.test(code)) code = code.replace(/defineConfig\(\s*\{/, "defineConfig({\n plugins: [reactAutolocale()],");
|
|
16
|
+
else if (/export\s+default\s*\{/.test(code)) code = code.replace(/export\s+default\s*\{/, "export default {\n plugins: [reactAutolocale()],");
|
|
17
|
+
else return null;
|
|
18
|
+
const imports = [...code.matchAll(/^import[^\n]*$/gm)];
|
|
19
|
+
const last = imports[imports.length - 1];
|
|
20
|
+
if (last) {
|
|
21
|
+
const at = last.index + last[0].length;
|
|
22
|
+
code = code.slice(0, at) + "\n" + IMPORT_LINE + code.slice(at);
|
|
23
|
+
} else code = IMPORT_LINE + "\n" + code;
|
|
24
|
+
return {
|
|
25
|
+
code,
|
|
26
|
+
changed: true
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/cli/index.ts
|
|
31
|
+
const HELP = `react-autolocale
|
|
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.
|
|
37
|
+
react-autolocale init
|
|
38
|
+
Adds the plugin to your vite.config for you.
|
|
39
|
+
react-autolocale add switcher [--dir src/components]
|
|
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.
|
|
43
|
+
`;
|
|
44
|
+
const TSX = `import { useLanguage } from "react-autolocale";
|
|
45
|
+
|
|
46
|
+
// This file is yours: change the markup, style it, or replace it entirely.
|
|
47
|
+
export function LanguageSwitcher() {
|
|
48
|
+
const { language, languages, setLanguage } = useLanguage();
|
|
49
|
+
return (
|
|
50
|
+
<div role="group" aria-label="Language">
|
|
51
|
+
{languages.map((l) => (
|
|
52
|
+
<button
|
|
53
|
+
key={l.code}
|
|
54
|
+
lang={l.code}
|
|
55
|
+
aria-pressed={l.code === language}
|
|
56
|
+
onClick={() => setLanguage(l.code)}
|
|
57
|
+
>
|
|
58
|
+
{l.native}
|
|
59
|
+
</button>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
`;
|
|
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
|
+
`;
|
|
82
|
+
function flag(args, name) {
|
|
83
|
+
const i = args.indexOf(name);
|
|
84
|
+
return i >= 0 ? args[i + 1] : void 0;
|
|
85
|
+
}
|
|
86
|
+
const VITE_CONFIGS = [
|
|
87
|
+
"vite.config.ts",
|
|
88
|
+
"vite.config.mts",
|
|
89
|
+
"vite.config.js",
|
|
90
|
+
"vite.config.mjs"
|
|
91
|
+
];
|
|
92
|
+
const NEXT_STEP = `
|
|
93
|
+
Next, wrap your app once (for example in main.tsx):
|
|
94
|
+
|
|
95
|
+
import { AutoScale } from "react-autolocale";
|
|
96
|
+
|
|
97
|
+
<AutoScale original="en" languages={["fr", "es"]}>
|
|
98
|
+
<App />
|
|
99
|
+
</AutoScale>
|
|
100
|
+
|
|
101
|
+
Then run your build.`;
|
|
102
|
+
function init(root) {
|
|
103
|
+
const file = VITE_CONFIGS.map((f) => path.join(root, f)).find((f) => fs.existsSync(f));
|
|
104
|
+
if (!file) throw new Error("No vite.config found. This package works with Vite projects.");
|
|
105
|
+
const name = path.basename(file);
|
|
106
|
+
const patched = patchViteConfig(fs.readFileSync(file, "utf8"));
|
|
107
|
+
if (!patched) throw new Error(`Could not edit ${name} automatically. Add this yourself:\n import reactAutolocale from "react-autolocale/vite";\n plugins: [reactAutolocale(), ...]`);
|
|
108
|
+
if (patched.changed) fs.writeFileSync(file, patched.code);
|
|
109
|
+
console.log(patched.changed ? `Added the react-autolocale plugin to ${name}.` : `${name} already uses react-autolocale.`);
|
|
110
|
+
console.log(NEXT_STEP);
|
|
111
|
+
}
|
|
112
|
+
function addComponent(root, args, name) {
|
|
113
|
+
const dir = path.resolve(root, flag(args, "--dir") ?? (fs.existsSync(path.join(root, "src")) ? "src/components" : "components"));
|
|
114
|
+
const ts = fs.existsSync(path.join(root, "tsconfig.json"));
|
|
115
|
+
const file = path.join(dir, ts ? `${name}.tsx` : `${name}.jsx`);
|
|
116
|
+
if (fs.existsSync(file)) throw new Error(`${path.relative(root, file)} already exists.`);
|
|
117
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
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}";`);
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const [cmd, what, ...rest] = process.argv.slice(2);
|
|
123
|
+
if (cmd === "init") init(process.cwd());
|
|
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");
|
|
130
|
+
else {
|
|
131
|
+
console.log(HELP);
|
|
132
|
+
if (cmd) process.exitCode = 1;
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error(`react-autolocale: ${err.message}`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { CSSProperties, ReactElement, ReactNode } from "react";
|
|
2
|
+
//#region src/runtime/index.d.ts
|
|
3
|
+
type Dict = Record<string, string>;
|
|
4
|
+
declare global {
|
|
5
|
+
interface Window {
|
|
6
|
+
/** Written into every generated page so the first render already has its translations. */
|
|
7
|
+
__AUTOSCALE__?: {
|
|
8
|
+
lang: string;
|
|
9
|
+
dict: Dict;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export interface AutoScaleProps {
|
|
14
|
+
/** Language the app is written in, e.g. "en". Must be a literal so the build can read it. */
|
|
15
|
+
original: string;
|
|
16
|
+
/** Languages to generate, e.g. ["fr", "es"]. Must be a literal. */
|
|
17
|
+
languages: string[];
|
|
18
|
+
/** Phrases that must never be translated, such as brand names. Must be a literal. */
|
|
19
|
+
exclude?: string[];
|
|
20
|
+
/**
|
|
21
|
+
* Opt in to SEO changes at build time: translated title/meta tags, canonical and hreflang links,
|
|
22
|
+
* and a sitemap. Off by default: nothing in <head> is touched. Must be a literal.
|
|
23
|
+
*/
|
|
24
|
+
seo?: boolean | "true";
|
|
25
|
+
/** Public site URL (e.g. "https://example.com"); used by `seo` for absolute links and the sitemap. */
|
|
26
|
+
siteUrl?: string;
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Wrap your app once. Language comes from the URL (/fr/...), so every language is a real, static page.
|
|
31
|
+
* The props are read at build time by the Vite plugin.
|
|
32
|
+
*/
|
|
33
|
+
export declare function AutoScale({ children }: AutoScaleProps): ReactElement;
|
|
34
|
+
/** @internal Inserted by the Vite plugin in place of static JSX text. */
|
|
35
|
+
export declare function __T({ s }: {
|
|
36
|
+
s: string;
|
|
37
|
+
}): ReactElement;
|
|
38
|
+
/** @internal Inserted by the Vite plugin for translated attributes (placeholder, alt, ...). */
|
|
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;
|
|
99
|
+
export interface LanguageApi {
|
|
100
|
+
language: string;
|
|
101
|
+
/** Switches language and updates the URL; resolves once translations are loaded. */
|
|
102
|
+
setLanguage: (code: string) => Promise<void>;
|
|
103
|
+
/** All languages, original first. */
|
|
104
|
+
languages: {
|
|
105
|
+
code: string;
|
|
106
|
+
native: string;
|
|
107
|
+
}[];
|
|
108
|
+
/** "/fr" - pass to your router's basename if you use client-side routing. */
|
|
109
|
+
basePath: string;
|
|
110
|
+
}
|
|
111
|
+
export declare function useLanguage(): LanguageApi;
|
|
112
|
+
export interface LanguageSwitcherProps {
|
|
113
|
+
className?: string;
|
|
114
|
+
style?: CSSProperties;
|
|
115
|
+
/** Accessible label for the dropdown. */
|
|
116
|
+
label?: string;
|
|
117
|
+
}
|
|
118
|
+
/** A dropdown that switches the whole app's language instantly, without a reload. */
|
|
119
|
+
export declare function LanguageSwitcher({ className, style, label }: LanguageSwitcherProps): ReactElement;
|
|
120
|
+
//#endregion
|