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.
Files changed (4) hide show
  1. package/LICENCE +21 -0
  2. package/README.md +296 -0
  3. package/package.json +54 -0
  4. 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
+ }