vite-plugin-build-time-i18n 0.1.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/LICENCE +21 -0
- package/README.md +296 -0
- package/package.json +54 -0
- package/src/index.ts +1057 -0
package/LICENCE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# vite-plugin-build-time-i18n
|
|
2
|
+
|
|
3
|
+
Build-time i18n for Vite. This plugin replaces string-literal
|
|
4
|
+
translation calls during build so your app ships translated output
|
|
5
|
+
instead of doing key lookup at runtime.
|
|
6
|
+
|
|
7
|
+
It is designed for projects that want:
|
|
8
|
+
|
|
9
|
+
- static replacement for simple messages
|
|
10
|
+
- precompiled formatting for plural, select, number, date, and time messages
|
|
11
|
+
- build-time diagnostics for missing, unused, or non-precompilable translation keys
|
|
12
|
+
- zero runtime translation catalog lookup in application code
|
|
13
|
+
|
|
14
|
+
License: [LICENCE](LICENCE)
|
|
15
|
+
|
|
16
|
+
## Why use it
|
|
17
|
+
|
|
18
|
+
Instead of shipping a message catalog and resolving keys in the
|
|
19
|
+
browser, this plugin rewrites calls such as:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
const title = t("app.page.title");
|
|
23
|
+
const countLabel = t("app.page.priorityCount", { count: 2 });
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
into either:
|
|
27
|
+
|
|
28
|
+
- a plain string literal for static messages
|
|
29
|
+
- a generated formatter call for messages that need interpolation or ICU-style branching
|
|
30
|
+
|
|
31
|
+
That keeps translated output close to the final bundle and catches
|
|
32
|
+
catalog problems during the build.
|
|
33
|
+
|
|
34
|
+
## Requirements
|
|
35
|
+
|
|
36
|
+
- Node.js 25+
|
|
37
|
+
- Vite 8+
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install vite-plugin-build-time-i18n
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`vite` is a peer dependency and must already exist in the consuming project.
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// vite.config.ts
|
|
51
|
+
import { defineConfig } from "vite";
|
|
52
|
+
import { buildTimeI18nPlugin } from "vite-plugin-build-time-i18n";
|
|
53
|
+
|
|
54
|
+
export default defineConfig({
|
|
55
|
+
plugins: [
|
|
56
|
+
...buildTimeI18nPlugin({
|
|
57
|
+
locale: "de",
|
|
58
|
+
localesDir: "src/i18n/locales",
|
|
59
|
+
}),
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
// src/i18n/locales/de.json
|
|
66
|
+
{
|
|
67
|
+
"app": {
|
|
68
|
+
"page": {
|
|
69
|
+
"title": "Startseite",
|
|
70
|
+
"priorityCount": "{count, plural, one {# Prioritaet} other {# Prioritaeten}}"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// application code
|
|
78
|
+
function t(key: string, values?: Record<string, unknown>) {
|
|
79
|
+
return key;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const title = t("app.page.title");
|
|
83
|
+
const countLabel = t("app.page.priorityCount", { count: 2 });
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Build output shape:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
const title = "Startseite";
|
|
90
|
+
|
|
91
|
+
import { __i18nFormat } from "virtual:build-time-i18n-helper";
|
|
92
|
+
|
|
93
|
+
const countLabel = __i18nFormat(
|
|
94
|
+
{
|
|
95
|
+
type: "message",
|
|
96
|
+
parts: [
|
|
97
|
+
/* compiled parts */
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
{ count: 2 },
|
|
101
|
+
"de",
|
|
102
|
+
);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## How it works
|
|
106
|
+
|
|
107
|
+
During build, the plugin:
|
|
108
|
+
|
|
109
|
+
1. reads `<localesDir>/<locale>.json`
|
|
110
|
+
2. flattens nested message objects into dotted keys
|
|
111
|
+
3. precompiles supported message syntax
|
|
112
|
+
4. scans matching source files for direct calls to the configured translation function
|
|
113
|
+
5. rewrites supported calls in the final bundle
|
|
114
|
+
|
|
115
|
+
## Locale file format
|
|
116
|
+
|
|
117
|
+
Locale files must be top-level JSON objects. Nested objects are
|
|
118
|
+
flattened into dotted keys.
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"app": {
|
|
123
|
+
"route": {
|
|
124
|
+
"modeLabel": "Routenmodus"
|
|
125
|
+
},
|
|
126
|
+
"stats": {
|
|
127
|
+
"participants": "Teilnehmende: {count, number, compact}"
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
This becomes:
|
|
134
|
+
|
|
135
|
+
- `app.route.modeLabel`
|
|
136
|
+
- `app.stats.participants`
|
|
137
|
+
|
|
138
|
+
Message values must be either strings or nested objects.
|
|
139
|
+
|
|
140
|
+
## Options
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
type BuildTimeI18nPluginOptions = {
|
|
144
|
+
locale: string;
|
|
145
|
+
localesDir?: string;
|
|
146
|
+
include?: RegExp;
|
|
147
|
+
functionName?: string;
|
|
148
|
+
strictMissing?: boolean;
|
|
149
|
+
failOnDynamicKeys?: boolean;
|
|
150
|
+
};
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### `locale`
|
|
154
|
+
|
|
155
|
+
Active locale code. The plugin reads `<localesDir>/<locale>.json`.
|
|
156
|
+
|
|
157
|
+
### `localesDir`
|
|
158
|
+
|
|
159
|
+
Directory containing locale JSON files.
|
|
160
|
+
|
|
161
|
+
Default: `<projectRoot>/i18n/locales` (resolved from `process.cwd()`).
|
|
162
|
+
|
|
163
|
+
### `include`
|
|
164
|
+
|
|
165
|
+
Regular expression used to choose which files run through the transform hook.
|
|
166
|
+
|
|
167
|
+
Default:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
/\.[cm]?[jt]sx?$/
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### `functionName`
|
|
174
|
+
|
|
175
|
+
Identifier name to rewrite.
|
|
176
|
+
|
|
177
|
+
Default: `"t"`
|
|
178
|
+
|
|
179
|
+
Only direct identifier calls are rewritten:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
t("app.page.title");
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
These are not rewritten:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
i18n.t("app.page.title");
|
|
189
|
+
translations[fn]("app.page.title");
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### `strictMissing`
|
|
193
|
+
|
|
194
|
+
Controls how missing keys are handled.
|
|
195
|
+
|
|
196
|
+
- `true` (default): fail the build
|
|
197
|
+
- `false`: warn and replace with the key string
|
|
198
|
+
|
|
199
|
+
### `failOnDynamicKeys`
|
|
200
|
+
|
|
201
|
+
Controls how non-literal translation keys are handled.
|
|
202
|
+
|
|
203
|
+
- `true` (default): fail the build
|
|
204
|
+
- `false`: warn and leave the call non-precompiled
|
|
205
|
+
|
|
206
|
+
## Supported message syntax
|
|
207
|
+
|
|
208
|
+
This plugin supports a focused subset of ICU-style message formatting.
|
|
209
|
+
|
|
210
|
+
### Variables
|
|
211
|
+
|
|
212
|
+
```txt
|
|
213
|
+
{name}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Numbers
|
|
217
|
+
|
|
218
|
+
```txt
|
|
219
|
+
{amount, number}
|
|
220
|
+
{amount, number, integer}
|
|
221
|
+
{amount, number, percent}
|
|
222
|
+
{amount, number, compact}
|
|
223
|
+
{amount, number, currency:EUR}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Dates and times
|
|
227
|
+
|
|
228
|
+
```txt
|
|
229
|
+
{when, date}
|
|
230
|
+
{when, date, short}
|
|
231
|
+
{when, date, medium}
|
|
232
|
+
{when, date, long}
|
|
233
|
+
{when, date, full}
|
|
234
|
+
|
|
235
|
+
{when, time}
|
|
236
|
+
{when, time, short}
|
|
237
|
+
{when, time, medium}
|
|
238
|
+
{when, time, long}
|
|
239
|
+
{when, time, full}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Select
|
|
243
|
+
|
|
244
|
+
```txt
|
|
245
|
+
{status, select, open {Open} closed {Closed} other {Unknown}}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Plural
|
|
249
|
+
|
|
250
|
+
```txt
|
|
251
|
+
{count, plural, =0 {No items} one {# item} other {# items}}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Rules:
|
|
255
|
+
|
|
256
|
+
- `plural` and `select` must include `other`
|
|
257
|
+
- `#` is only meaningful inside plural branches
|
|
258
|
+
- invalid styles fail during catalog precompile
|
|
259
|
+
|
|
260
|
+
## Diagnostics
|
|
261
|
+
|
|
262
|
+
The plugin reports diagnostics with the prefix `[build-time-i18n]`.
|
|
263
|
+
|
|
264
|
+
It can report:
|
|
265
|
+
|
|
266
|
+
- missing translation keys
|
|
267
|
+
- unused translation keys
|
|
268
|
+
- dynamic translation calls that cannot be precompiled
|
|
269
|
+
- invalid message syntax or unsupported formatting styles
|
|
270
|
+
|
|
271
|
+
## Caveats
|
|
272
|
+
|
|
273
|
+
- This is not a full ICU MessageFormat implementation.
|
|
274
|
+
- Only direct calls to the configured function name are rewritten.
|
|
275
|
+
- The first argument must be a string literal to be precompiled.
|
|
276
|
+
- The plugin applies only to Vite build mode.
|
|
277
|
+
- Locale files must be valid JSON and must contain a top-level object.
|
|
278
|
+
|
|
279
|
+
## Advanced
|
|
280
|
+
|
|
281
|
+
When a message needs runtime formatting, the plugin injects a virtual helper import:
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
import { __i18nFormat } from "virtual:build-time-i18n-helper";
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
That helper uses native `Intl.PluralRules`, `Intl.NumberFormat`, and
|
|
288
|
+
`Intl.DateTimeFormat` under the hood.
|
|
289
|
+
|
|
290
|
+
## Development
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
npm install
|
|
294
|
+
npm run typecheck
|
|
295
|
+
npm test
|
|
296
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vite-plugin-build-time-i18n",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Vite plugin for build-time i18n with static translation replacement and ICU message precompilation.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"vite",
|
|
7
|
+
"vite-plugin",
|
|
8
|
+
"i18n",
|
|
9
|
+
"internationalization",
|
|
10
|
+
"build-time",
|
|
11
|
+
"translation"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/aheissenberger/vite-plugin-build-time-i18n.git"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/aheissenberger/vite-plugin-build-time-i18n",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/aheissenberger/vite-plugin-build-time-i18n/issues"
|
|
20
|
+
},
|
|
21
|
+
"author": "Andreas Heissenberger <andreas@heissenberger.at>",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=25"
|
|
26
|
+
},
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./src/index.ts"
|
|
29
|
+
},
|
|
30
|
+
"types": "./src/index.ts",
|
|
31
|
+
"files": [
|
|
32
|
+
"src/index.ts"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"dev": "node src/index.ts",
|
|
36
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"test:watch": "vitest",
|
|
39
|
+
"test:coverage": "vitest run --coverage"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"vite": "^8.0.0"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"magic-string": "^0.30.21"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^24.0.0",
|
|
49
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
50
|
+
"typescript": "^5.9.2",
|
|
51
|
+
"vite": "^8.0.0",
|
|
52
|
+
"vitest": "^4.1.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,1057 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import MagicString from "magic-string";
|
|
4
|
+
import type { Plugin } from "vite";
|
|
5
|
+
|
|
6
|
+
const VIRTUAL_HELPER_ID = "virtual:build-time-i18n-helper";
|
|
7
|
+
const RESOLVED_VIRTUAL_HELPER_ID = `\0${VIRTUAL_HELPER_ID}`;
|
|
8
|
+
const DEFAULT_LOCALES_DIR = path.resolve(process.cwd(), "i18n", "locales");
|
|
9
|
+
|
|
10
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
11
|
+
|
|
12
|
+
type JsonObject = { [key: string]: JsonValue };
|
|
13
|
+
|
|
14
|
+
type BuildTimeI18nPluginOptions = {
|
|
15
|
+
locale: string;
|
|
16
|
+
localesDir?: string;
|
|
17
|
+
include?: RegExp;
|
|
18
|
+
functionName?: string;
|
|
19
|
+
strictMissing?: boolean;
|
|
20
|
+
failOnDynamicKeys?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type CompiledCatalogEntry = {
|
|
24
|
+
raw: string;
|
|
25
|
+
compiled: CompiledMessage;
|
|
26
|
+
serializedCompiled: string;
|
|
27
|
+
needsFormatter: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type Replacement = {
|
|
31
|
+
start: number;
|
|
32
|
+
end: number;
|
|
33
|
+
text: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type CompiledMessage = {
|
|
37
|
+
type: "message";
|
|
38
|
+
parts: CompiledPart[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type CompiledPart =
|
|
42
|
+
| { type: "text"; value: string }
|
|
43
|
+
| { type: "var"; name: string }
|
|
44
|
+
| { type: "number"; name: string; style?: string }
|
|
45
|
+
| { type: "date"; name: string; style?: string }
|
|
46
|
+
| { type: "time"; name: string; style?: string }
|
|
47
|
+
| { type: "pound" }
|
|
48
|
+
| { type: "plural"; name: string; options: Record<string, CompiledMessage> }
|
|
49
|
+
| { type: "select"; name: string; options: Record<string, CompiledMessage> };
|
|
50
|
+
|
|
51
|
+
type CallExpressionNode = {
|
|
52
|
+
type: "CallExpression";
|
|
53
|
+
start?: number;
|
|
54
|
+
end?: number;
|
|
55
|
+
callee?: unknown;
|
|
56
|
+
arguments?: unknown[];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type LiteralNode = {
|
|
60
|
+
type: "Literal";
|
|
61
|
+
value?: unknown;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type IdentifierNode = {
|
|
65
|
+
type: "Identifier";
|
|
66
|
+
name?: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type ProgramNode = {
|
|
70
|
+
type: "Program";
|
|
71
|
+
body?: Array<{
|
|
72
|
+
type?: string;
|
|
73
|
+
start?: number;
|
|
74
|
+
end?: number;
|
|
75
|
+
directive?: string;
|
|
76
|
+
}>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type ParserLang = "js" | "jsx" | "ts" | "tsx" | "dts";
|
|
80
|
+
|
|
81
|
+
const DEFAULT_INCLUDE = /\.[cm]?[jt]sx?$/;
|
|
82
|
+
|
|
83
|
+
/*
|
|
84
|
+
* Supported compile-time message subset:
|
|
85
|
+
* - Variable: {name}
|
|
86
|
+
* - Number: {value, number[, integer|percent|compact|currency:EUR]}
|
|
87
|
+
* - Date: {value, date[, short|medium|long|full]}
|
|
88
|
+
* - Time: {value, time[, short|medium|long|full]}
|
|
89
|
+
* - Select: {status, select, key {...} other {...}}
|
|
90
|
+
* - Plural: {count, plural, =0 {...} one {...} other {...}}
|
|
91
|
+
*
|
|
92
|
+
* Non-goals for this parser:
|
|
93
|
+
* - Full ICU MessageFormat grammar support
|
|
94
|
+
* - Dynamic key precompilation (non-literal t(arg0, ...))
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
export function buildTimeI18nPlugin(options: BuildTimeI18nPluginOptions): Plugin[] {
|
|
98
|
+
const include = options.include ?? DEFAULT_INCLUDE;
|
|
99
|
+
const functionName = options.functionName ?? "t";
|
|
100
|
+
const strictMissing = options.strictMissing ?? true;
|
|
101
|
+
const failOnDynamicKeys = options.failOnDynamicKeys ?? true;
|
|
102
|
+
|
|
103
|
+
let localeMap = new Map<string, string>();
|
|
104
|
+
let compiledCatalog = new Map<string, CompiledCatalogEntry>();
|
|
105
|
+
let localeFilePath = "";
|
|
106
|
+
const usedKeys = new Set<string>();
|
|
107
|
+
const missingKeys = new Set<string>();
|
|
108
|
+
let dynamicCallCount = 0;
|
|
109
|
+
let auditReported = false;
|
|
110
|
+
|
|
111
|
+
return [
|
|
112
|
+
{
|
|
113
|
+
name: "vite-plugin-build-time-i18n",
|
|
114
|
+
enforce: "pre",
|
|
115
|
+
apply: "build",
|
|
116
|
+
buildStart() {
|
|
117
|
+
localeFilePath = resolveLocaleFilePath(options.locale, options.localesDir);
|
|
118
|
+
this.addWatchFile(localeFilePath);
|
|
119
|
+
usedKeys.clear();
|
|
120
|
+
missingKeys.clear();
|
|
121
|
+
dynamicCallCount = 0;
|
|
122
|
+
auditReported = false;
|
|
123
|
+
|
|
124
|
+
const catalog = readJsonFile(localeFilePath);
|
|
125
|
+
localeMap = flattenSectionedMessages(catalog);
|
|
126
|
+
compiledCatalog = precompileCatalog(localeMap);
|
|
127
|
+
|
|
128
|
+
this.info(
|
|
129
|
+
`[build-time-i18n] loaded ${localeMap.size} messages from ${normalizeForLog(localeFilePath)}`,
|
|
130
|
+
);
|
|
131
|
+
},
|
|
132
|
+
resolveId: {
|
|
133
|
+
filter: {
|
|
134
|
+
id: /^virtual:build-time-i18n-helper$/,
|
|
135
|
+
},
|
|
136
|
+
handler(id: string) {
|
|
137
|
+
if (id === VIRTUAL_HELPER_ID) {
|
|
138
|
+
return RESOLVED_VIRTUAL_HELPER_ID;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
load: {
|
|
144
|
+
handler(id: string) {
|
|
145
|
+
if (id !== RESOLVED_VIRTUAL_HELPER_ID) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
code: createInterpolationHelperSource(),
|
|
151
|
+
map: null,
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
transform: {
|
|
156
|
+
filter: {
|
|
157
|
+
id: include,
|
|
158
|
+
},
|
|
159
|
+
handler(
|
|
160
|
+
this: {
|
|
161
|
+
parse: (source: string, options?: { lang?: ParserLang } | null) => unknown;
|
|
162
|
+
warn: (message: string) => void;
|
|
163
|
+
error: (message: string) => never;
|
|
164
|
+
},
|
|
165
|
+
code: string,
|
|
166
|
+
id: string,
|
|
167
|
+
) {
|
|
168
|
+
if (!mightContainTranslationCalls(code, functionName)) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const ast = this.parse(code, getParserOptionsForId(id));
|
|
173
|
+
const analysis = analyzeTranslationCalls(ast, functionName);
|
|
174
|
+
dynamicCallCount += analysis.dynamicCalls;
|
|
175
|
+
|
|
176
|
+
if (analysis.dynamicCalls > 0) {
|
|
177
|
+
const message = `[build-time-i18n] found ${analysis.dynamicCalls} dynamic translation call(s) in ${normalizeForLog(id)}. Use string literal keys for compile-time replacement.`;
|
|
178
|
+
if (failOnDynamicKeys) {
|
|
179
|
+
this.error(message);
|
|
180
|
+
} else {
|
|
181
|
+
this.warn(message);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (analysis.literalCallSites.length === 0) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const replacements: Replacement[] = [];
|
|
190
|
+
let helperIsNeeded = false;
|
|
191
|
+
|
|
192
|
+
for (const callSite of analysis.literalCallSites) {
|
|
193
|
+
usedKeys.add(callSite.key);
|
|
194
|
+
|
|
195
|
+
const replacement = buildCallReplacement({
|
|
196
|
+
callSite,
|
|
197
|
+
source: code,
|
|
198
|
+
compiledCatalog,
|
|
199
|
+
strictMissing,
|
|
200
|
+
locale: options.locale,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (replacement.missingKey) {
|
|
204
|
+
missingKeys.add(replacement.missingKey);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
replacements.push(replacement.replacement);
|
|
208
|
+
helperIsNeeded ||= replacement.helperIsNeeded;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const magicString = new MagicString(code);
|
|
212
|
+
applyReplacementsToMagicString(magicString, replacements);
|
|
213
|
+
|
|
214
|
+
if (helperIsNeeded) {
|
|
215
|
+
injectImportAfterDirectivePrologue(
|
|
216
|
+
magicString,
|
|
217
|
+
code,
|
|
218
|
+
ast,
|
|
219
|
+
`import { __i18nFormat } from "${VIRTUAL_HELPER_ID}";\n`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
code: magicString.toString(),
|
|
225
|
+
map: magicString.generateMap({
|
|
226
|
+
source: id,
|
|
227
|
+
includeContent: true,
|
|
228
|
+
hires: true,
|
|
229
|
+
}),
|
|
230
|
+
};
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
generateBundle() {
|
|
234
|
+
const environmentName = (this as { environment?: { name?: string } }).environment?.name;
|
|
235
|
+
if (environmentName && environmentName !== "client") {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (auditReported) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
auditReported = true;
|
|
244
|
+
|
|
245
|
+
if (missingKeys.size > 0) {
|
|
246
|
+
const missing = [...missingKeys].sort();
|
|
247
|
+
const message = `[build-time-i18n] missing translation keys for locale ${options.locale}: ${missing.join(", ")}`;
|
|
248
|
+
this.warn(message);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const unusedKeys = [...localeMap.keys()].filter((key) => !usedKeys.has(key)).sort();
|
|
252
|
+
if (unusedKeys.length > 0) {
|
|
253
|
+
const preview = unusedKeys.slice(0, 10).join(", ");
|
|
254
|
+
const suffix = unusedKeys.length > 10 ? ` (+${unusedKeys.length - 10} more)` : "";
|
|
255
|
+
this.warn(
|
|
256
|
+
`[build-time-i18n] ${unusedKeys.length} unused translation keys in ${normalizeForLog(localeFilePath)}: ${preview}${suffix}`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (dynamicCallCount > 0) {
|
|
261
|
+
this.warn(
|
|
262
|
+
`[build-time-i18n] encountered ${dynamicCallCount} dynamic translation call(s) that cannot be precompiled.`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
type BuildCallReplacementInput = {
|
|
271
|
+
callSite: { start: number; end: number; key: string; paramsArg?: { start: number; end: number } };
|
|
272
|
+
source: string;
|
|
273
|
+
compiledCatalog: Map<string, CompiledCatalogEntry>;
|
|
274
|
+
strictMissing: boolean;
|
|
275
|
+
locale: string;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
function buildCallReplacement(input: BuildCallReplacementInput) {
|
|
279
|
+
const entry = input.compiledCatalog.get(input.callSite.key);
|
|
280
|
+
|
|
281
|
+
if (!entry) {
|
|
282
|
+
if (input.strictMissing) {
|
|
283
|
+
throw new Error(`Missing translation key: ${input.callSite.key}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
missingKey: input.callSite.key,
|
|
288
|
+
helperIsNeeded: false,
|
|
289
|
+
replacement: {
|
|
290
|
+
start: input.callSite.start,
|
|
291
|
+
end: input.callSite.end,
|
|
292
|
+
text: JSON.stringify(input.callSite.key),
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!input.callSite.paramsArg && !entry.needsFormatter) {
|
|
298
|
+
return {
|
|
299
|
+
missingKey: undefined,
|
|
300
|
+
helperIsNeeded: false,
|
|
301
|
+
replacement: {
|
|
302
|
+
start: input.callSite.start,
|
|
303
|
+
end: input.callSite.end,
|
|
304
|
+
text: JSON.stringify(entry.raw),
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const paramsExpression = input.callSite.paramsArg
|
|
310
|
+
? input.source.slice(input.callSite.paramsArg.start, input.callSite.paramsArg.end)
|
|
311
|
+
: "undefined";
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
missingKey: undefined,
|
|
315
|
+
helperIsNeeded: true,
|
|
316
|
+
replacement: {
|
|
317
|
+
start: input.callSite.start,
|
|
318
|
+
end: input.callSite.end,
|
|
319
|
+
text: `__i18nFormat(${entry.serializedCompiled}, ${paramsExpression}, ${JSON.stringify(input.locale)})`,
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function applyReplacementsToMagicString(magicString: MagicString, replacements: Replacement[]) {
|
|
325
|
+
const sorted = [...replacements].sort((left, right) => right.start - left.start);
|
|
326
|
+
|
|
327
|
+
for (const replacement of sorted) {
|
|
328
|
+
magicString.overwrite(replacement.start, replacement.end, replacement.text);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function analyzeTranslationCalls(ast: unknown, functionName: string) {
|
|
333
|
+
const literalCallSites: Array<{
|
|
334
|
+
start: number;
|
|
335
|
+
end: number;
|
|
336
|
+
key: string;
|
|
337
|
+
paramsArg?: { start: number; end: number };
|
|
338
|
+
}> = [];
|
|
339
|
+
let dynamicCalls = 0;
|
|
340
|
+
|
|
341
|
+
walkAst(ast, (node) => {
|
|
342
|
+
const callNode = node as Partial<CallExpressionNode>;
|
|
343
|
+
|
|
344
|
+
if (callNode.type !== "CallExpression") {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const callStart = callNode.start;
|
|
349
|
+
const callEnd = callNode.end;
|
|
350
|
+
|
|
351
|
+
if (typeof callStart !== "number" || typeof callEnd !== "number") {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!isSupportedTranslationCallee(callNode.callee, functionName)) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const args = Array.isArray(callNode.arguments) ? callNode.arguments : [];
|
|
360
|
+
const firstArg = args[0] as Partial<LiteralNode> | undefined;
|
|
361
|
+
|
|
362
|
+
if (!firstArg) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!isStringLiteral(firstArg)) {
|
|
367
|
+
dynamicCalls += 1;
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const key = firstArg.value;
|
|
372
|
+
const paramsArg = args[1] as { start?: number; end?: number } | undefined;
|
|
373
|
+
|
|
374
|
+
const site: {
|
|
375
|
+
start: number;
|
|
376
|
+
end: number;
|
|
377
|
+
key: string;
|
|
378
|
+
paramsArg?: { start: number; end: number };
|
|
379
|
+
} = {
|
|
380
|
+
start: callStart,
|
|
381
|
+
end: callEnd,
|
|
382
|
+
key,
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
if (typeof paramsArg?.start === "number" && typeof paramsArg?.end === "number") {
|
|
386
|
+
site.paramsArg = {
|
|
387
|
+
start: paramsArg.start,
|
|
388
|
+
end: paramsArg.end,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
literalCallSites.push(site);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
literalCallSites,
|
|
397
|
+
dynamicCalls,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function isSupportedTranslationCallee(callee: unknown, functionName: string) {
|
|
402
|
+
const identifier = callee as Partial<IdentifierNode>;
|
|
403
|
+
return identifier.type === "Identifier" && identifier.name === functionName;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function isStringLiteral(
|
|
407
|
+
node: Partial<LiteralNode> | undefined,
|
|
408
|
+
): node is LiteralNode & { value: string } {
|
|
409
|
+
return node?.type === "Literal" && typeof node.value === "string";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function walkAst(node: unknown, visit: (value: unknown) => void) {
|
|
413
|
+
if (!node || typeof node !== "object") {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (Array.isArray(node)) {
|
|
418
|
+
for (const item of node) {
|
|
419
|
+
walkAst(item, visit);
|
|
420
|
+
}
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!isAstNode(node)) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
visit(node);
|
|
429
|
+
|
|
430
|
+
for (const [key, value] of Object.entries(node)) {
|
|
431
|
+
if (
|
|
432
|
+
key === "type" ||
|
|
433
|
+
key === "start" ||
|
|
434
|
+
key === "end" ||
|
|
435
|
+
key === "loc" ||
|
|
436
|
+
key === "range" ||
|
|
437
|
+
key === "raw" ||
|
|
438
|
+
key === "name" ||
|
|
439
|
+
key === "value" ||
|
|
440
|
+
key === "operator" ||
|
|
441
|
+
key === "kind" ||
|
|
442
|
+
key === "directive" ||
|
|
443
|
+
key === "sourceType"
|
|
444
|
+
) {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
walkAst(value, visit);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function isAstNode(value: unknown): value is { type: string } {
|
|
453
|
+
return (
|
|
454
|
+
Boolean(value) &&
|
|
455
|
+
typeof value === "object" &&
|
|
456
|
+
typeof (value as { type?: unknown }).type === "string"
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function precompileCatalog(catalog: Map<string, string>) {
|
|
461
|
+
const compiled = new Map<string, CompiledCatalogEntry>();
|
|
462
|
+
|
|
463
|
+
for (const [key, raw] of catalog.entries()) {
|
|
464
|
+
const compiledMessage = compileMessage(raw, false, `key ${key}`);
|
|
465
|
+
validateCompiledMessage(compiledMessage, key);
|
|
466
|
+
compiled.set(key, {
|
|
467
|
+
raw,
|
|
468
|
+
compiled: compiledMessage,
|
|
469
|
+
serializedCompiled: JSON.stringify(compiledMessage),
|
|
470
|
+
needsFormatter: messageNeedsFormatter(compiledMessage),
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return compiled;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function injectImportAfterDirectivePrologue(
|
|
478
|
+
magicString: MagicString,
|
|
479
|
+
code: string,
|
|
480
|
+
ast: unknown,
|
|
481
|
+
importStatement: string,
|
|
482
|
+
) {
|
|
483
|
+
if (hasHelperImport(ast, code, VIRTUAL_HELPER_ID)) {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const insertionIndex = findDirectiveAwareInsertionIndex(code, ast);
|
|
488
|
+
magicString.appendLeft(insertionIndex, importStatement);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function hasHelperImport(ast: unknown, code: string, helperId: string): boolean {
|
|
492
|
+
const program = ast as Partial<ProgramNode>;
|
|
493
|
+
const body = Array.isArray(program.body) ? program.body : [];
|
|
494
|
+
|
|
495
|
+
for (const node of body) {
|
|
496
|
+
const importNode = node as {
|
|
497
|
+
type?: string;
|
|
498
|
+
source?: { type?: string; value?: unknown };
|
|
499
|
+
specifiers?: Array<{
|
|
500
|
+
type?: string;
|
|
501
|
+
imported?: { type?: string; name?: string };
|
|
502
|
+
local?: { type?: string; name?: string };
|
|
503
|
+
}>;
|
|
504
|
+
start?: number;
|
|
505
|
+
end?: number;
|
|
506
|
+
};
|
|
507
|
+
if (importNode.type !== "ImportDeclaration") {
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const sourceValue = importNode.source?.value;
|
|
512
|
+
if (typeof sourceValue !== "string" || sourceValue !== helperId) {
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const specifiers = Array.isArray(importNode.specifiers) ? importNode.specifiers : [];
|
|
517
|
+
for (const specifier of specifiers) {
|
|
518
|
+
if (specifier.local?.type === "Identifier" && specifier.local.name === "__i18nFormat") {
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function findDirectiveAwareInsertionIndex(code: string, ast: unknown): number {
|
|
528
|
+
const program = ast as Partial<ProgramNode>;
|
|
529
|
+
const body = Array.isArray(program.body) ? program.body : [];
|
|
530
|
+
let insertionIndex = 0;
|
|
531
|
+
|
|
532
|
+
for (const node of body) {
|
|
533
|
+
if (node.type !== "ExpressionStatement" || typeof node.directive !== "string") {
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (typeof node.end === "number") {
|
|
538
|
+
insertionIndex = node.end;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (insertionIndex === 0) {
|
|
546
|
+
return 0;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
while (insertionIndex < code.length) {
|
|
550
|
+
const char = code[insertionIndex];
|
|
551
|
+
if (char !== "\n" && char !== "\r") {
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
insertionIndex += 1;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return insertionIndex;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function resolveLocaleFilePath(locale: string, localesDir: string | undefined) {
|
|
561
|
+
const baseDir = localesDir ?? DEFAULT_LOCALES_DIR;
|
|
562
|
+
return path.join(baseDir, `${locale}.json`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function readJsonFile(filePath: string): JsonObject {
|
|
566
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
567
|
+
const data = JSON.parse(raw) as JsonValue;
|
|
568
|
+
|
|
569
|
+
if (!isJsonObject(data)) {
|
|
570
|
+
throw new Error(`Expected top-level JSON object in ${normalizeForLog(filePath)}`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return data;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function flattenSectionedMessages(
|
|
577
|
+
input: JsonObject,
|
|
578
|
+
prefix = "",
|
|
579
|
+
out: Map<string, string> = new Map(),
|
|
580
|
+
): Map<string, string> {
|
|
581
|
+
for (const [key, value] of Object.entries(input)) {
|
|
582
|
+
const nextKey = prefix ? `${prefix}.${key}` : key;
|
|
583
|
+
|
|
584
|
+
if (typeof value === "string") {
|
|
585
|
+
out.set(nextKey, value);
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (isJsonObject(value)) {
|
|
590
|
+
flattenSectionedMessages(value, nextKey, out);
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
throw new Error(
|
|
595
|
+
`Invalid message value for key ${nextKey}. Expected string or object section, got ${typeof value}.`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return out;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function isJsonObject(value: unknown): value is JsonObject {
|
|
603
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function mightContainTranslationCalls(source: string, functionName: string) {
|
|
607
|
+
return source.includes(`${functionName}(`) || source.includes(`.${functionName}(`);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function getParserOptionsForId(id: string): { lang: ParserLang } {
|
|
611
|
+
if (id.endsWith(".tsx")) {
|
|
612
|
+
return { lang: "tsx" };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (id.endsWith(".ts")) {
|
|
616
|
+
return { lang: "ts" };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (id.endsWith(".jsx")) {
|
|
620
|
+
return { lang: "jsx" };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return { lang: "js" };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function normalizeForLog(inputPath: string) {
|
|
627
|
+
return inputPath.split(path.sep).join("/");
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function createInterpolationHelperSource() {
|
|
631
|
+
return `
|
|
632
|
+
const __i18nPluralRulesCache = new Map();
|
|
633
|
+
const __i18nNumberFormatCache = new Map();
|
|
634
|
+
const __i18nDateTimeFormatCache = new Map();
|
|
635
|
+
|
|
636
|
+
export function __i18nFormat(compiledMessage, values, locale) {
|
|
637
|
+
return formatMessage(compiledMessage, values, locale || "en", undefined);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function formatMessage(compiledMessage, values, locale, pluralCount) {
|
|
641
|
+
if (!compiledMessage || !Array.isArray(compiledMessage.parts)) {
|
|
642
|
+
return "";
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
let output = "";
|
|
646
|
+
for (const part of compiledMessage.parts) {
|
|
647
|
+
if (part.type === "text") {
|
|
648
|
+
output += part.value;
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (part.type === "var") {
|
|
653
|
+
const value = readPath(values, part.name);
|
|
654
|
+
output += value == null ? "" : String(value);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (part.type === "number") {
|
|
659
|
+
const value = readPath(values, part.name);
|
|
660
|
+
output += formatNumber(value, locale, part.style);
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (part.type === "date") {
|
|
665
|
+
const value = readPath(values, part.name);
|
|
666
|
+
output += formatDate(value, locale, part.style);
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (part.type === "time") {
|
|
671
|
+
const value = readPath(values, part.name);
|
|
672
|
+
output += formatTime(value, locale, part.style);
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (part.type === "pound") {
|
|
677
|
+
output += pluralCount == null ? "#" : String(pluralCount);
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (part.type === "select") {
|
|
682
|
+
const raw = readPath(values, part.name);
|
|
683
|
+
const key = raw == null ? "other" : String(raw);
|
|
684
|
+
const selected = part.options[key] ?? part.options.other;
|
|
685
|
+
output += selected ? formatMessage(selected, values, locale, pluralCount) : "";
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (part.type === "plural") {
|
|
690
|
+
const raw = readPath(values, part.name);
|
|
691
|
+
const count = Number(raw);
|
|
692
|
+
const explicitKey = Number.isFinite(count) ? "=" + String(count) : "";
|
|
693
|
+
const optionKey = explicitKey && part.options[explicitKey] ? explicitKey : getPluralCategory(count, locale);
|
|
694
|
+
const selected = part.options[optionKey] ?? part.options.other;
|
|
695
|
+
output += selected
|
|
696
|
+
? formatMessage(selected, values, locale, Number.isFinite(count) ? count : 0)
|
|
697
|
+
: "";
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return output;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function readPath(values, dotPath) {
|
|
705
|
+
const segments = String(dotPath).split(".");
|
|
706
|
+
let current = values;
|
|
707
|
+
|
|
708
|
+
for (const segment of segments) {
|
|
709
|
+
if (!current || typeof current !== "object") {
|
|
710
|
+
return undefined;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
current = current[segment];
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return current;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function getPluralCategory(count, locale) {
|
|
720
|
+
if (!Number.isFinite(count)) {
|
|
721
|
+
return "other";
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const key = locale;
|
|
725
|
+
let pluralRules = __i18nPluralRulesCache.get(key);
|
|
726
|
+
if (!pluralRules) {
|
|
727
|
+
pluralRules = new Intl.PluralRules(locale, { type: "cardinal" });
|
|
728
|
+
__i18nPluralRulesCache.set(key, pluralRules);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return pluralRules.select(count);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function formatNumber(value, locale, style) {
|
|
735
|
+
const numeric = Number(value);
|
|
736
|
+
if (!Number.isFinite(numeric)) {
|
|
737
|
+
return value == null ? "" : String(value);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const key = locale + "|number|" + String(style || "default");
|
|
741
|
+
let formatter = __i18nNumberFormatCache.get(key);
|
|
742
|
+
if (!formatter) {
|
|
743
|
+
formatter = new Intl.NumberFormat(locale, toNumberFormatOptions(style));
|
|
744
|
+
__i18nNumberFormatCache.set(key, formatter);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return formatter.format(numeric);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function formatDate(value, locale, style) {
|
|
751
|
+
const date = toDate(value);
|
|
752
|
+
if (!date) {
|
|
753
|
+
return value == null ? "" : String(value);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const finalStyle = normalizeDateTimeStyle(style);
|
|
757
|
+
const key = locale + "|date|" + finalStyle;
|
|
758
|
+
let formatter = __i18nDateTimeFormatCache.get(key);
|
|
759
|
+
if (!formatter) {
|
|
760
|
+
formatter = new Intl.DateTimeFormat(locale, { dateStyle: finalStyle });
|
|
761
|
+
__i18nDateTimeFormatCache.set(key, formatter);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return formatter.format(date);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function formatTime(value, locale, style) {
|
|
768
|
+
const date = toDate(value);
|
|
769
|
+
if (!date) {
|
|
770
|
+
return value == null ? "" : String(value);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const finalStyle = normalizeDateTimeStyle(style);
|
|
774
|
+
const key = locale + "|time|" + finalStyle;
|
|
775
|
+
let formatter = __i18nDateTimeFormatCache.get(key);
|
|
776
|
+
if (!formatter) {
|
|
777
|
+
formatter = new Intl.DateTimeFormat(locale, { timeStyle: finalStyle });
|
|
778
|
+
__i18nDateTimeFormatCache.set(key, formatter);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
return formatter.format(date);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function toDate(value) {
|
|
785
|
+
if (value instanceof Date) {
|
|
786
|
+
return Number.isNaN(value.getTime()) ? null : value;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (typeof value === "number" || typeof value === "string") {
|
|
790
|
+
const parsed = new Date(value);
|
|
791
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function toNumberFormatOptions(style) {
|
|
798
|
+
if (style === "integer") {
|
|
799
|
+
return { maximumFractionDigits: 0 };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (style === "percent") {
|
|
803
|
+
return { style: "percent" };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (style && style.startsWith("currency:")) {
|
|
807
|
+
const currency = style.slice("currency:".length).toUpperCase();
|
|
808
|
+
return { style: "currency", currency };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (style === "compact") {
|
|
812
|
+
return { notation: "compact" };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
return {};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function normalizeDateTimeStyle(style) {
|
|
819
|
+
if (style === "full" || style === "long" || style === "medium" || style === "short") {
|
|
820
|
+
return style;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return "medium";
|
|
824
|
+
}
|
|
825
|
+
`;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function messageNeedsFormatter(compiledMessage: CompiledMessage): boolean {
|
|
829
|
+
return compiledMessage.parts.some((part) => part.type !== "text");
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function compileMessage(
|
|
833
|
+
input: string,
|
|
834
|
+
allowPound = false,
|
|
835
|
+
contextLabel = "message",
|
|
836
|
+
): CompiledMessage {
|
|
837
|
+
const parts: CompiledPart[] = [];
|
|
838
|
+
let cursor = 0;
|
|
839
|
+
let textBuffer = "";
|
|
840
|
+
|
|
841
|
+
const flushText = () => {
|
|
842
|
+
if (!textBuffer) {
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
parts.push({ type: "text", value: textBuffer });
|
|
846
|
+
textBuffer = "";
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
while (cursor < input.length) {
|
|
850
|
+
const char = input[cursor];
|
|
851
|
+
|
|
852
|
+
if (char === "#" && allowPound) {
|
|
853
|
+
flushText();
|
|
854
|
+
parts.push({ type: "pound" });
|
|
855
|
+
cursor += 1;
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (char !== "{") {
|
|
860
|
+
textBuffer += char;
|
|
861
|
+
cursor += 1;
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const end = findMatchingBrace(input, cursor);
|
|
866
|
+
if (end < 0) {
|
|
867
|
+
textBuffer += char;
|
|
868
|
+
cursor += 1;
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const inside = input.slice(cursor + 1, end);
|
|
873
|
+
const placeholder = parsePlaceholder(inside, contextLabel);
|
|
874
|
+
if (!placeholder) {
|
|
875
|
+
textBuffer += input.slice(cursor, end + 1);
|
|
876
|
+
cursor = end + 1;
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
flushText();
|
|
881
|
+
parts.push(placeholder);
|
|
882
|
+
cursor = end + 1;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
flushText();
|
|
886
|
+
return { type: "message", parts };
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function parsePlaceholder(input: string, contextLabel: string): CompiledPart | null {
|
|
890
|
+
const firstComma = input.indexOf(",");
|
|
891
|
+
if (firstComma < 0) {
|
|
892
|
+
const name = input.trim();
|
|
893
|
+
return name ? { type: "var", name } : null;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const name = input.slice(0, firstComma).trim();
|
|
897
|
+
const rest = input.slice(firstComma + 1).trim();
|
|
898
|
+
if (!name || !rest) {
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (rest.startsWith("plural,")) {
|
|
903
|
+
const options = parseControlOptions(
|
|
904
|
+
rest.slice("plural,".length),
|
|
905
|
+
`${contextLabel} plural ${name}`,
|
|
906
|
+
);
|
|
907
|
+
return { type: "plural", name, options };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (rest.startsWith("select,")) {
|
|
911
|
+
const options = parseControlOptions(
|
|
912
|
+
rest.slice("select,".length),
|
|
913
|
+
`${contextLabel} select ${name}`,
|
|
914
|
+
);
|
|
915
|
+
return { type: "select", name, options };
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (rest.startsWith("number")) {
|
|
919
|
+
const style = parseSimpleStyle(rest, "number", contextLabel);
|
|
920
|
+
return { type: "number", name, style };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (rest.startsWith("date")) {
|
|
924
|
+
const style = parseSimpleStyle(rest, "date", contextLabel);
|
|
925
|
+
return { type: "date", name, style };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (rest.startsWith("time")) {
|
|
929
|
+
const style = parseSimpleStyle(rest, "time", contextLabel);
|
|
930
|
+
return { type: "time", name, style };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function parseSimpleStyle(input: string, kind: "number" | "date" | "time", contextLabel: string) {
|
|
937
|
+
if (input === kind) {
|
|
938
|
+
return undefined;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (input.startsWith(`${kind},`)) {
|
|
942
|
+
const raw = input.slice(kind.length + 1).trim();
|
|
943
|
+
validateSimpleStyle(kind, raw, contextLabel);
|
|
944
|
+
return raw || undefined;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return undefined;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function parseControlOptions(input: string, contextLabel: string): Record<string, CompiledMessage> {
|
|
951
|
+
const options: Record<string, CompiledMessage> = {};
|
|
952
|
+
let cursor = 0;
|
|
953
|
+
|
|
954
|
+
while (cursor < input.length) {
|
|
955
|
+
while (cursor < input.length && /\s/.test(input[cursor] ?? "")) {
|
|
956
|
+
cursor += 1;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
if (cursor >= input.length) {
|
|
960
|
+
break;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
let key = "";
|
|
964
|
+
while (cursor < input.length) {
|
|
965
|
+
const char = input[cursor] ?? "";
|
|
966
|
+
if (char === "{" || /\s/.test(char)) {
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
969
|
+
key += char;
|
|
970
|
+
cursor += 1;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
while (cursor < input.length && /\s/.test(input[cursor] ?? "")) {
|
|
974
|
+
cursor += 1;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (!key || input[cursor] !== "{") {
|
|
978
|
+
break;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const end = findMatchingBrace(input, cursor);
|
|
982
|
+
if (end < 0) {
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const messageValue = input.slice(cursor + 1, end);
|
|
987
|
+
options[key] = compileMessage(messageValue, true, `${contextLabel} option ${key}`);
|
|
988
|
+
cursor = end + 1;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return options;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function validateCompiledMessage(message: CompiledMessage, key: string) {
|
|
995
|
+
for (const part of message.parts) {
|
|
996
|
+
if (part.type === "plural" || part.type === "select") {
|
|
997
|
+
if (!part.options.other) {
|
|
998
|
+
throw new Error(`Invalid ICU message for key ${key}: missing required 'other' option.`);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
for (const option of Object.values(part.options)) {
|
|
1002
|
+
validateCompiledMessage(option, key);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function validateSimpleStyle(kind: "number" | "date" | "time", raw: string, contextLabel: string) {
|
|
1009
|
+
if (!raw) {
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if (kind === "number") {
|
|
1014
|
+
if (
|
|
1015
|
+
raw === "integer" ||
|
|
1016
|
+
raw === "percent" ||
|
|
1017
|
+
raw === "compact" ||
|
|
1018
|
+
/^currency:[A-Za-z]{3}$/.test(raw)
|
|
1019
|
+
) {
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
throw new Error(
|
|
1024
|
+
`Invalid number style '${raw}' in ${contextLabel}. Allowed: integer, percent, compact, currency:EUR.`,
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
if (raw === "full" || raw === "long" || raw === "medium" || raw === "short") {
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
throw new Error(
|
|
1033
|
+
`Invalid ${kind} style '${raw}' in ${contextLabel}. Allowed: full, long, medium, short.`,
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function findMatchingBrace(input: string, openIndex: number): number {
|
|
1038
|
+
let depth = 0;
|
|
1039
|
+
for (let index = openIndex; index < input.length; index += 1) {
|
|
1040
|
+
const char = input[index];
|
|
1041
|
+
if (char === "{") {
|
|
1042
|
+
depth += 1;
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if (char !== "}") {
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
depth -= 1;
|
|
1051
|
+
if (depth === 0) {
|
|
1052
|
+
return index;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
return -1;
|
|
1057
|
+
}
|