react-autolocale 0.3.0-beta.0 → 0.3.0-beta.1
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 +8 -2
- package/dist/cli.js +110 -9
- package/dist/{config-CYHyGpdX.js → config-U-SpTtCW.js} +6 -3
- package/dist/config.d.ts +12 -2
- package/dist/config.js +1 -1
- package/dist/{project-CGAWETvT.js → project-BazDFtnp.js} +1 -1
- package/dist/vite.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# react-autolocale
|
|
2
2
|
|
|
3
|
-
Write your React + Vite app in one language. react-autolocale extracts the text, translates it with the engine you choose (a free local model, OpenAI or
|
|
3
|
+
Write your React + Vite app in one language. react-autolocale extracts the text, translates it with the engine you choose (a free local model, OpenAI, Anthropic Claude or Google Gemini), and `vite build` produces a static page for every language (`/en/`, `/fr/`, `/es/`, ...). You don't call `t()` or manage keys, and your source files are never modified.
|
|
4
4
|
|
|
5
5
|
Translation is a separate, explicit command. Builds only read the generated catalogs. They never download a model, call an API or wait for a translation job.
|
|
6
6
|
|
|
@@ -63,7 +63,7 @@ Optional: `npx react-autolocale add switcher` copies an editable language switch
|
|
|
63
63
|
| `export` | Copies the translations into `react-autolocale.overrides.json` for hand editing. | None |
|
|
64
64
|
| `add switcher` / `add banner` | Copies an editable component into your project. | None |
|
|
65
65
|
|
|
66
|
-
`init` also accepts flags, so it can run without prompts in scripts: `--source en --locales fr,es --provider local|openai|anthropic|ollama --model <id> --api-key-env <NAME> --output <dir> --yes`. Every command takes `--root <dir>`; the default is the nearest folder with a `package.json`.
|
|
66
|
+
`init` also accepts flags, so it can run without prompts in scripts: `--source en --locales fr,es --provider local|openai|anthropic|gemini|ollama --model <id> --api-key-env <NAME> --output <dir> --yes`. Every command takes `--root <dir>`; the default is the nearest folder with a `package.json`.
|
|
67
67
|
|
|
68
68
|
A `generate` run looks like this:
|
|
69
69
|
|
|
@@ -117,6 +117,12 @@ The engine only matters to `translate`/`generate`/`sync`. Extraction, the catalo
|
|
|
117
117
|
- **Keys**: they are read from `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` (change the name with `apiKeyEnv`), from the environment or from `.env` / `.env.local` in the project root. Variables already set in your shell win. If you paste a key into `init`, it is appended to `.env` (never over an existing value) and `.env` is added to `.gitignore`. Keys are never printed or written into the config, the catalogs or the bundle. A missing key stops the command before any request is made.
|
|
118
118
|
- **Defaults**: `gpt-6-astra` for OpenAI, and `claude-opus-5-5` at `effort: "low"` for Anthropic. Short UI strings rarely need more; Claude refusals fall back server-side on models that support it.
|
|
119
119
|
- **Requests**: strings are sent in batches of 25, 3 at a time, with each string's element (`button`, `h1`, ...) as context. Replies are structured JSON with one translation per string; a batch that comes back the wrong size is retried one string at a time. The SDKs time out after 2 minutes and retry rate limits and server errors 3 times.
|
|
120
|
+
|
|
121
|
+
**Google Gemini** (`{ name: "gemini", model?, apiKeyEnv?, baseURL? }`) works the same way, with nothing to install: it calls Gemini's REST API directly, so there is no SDK.
|
|
122
|
+
- **Key**: create one in [Google AI Studio](https://aistudio.google.com/apikey) and put `GEMINI_API_KEY=...` in `.env` (or let `init` save it for you). Change the variable name with `apiKeyEnv`.
|
|
123
|
+
- **Model**: `gemini-2.5-flash` by default. Set `model` to any model id shown in AI Studio, for example a newer Flash or a Pro model for harder text.
|
|
124
|
+
- **Behavior**: the same batching and context as the other cloud providers, with structured JSON replies. Rate limits (429) and server errors are retried with backoff, honoring `Retry-After`. A rejected key, a wrong model name or an exhausted quota stops the run with a clear message before anything is stored. Free-tier keys have low rate limits, so large first runs may need to be repeated; finished work is saved after every batch.
|
|
125
|
+
- **Choose it**: `npx react-autolocale init --provider gemini`, or in the config: `provider: { name: "gemini" }`.
|
|
120
126
|
- **Errors**: a rejected key, an unknown model or a used-up quota stops the run instead of failing every batch. A failing provider is never swapped for another one.
|
|
121
127
|
|
|
122
128
|
**Ollama** (`{ name: "ollama", model: "llama3.1", url? }`) runs a local LLM that receives the context too.
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { F as toTranslator, I as getLanguage, M as TranslationError, N as createLocalTranslator, P as ollama, S as scanProject, a as findSite, b as manifestFile, c as hasRootSrc, d as OVERRIDES_FILE, f as exportTranslations, g as importLegacyCache, h as catalogFile, i as defaultInclude, j as writeJson, k as ensureBanner, l as resolveOutput, m as readOverrides, n as loadConfigFile, o as META_DIR, r as loadProject, s as defaultOutput, t as findConfigFile, u as toProjectPath, v as removeFromCatalogs, x as readManifest, y as translateMissing } from "./project-
|
|
3
|
-
import { n as DEFAULT_MODELS, t as DEFAULT_KEY_ENV } from "./config-
|
|
2
|
+
import { F as toTranslator, I as getLanguage, M as TranslationError, N as createLocalTranslator, P as ollama, S as scanProject, a as findSite, b as manifestFile, c as hasRootSrc, d as OVERRIDES_FILE, f as exportTranslations, g as importLegacyCache, h as catalogFile, i as defaultInclude, j as writeJson, k as ensureBanner, l as resolveOutput, m as readOverrides, n as loadConfigFile, o as META_DIR, r as loadProject, s as defaultOutput, t as findConfigFile, u as toProjectPath, v as removeFromCatalogs, x as readManifest, y as translateMissing } from "./project-BazDFtnp.js";
|
|
3
|
+
import { n as DEFAULT_MODELS, t as DEFAULT_KEY_ENV } from "./config-U-SpTtCW.js";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
@@ -66,6 +66,11 @@ async function createProvider(project, log) {
|
|
|
66
66
|
name: `anthropic:${p.model ?? DEFAULT_MODELS.anthropic}`,
|
|
67
67
|
chunkSize: 100
|
|
68
68
|
};
|
|
69
|
+
case "gemini": return {
|
|
70
|
+
translator: geminiTranslator(p),
|
|
71
|
+
name: `gemini:${p.model ?? DEFAULT_MODELS.gemini}`,
|
|
72
|
+
chunkSize: 100
|
|
73
|
+
};
|
|
69
74
|
}
|
|
70
75
|
}
|
|
71
76
|
function apiKey(provider, envName) {
|
|
@@ -238,6 +243,92 @@ async function anthropicTranslator(root, p) {
|
|
|
238
243
|
}
|
|
239
244
|
});
|
|
240
245
|
}
|
|
246
|
+
/** Gemini's schema dialect (types in capitals) for {"translations": [string, ...]}. */
|
|
247
|
+
const GEMINI_SCHEMA = {
|
|
248
|
+
type: "OBJECT",
|
|
249
|
+
properties: { translations: {
|
|
250
|
+
type: "ARRAY",
|
|
251
|
+
items: { type: "STRING" }
|
|
252
|
+
} },
|
|
253
|
+
required: ["translations"]
|
|
254
|
+
};
|
|
255
|
+
const GEMINI_RETRYABLE = /* @__PURE__ */ new Set([
|
|
256
|
+
429,
|
|
257
|
+
500,
|
|
258
|
+
502,
|
|
259
|
+
503,
|
|
260
|
+
504
|
|
261
|
+
]);
|
|
262
|
+
const GEMINI_RETRIES = 3;
|
|
263
|
+
/** The model stopped because of its safety rules, not because it finished. */
|
|
264
|
+
const GEMINI_DECLINED = /* @__PURE__ */ new Set([
|
|
265
|
+
"SAFETY",
|
|
266
|
+
"BLOCKLIST",
|
|
267
|
+
"PROHIBITED_CONTENT",
|
|
268
|
+
"SPII",
|
|
269
|
+
"IMAGE_SAFETY",
|
|
270
|
+
"RECITATION"
|
|
271
|
+
]);
|
|
272
|
+
/** Maps an HTTP failure to a TranslationError. Credential, permission and request errors stop the run. */
|
|
273
|
+
function geminiError(status, data) {
|
|
274
|
+
const msg = String(data?.error?.message ?? `HTTP ${status}`).replace(/[.\s]+$/, "");
|
|
275
|
+
if (status === 401 || status === 403 || /API key not valid|API_KEY_INVALID/i.test(msg)) return new TranslationError(`Gemini: the API key was rejected or lacks permission (${msg})`, true);
|
|
276
|
+
if (status === 404) return new TranslationError(`Gemini: model not found; check the model name (${msg})`, true);
|
|
277
|
+
if (status === 400) return new TranslationError(`Gemini: request rejected (${msg})`, true);
|
|
278
|
+
if (status === 429) return new TranslationError(`Gemini: rate limit or quota reached after retries (${msg}). Run the command again later`, true);
|
|
279
|
+
return new TranslationError(`Gemini: ${msg}`);
|
|
280
|
+
}
|
|
281
|
+
function geminiTranslator(p, deps = {}) {
|
|
282
|
+
const key = apiKey("gemini", p.apiKeyEnv);
|
|
283
|
+
const model = p.model ?? DEFAULT_MODELS.gemini;
|
|
284
|
+
const url = `${(p.baseURL ?? "https://generativelanguage.googleapis.com/v1beta").replace(/\/+$/, "")}/models/${encodeURIComponent(model)}:generateContent`;
|
|
285
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
286
|
+
const backoff = (attempt) => Math.min(3e4, 1e3 * 2 ** attempt + Math.random() * 500);
|
|
287
|
+
return batchedTranslator(async (texts, contexts, from, to) => {
|
|
288
|
+
const body = JSON.stringify({
|
|
289
|
+
systemInstruction: { parts: [{ text: systemPrompt(from, to) }] },
|
|
290
|
+
contents: [{
|
|
291
|
+
role: "user",
|
|
292
|
+
parts: [{ text: userPrompt(texts, contexts) }]
|
|
293
|
+
}],
|
|
294
|
+
generationConfig: {
|
|
295
|
+
temperature: 0,
|
|
296
|
+
responseMimeType: "application/json",
|
|
297
|
+
responseSchema: GEMINI_SCHEMA
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
for (let attempt = 0;; attempt++) {
|
|
301
|
+
let res;
|
|
302
|
+
try {
|
|
303
|
+
res = await fetch(url, {
|
|
304
|
+
method: "POST",
|
|
305
|
+
headers: {
|
|
306
|
+
"content-type": "application/json",
|
|
307
|
+
"x-goog-api-key": key
|
|
308
|
+
},
|
|
309
|
+
body,
|
|
310
|
+
signal: AbortSignal.timeout(12e4)
|
|
311
|
+
});
|
|
312
|
+
} catch (err) {
|
|
313
|
+
if (attempt >= GEMINI_RETRIES) throw new TranslationError(`Gemini: could not reach the API (${err.message})`);
|
|
314
|
+
await sleep(backoff(attempt));
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (GEMINI_RETRYABLE.has(res.status) && attempt < GEMINI_RETRIES) {
|
|
318
|
+
const wait = Number(res.headers.get("retry-after"));
|
|
319
|
+
await sleep(Number.isFinite(wait) && wait > 0 ? Math.min(wait, 60) * 1e3 : backoff(attempt));
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const data = await res.json().catch(() => ({}));
|
|
323
|
+
if (!res.ok) throw geminiError(res.status, data);
|
|
324
|
+
if (data.promptFeedback?.blockReason) throw new TranslationError(`Gemini declined to translate this batch (${data.promptFeedback.blockReason})`);
|
|
325
|
+
const candidate = data.candidates?.[0];
|
|
326
|
+
if (!candidate || candidate.finishReason === "MAX_TOKENS") return "";
|
|
327
|
+
if (GEMINI_DECLINED.has(candidate.finishReason)) throw new TranslationError(`Gemini declined to translate this batch (${candidate.finishReason})`);
|
|
328
|
+
return (candidate.content?.parts ?? []).map((part) => part.text ?? "").join("");
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
241
332
|
//#endregion
|
|
242
333
|
//#region src/cli/commands.ts
|
|
243
334
|
const ok = (out, msg) => out.log(`✓ ${msg}`);
|
|
@@ -545,10 +636,15 @@ async function runInit(root, flags, prompt, out) {
|
|
|
545
636
|
value: "anthropic",
|
|
546
637
|
label: "Cloud AI: Anthropic Claude",
|
|
547
638
|
help: "Uses a hosted AI model. No large downloads, and usually faster for big translation jobs. Needs an API key;\nprovider usage may be billed to your account."
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
value: "gemini",
|
|
642
|
+
label: "Cloud AI: Google Gemini",
|
|
643
|
+
help: "Uses a hosted AI model. No large downloads, and usually faster for big translation jobs. Needs an API key;\nprovider usage may be billed to your account."
|
|
548
644
|
}
|
|
549
645
|
], "local");
|
|
550
646
|
let provider;
|
|
551
|
-
if (name === "openai" || name === "anthropic") provider = {
|
|
647
|
+
if (name === "openai" || name === "anthropic" || name === "gemini") provider = {
|
|
552
648
|
name,
|
|
553
649
|
model: flags.model ?? DEFAULT_MODELS[name],
|
|
554
650
|
apiKeyEnv: flags.apiKeyEnv ?? DEFAULT_KEY_ENV[name]
|
|
@@ -560,7 +656,7 @@ async function runInit(root, flags, prompt, out) {
|
|
|
560
656
|
model: flags.model
|
|
561
657
|
};
|
|
562
658
|
} else if (name === "local") provider = { name };
|
|
563
|
-
else throw new Error(`Unknown provider "${name}". Choose local, openai, anthropic or ollama.`);
|
|
659
|
+
else throw new Error(`Unknown provider "${name}". Choose local, openai, anthropic, gemini or ollama.`);
|
|
564
660
|
const auto = defaultOutput(root);
|
|
565
661
|
out.log(`\nGenerated output:\n ${auto}/\nInternal metadata:\n ${META_DIR}/`);
|
|
566
662
|
let output = flags.output ?? await prompt.ask("Catalog folder (press Enter to keep)", auto);
|
|
@@ -578,19 +674,23 @@ async function runInit(root, flags, prompt, out) {
|
|
|
578
674
|
const provider = config.provider;
|
|
579
675
|
const ignore = [`${META_DIR}/cache.json`];
|
|
580
676
|
let modelNote = "";
|
|
581
|
-
if (provider && typeof provider === "object" && "name" in provider && (provider.name === "openai" || provider.name === "anthropic")) {
|
|
677
|
+
if (provider && typeof provider === "object" && "name" in provider && (provider.name === "openai" || provider.name === "anthropic" || provider.name === "gemini")) {
|
|
582
678
|
const keyEnv = provider.apiKeyEnv ?? DEFAULT_KEY_ENV[provider.name];
|
|
583
679
|
ignore.push(".env", ".env.local");
|
|
584
680
|
if (keyIsAvailable(root, keyEnv)) out.log(`${keyEnv} is already set (value hidden).`);
|
|
585
681
|
else {
|
|
586
|
-
const key = await prompt.secret(`Paste your ${
|
|
682
|
+
const key = await prompt.secret(`Paste your ${{
|
|
683
|
+
openai: "OpenAI",
|
|
684
|
+
anthropic: "Anthropic",
|
|
685
|
+
gemini: "Google Gemini"
|
|
686
|
+
}[provider.name]} API key to save it in .env (leave empty to do it later)`);
|
|
587
687
|
if (key) {
|
|
588
688
|
saveKeyToEnv(root, keyEnv, key);
|
|
589
689
|
out.log(`Saved ${keyEnv} to .env (value hidden).`);
|
|
590
690
|
} else out.log(`Add ${keyEnv}=... to .env (or your environment) before running translate. No request is made without it.`);
|
|
591
691
|
}
|
|
592
|
-
const pkg = SDK[provider.name];
|
|
593
|
-
if (!sdkInstalled(root, pkg)) {
|
|
692
|
+
const pkg = provider.name === "gemini" ? null : SDK[provider.name];
|
|
693
|
+
if (pkg && !sdkInstalled(root, pkg)) {
|
|
594
694
|
const cmd = {
|
|
595
695
|
npm: [
|
|
596
696
|
"install",
|
|
@@ -635,6 +735,7 @@ async function runInit(root, flags, prompt, out) {
|
|
|
635
735
|
local: "Local model",
|
|
636
736
|
openai: "OpenAI",
|
|
637
737
|
anthropic: "Anthropic Claude",
|
|
738
|
+
gemini: "Google Gemini",
|
|
638
739
|
ollama: "Ollama"
|
|
639
740
|
}[p.name];
|
|
640
741
|
const out_ = toProjectPath(root, resolveOutput(root, config.output));
|
|
@@ -710,7 +811,7 @@ function createPrompter(interactive) {
|
|
|
710
811
|
const HELP = `react-autolocale
|
|
711
812
|
|
|
712
813
|
Usage:
|
|
713
|
-
react-autolocale init [--source en] [--locales fr,es] [--provider local|openai|anthropic|ollama]
|
|
814
|
+
react-autolocale init [--source en] [--locales fr,es] [--provider local|openai|anthropic|gemini|ollama]
|
|
714
815
|
[--model <id>] [--api-key-env <NAME>] [--output <dir>] [--yes] [--force]
|
|
715
816
|
Sets up auto-locale.config.ts, the translation provider and the Vite plugin. Downloads nothing.
|
|
716
817
|
react-autolocale generate [--lang fr,es]
|
|
@@ -7,15 +7,18 @@ const PROVIDER_NAMES = [
|
|
|
7
7
|
"local",
|
|
8
8
|
"ollama",
|
|
9
9
|
"openai",
|
|
10
|
-
"anthropic"
|
|
10
|
+
"anthropic",
|
|
11
|
+
"gemini"
|
|
11
12
|
];
|
|
12
13
|
const DEFAULT_MODELS = {
|
|
13
14
|
openai: "gpt-6-astra",
|
|
14
|
-
anthropic: "claude-opus-5-5"
|
|
15
|
+
anthropic: "claude-opus-5-5",
|
|
16
|
+
gemini: "gemini-2.5-flash"
|
|
15
17
|
};
|
|
16
18
|
const DEFAULT_KEY_ENV = {
|
|
17
19
|
openai: "OPENAI_API_KEY",
|
|
18
|
-
anthropic: "ANTHROPIC_API_KEY"
|
|
20
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
21
|
+
gemini: "GEMINI_API_KEY"
|
|
19
22
|
};
|
|
20
23
|
//#endregion
|
|
21
24
|
export { defineConfig as i, DEFAULT_MODELS as n, PROVIDER_NAMES as r, DEFAULT_KEY_ENV as t };
|
package/dist/config.d.ts
CHANGED
|
@@ -30,7 +30,17 @@ interface AnthropicProvider {
|
|
|
30
30
|
/** How much the model thinks per request. Short UI strings rarely need more than "low" (the default). */
|
|
31
31
|
effort?: "low" | "medium" | "high" | "xhigh" | "max";
|
|
32
32
|
}
|
|
33
|
-
|
|
33
|
+
/** Google Gemini through its REST API. No SDK needed. Get a key at https://aistudio.google.com/apikey. */
|
|
34
|
+
interface GeminiProvider {
|
|
35
|
+
name: "gemini";
|
|
36
|
+
/** Defaults to "gemini-2.5-flash". Use any model id listed in Google AI Studio. */
|
|
37
|
+
model?: string;
|
|
38
|
+
/** Environment variable holding the key. Defaults to GEMINI_API_KEY. */
|
|
39
|
+
apiKeyEnv?: string;
|
|
40
|
+
/** Custom endpoint, e.g. a proxy. Defaults to https://generativelanguage.googleapis.com/v1beta. */
|
|
41
|
+
baseURL?: string;
|
|
42
|
+
}
|
|
43
|
+
type ProviderConfig = LocalProvider | OllamaProvider | OpenAIProvider | AnthropicProvider | GeminiProvider;
|
|
34
44
|
interface AutoLocaleConfig {
|
|
35
45
|
/** Language the source code is written in, e.g. "en". */
|
|
36
46
|
sourceLocale?: string;
|
|
@@ -53,4 +63,4 @@ interface AutoLocaleConfig {
|
|
|
53
63
|
/** Typed helper for auto-locale.config.ts. */
|
|
54
64
|
export declare function defineConfig(config: AutoLocaleConfig): AutoLocaleConfig;
|
|
55
65
|
//#endregion
|
|
56
|
-
export type { AnthropicProvider, AutoLocaleConfig, LocalProvider, OllamaProvider, OpenAIProvider, ProviderConfig, TranslateFn, Translator };
|
|
66
|
+
export type { AnthropicProvider, AutoLocaleConfig, GeminiProvider, LocalProvider, OllamaProvider, OpenAIProvider, ProviderConfig, TranslateFn, Translator };
|
package/dist/config.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { i as defineConfig } from "./config-
|
|
1
|
+
export { i as defineConfig } from "./config-U-SpTtCW.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as BANNER_EN } from "./suggestion-CkZQqMvZ.js";
|
|
2
|
-
import { r as PROVIDER_NAMES } from "./config-
|
|
2
|
+
import { r as PROVIDER_NAMES } from "./config-U-SpTtCW.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
package/dist/vite.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { n as redirectScript } from "./redirect-pDFblLdm.js";
|
|
2
|
-
import { A as readBanner, C as isSourceFile, D as translateHead, E as transformJsx, I as getLanguage, O as messageId, P as ollama, T as parserPlugins, _ as loadDicts, d as OVERRIDES_FILE, m as readOverrides, p as mergeOverrides, r as loadProject, w as walk } from "./project-
|
|
2
|
+
import { A as readBanner, C as isSourceFile, D as translateHead, E as transformJsx, I as getLanguage, O as messageId, P as ollama, T as parserPlugins, _ as loadDicts, d as OVERRIDES_FILE, m as readOverrides, p as mergeOverrides, r as loadProject, w as walk } from "./project-BazDFtnp.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import { JSDOM, VirtualConsole } from "jsdom";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-autolocale",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.1",
|
|
4
4
|
"description": "Wrap your React + Vite app in <AutoScale> and get fully translated static pages (/fr, /es, ...). Translations are generated by an explicit CLI step with a local model, OpenAI or Anthropic; builds never translate.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|