@gjsify/vite-plugin-gettext 0.48.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,6 +34,44 @@ Requires `gettext` tools (`msgfmt`, `xgettext`) to be installed on the system.
34
34
  fuzzy entries out of the `.mo`. A run that really does replace that many strings raises the
35
35
  option.
36
36
 
37
+ ## One catalog, three namespaces
38
+
39
+ A catalog is called one thing in the repository and has to be spelled three different ways
40
+ downstream. They disagree, so the `.po` basename is not passed through unchanged:
41
+
42
+ | Target | Namespace | `zh_Hans.po` | `pt_BR.po` |
43
+ |---|---|---|---|
44
+ | `.mo` directory (`gettextPlugin`, `msgfmtPlugin`) | POSIX locale | `locale/zh_CN/`, `locale/zh_SG/` | `locale/pt_BR/` |
45
+ | JSON file (`po2jsonPlugin`) | Android qualifier | `zh.json` | `pt-BR.json` |
46
+ | the `.po` in the repo | BCP-47-ish (Weblate) | `zh_Hans` | `pt_BR` |
47
+
48
+ Weblate names simplified Chinese `zh_Hans`, which is BCP-47 and **not** a POSIX locale — glibc
49
+ never probes it, so a catalog compiled under that name ships complete and renders in English for
50
+ every Chinese user while the build exits 0. The compiled catalog is therefore installed under the
51
+ locale directories real users actually resolve, keeping the original name alongside them so
52
+ nothing that already worked stops.
53
+
54
+ On the JSON side the filename becomes an Android resource qualifier
55
+ (`values-<lang>[-r<REGION>]`), where an underscore is illegal: `pt_BR.json` would yield
56
+ `values-pt_BR`, a directory Android never consults.
57
+
58
+ **A catalog whose script subtag maps to no known name fails the build**, naming every such
59
+ catalog at once. A build that cannot say where a catalog goes must not quietly put it somewhere —
60
+ "somewhere" is indistinguishable from "correct" until a user of that language complains. Map it
61
+ explicitly with `localeNames`, one catalog at a time, so mapping one language does not disarm the
62
+ check for the rest:
63
+
64
+ ```typescript
65
+ gettextPlugin({
66
+ poDirectory: 'po',
67
+ moDirectory: 'dist',
68
+ localeNames: { az_Arab: ['az_IR'] },
69
+ });
70
+ ```
71
+
72
+ Two catalogs that would land on one output name (a Weblate `zh_Hans.po` beside a hand-made
73
+ `zh_CN.po`) fail the same way, rather than letting one overwrite the other.
74
+
37
75
  ```typescript
38
76
  xgettextPlugin({
39
77
  sources: ['src/**/*.blp', 'data/**/*.desktop.in', '!src/**/*.generated.blp'],
@@ -0,0 +1,71 @@
1
+ import { GettextGuardError } from './guards.js';
2
+ /** A catalog name whose script subtag maps to no name we know. */
3
+ export declare class UnmappableCatalogNameError extends GettextGuardError {
4
+ /** The catalog names that could not be mapped, verbatim as the `.po` is called. */
5
+ readonly catalogs: readonly string[];
6
+ constructor(message: string,
7
+ /** The catalog names that could not be mapped, verbatim as the `.po` is called. */
8
+ catalogs: readonly string[]);
9
+ }
10
+ /** Two catalogs claim one output name; one would overwrite the other. */
11
+ export declare class CollidingCatalogNameError extends GettextGuardError {
12
+ /** The name claimed twice, and the catalogs claiming it. */
13
+ readonly claimed: string;
14
+ readonly catalogs: readonly string[];
15
+ constructor(message: string,
16
+ /** The name claimed twice, and the catalogs claiming it. */
17
+ claimed: string, catalogs: readonly string[]);
18
+ }
19
+ /** How one catalog is spelled in each namespace that consumes it. */
20
+ export interface CatalogNames {
21
+ /** The `.po` basename, verbatim. What an error message names. */
22
+ catalog: string;
23
+ /** `.mo` locale directories, in write order. */
24
+ posix: readonly string[];
25
+ /** JSON basename — a BCP-47 tag `@nativescript/localize` can qualify. */
26
+ bcp47: string;
27
+ }
28
+ /** Which generated namespace a lookup is for, so errors can say what broke. */
29
+ export type CatalogNamespace = 'posix' | 'bcp47';
30
+ /**
31
+ * How one catalog is spelled everywhere, or `undefined` when its script subtag
32
+ * has no name in the namespace asked for.
33
+ *
34
+ * The ORIGINAL name is always kept among the POSIX directories. It is additive —
35
+ * no deployment that already found a catalog can stop finding it — and it keeps
36
+ * the compiled tree greppable against the catalogs it came from. It is never the
37
+ * only entry, because on its own it is precisely what nothing probes. The JSON
38
+ * side gets no such alias: a filename is one name, and a second copy under the
39
+ * dead spelling is what left two generations in the tree to begin with.
40
+ */
41
+ export declare function catalogNames(catalog: string, overrides?: Readonly<Record<string, readonly string[]>>): CatalogNames | undefined;
42
+ /**
43
+ * The POSIX directories alone.
44
+ *
45
+ * A script with a POSIX mapping but no safe Android qualifier (`sr_Latn`) is
46
+ * answerable here and not by {@link catalogNames}: the `.mo` side knows exactly
47
+ * where it goes. Splitting the lookup this way is what lets the gettext plugin
48
+ * stay correct for catalogs the JSON plugin has to refuse.
49
+ */
50
+ export declare function posixLocaleDirectories(catalog: string, overrides?: Readonly<Record<string, readonly string[]>>): readonly string[] | undefined;
51
+ /**
52
+ * Plans every catalog for ONE namespace, and fails if any cannot be spelled.
53
+ *
54
+ * FAILING IS THE DEFAULT ON PURPOSE, on the same asymmetry the source-pattern
55
+ * guard is built on: a false failure costs one build and one line of config, a
56
+ * missed one ships a whole language that silently renders in English — which is
57
+ * what `zh_Hans` did, undetected, for as long as the catalog existed. A build
58
+ * that cannot say where a catalog goes must not quietly put it somewhere,
59
+ * because "somewhere" is indistinguishable from "correct" until a user of that
60
+ * language complains.
61
+ *
62
+ * Every unmappable name is named in ONE error rather than the first aborting, so
63
+ * a project adopting several Weblate languages fixes them in a single pass
64
+ * instead of a build-fix-build loop.
65
+ */
66
+ export declare function planCatalogNames(catalogs: readonly string[], context: {
67
+ pluginName: string;
68
+ namespace: CatalogNamespace;
69
+ /** Per-catalog override, e.g. `{ az_Arab: ['az_IR'] }`. */
70
+ localeNames?: Readonly<Record<string, readonly string[]>>;
71
+ }): CatalogNames[];
@@ -0,0 +1,328 @@
1
+ // One catalog, three namespaces — and the table that keeps them apart.
2
+ //
3
+ // A catalog is called ONE thing in the repository and has to be spelled three
4
+ // different ways downstream. They disagree on the same catalog, so passing the
5
+ // name through unchanged is wrong in two of the three places.
6
+ //
7
+ // | target | namespace | zh_Hans | pt_BR |
8
+ // |------------------------------|-------------------|---------|-------|
9
+ // | gettext `.mo` directory | POSIX locale | zh_CN | pt_BR |
10
+ // | Android resources (via JSON) | Android qualifier | zh | pt-BR |
11
+ // | Weblate / the `.po` in repo | BCP-47-ish | zh_Hans | pt_BR |
12
+ //
13
+ // THE INCIDENT, POSIX SIDE (JumpLink/Learn6502#182, measured 2026-09-11).
14
+ // Catalogs are maintained on Weblate, which names simplified Chinese `zh_Hans`.
15
+ // The compiler used the `.po` basename verbatim as the locale directory, so the
16
+ // catalog landed in `locale/zh_Hans/LC_MESSAGES/`. `zh_Hans` is BCP-47, not a
17
+ // POSIX locale name, and glibc never probes it. Measured against the real
18
+ // shipped `.mo` with the glibc `gettext` CLI as the oracle:
19
+ //
20
+ // LANGUAGE=zh_Hans -> 黄色 (a selector no real user has)
21
+ // LANGUAGE=zh_CN -> Yellow <- what an actual user in China gets
22
+ // LANGUAGE=zh_TW -> Yellow
23
+ // LANGUAGE=de -> Gelb (control: ordinary names were always fine)
24
+ //
25
+ // A complete Chinese catalog shipped and every Chinese user saw English. The
26
+ // build exited 0; 14 of the 15 catalogs were fine, which is why nobody looked.
27
+ //
28
+ // THE INCIDENT, ANDROID SIDE (JumpLink/Learn6502#179). `po2json` wrote the same
29
+ // basename as the JSON filename, and `@nativescript/localize` turns that
30
+ // filename straight into an Android resource directory (`hooks/converter.js`:
31
+ // values-${language.replace(/^(.+?)-(.+?)$/, '$1-r$2')}). Running that
32
+ // transformation over the names in play:
33
+ //
34
+ // de -> values-de ok
35
+ // pt-BR -> values-pt-rBR ok <- the name the repo tracks
36
+ // pt_BR -> values-pt_BR BROKEN <- what po2json wrote
37
+ // zh -> values-zh ok <- the name the repo tracks
38
+ // zh_Hans -> values-zh_Hans BROKEN <- what po2json wrote
39
+ // zh-Hans -> values-zh-rHans BROKEN (a script subtag needs values-b+zh+Hans)
40
+ //
41
+ // An underscore is not legal in an Android resource qualifier, so those are
42
+ // directories Android never consults. Both generations sat in the tree at once:
43
+ // the tracked, correct `pt-BR.json`/`zh.json` and the generated, dead
44
+ // `pt_BR.json`/`zh_Hans.json` beside them.
45
+ //
46
+ // NOT MEASURED: that Android then loads the resulting directory. The qualifier
47
+ // rule is derived from the hook's source and the transformation above was
48
+ // executed, but no emulator or device ran. The POSIX side, by contrast, was
49
+ // measured end to end against glibc.
50
+ //
51
+ // Why this cannot be fixed by renaming the `.po`: the name comes from Weblate,
52
+ // it is correct there, and the two downstream namespaces would still disagree
53
+ // with each other. The mapping has to happen where each artifact is written.
54
+ //
55
+ // The mapping is DATA in one table read by both plugins, rather than logic in
56
+ // each — two copies of this reasoning would drift, and the drifted one wins
57
+ // silently.
58
+ //
59
+ // The decisions live here, apart from the I/O that acts on them, so they can be
60
+ // tested without gettext installed and without a filesystem.
61
+ import { GettextGuardError } from './guards.js';
62
+ /** A catalog name whose script subtag maps to no name we know. */
63
+ export class UnmappableCatalogNameError extends GettextGuardError {
64
+ catalogs;
65
+ constructor(message,
66
+ /** The catalog names that could not be mapped, verbatim as the `.po` is called. */
67
+ catalogs) {
68
+ super(message);
69
+ this.catalogs = catalogs;
70
+ this.name = 'UnmappableCatalogNameError';
71
+ }
72
+ }
73
+ /** Two catalogs claim one output name; one would overwrite the other. */
74
+ export class CollidingCatalogNameError extends GettextGuardError {
75
+ claimed;
76
+ catalogs;
77
+ constructor(message,
78
+ /** The name claimed twice, and the catalogs claiming it. */
79
+ claimed, catalogs) {
80
+ super(message);
81
+ this.claimed = claimed;
82
+ this.catalogs = catalogs;
83
+ this.name = 'CollidingCatalogNameError';
84
+ }
85
+ }
86
+ /**
87
+ * Script subtag → the names that serve it.
88
+ *
89
+ * The POSIX column was MEASURED on glibc 2.42 / gettext 0.26 by installing a
90
+ * catalog under the target alone and asking the `gettext` CLI which selectors
91
+ * reached it. Two rules came out of that and explain the shapes below:
92
+ *
93
+ * - a bare `<lang>` directory is reached by `<lang>_<TERRITORY>` users
94
+ * (`sr` answered `sr_RS`), so a script that is simply the language's normal
95
+ * one needs no territory at all;
96
+ * - `<lang>@<modifier>` is reached by `<lang>_<TERRITORY>@<modifier>` users,
97
+ * while `<lang>_<TERRITORY>@<modifier>` is reached ONLY by itself, so the
98
+ * modifier forms are deliberately written WITHOUT a territory. This is the
99
+ * one place where the narrower spelling is the tempting one and is wrong.
100
+ *
101
+ * Chinese needs its territories spelled out: `zh_CN` is NOT reached by a
102
+ * `zh_SG` user (measured), and the broad `zh` IS reached by `zh_TW` — so a
103
+ * simplified catalog installed as `zh` would serve traditional users simplified
104
+ * text. The territories are explicit precisely to avoid that.
105
+ *
106
+ * A POSIX target absent from the building host is still written: the catalog is
107
+ * compiled once and installed on machines whose locale set this build cannot
108
+ * see (`zh_MO` is absent on Fedora 44 and present elsewhere). A directory
109
+ * nobody probes costs a few KB; a missing one costs the language.
110
+ *
111
+ * The table is deliberately NOT exhaustive. An unknown script is REPORTED
112
+ * rather than guessed — a guessed territory is the same silent-wrong-name
113
+ * failure this module exists to end.
114
+ */
115
+ const SCRIPT_NAMES = {
116
+ // Chinese: the script IS the axis users select on, and both halves ship.
117
+ // `zh` for simplified is the name Learn6502 already tracks and yields
118
+ // `values-zh`; traditional takes the territory form, `values-zh-rTW`.
119
+ zh_Hans: { posix: ['zh_CN', 'zh_SG'], bcp47: 'zh' },
120
+ zh_Hant: { posix: ['zh_TW', 'zh_HK', 'zh_MO'], bcp47: 'zh-TW' },
121
+ // Scripts glibc expresses as an @modifier. None has a safe Android
122
+ // qualifier: the distinction they carry is exactly the one the hook drops.
123
+ sr_Latn: { posix: ['sr@latin'] },
124
+ uz_Cyrl: { posix: ['uz@cyrillic'] },
125
+ be_Latn: { posix: ['be@latin'] },
126
+ // Scripts that are the language's ordinary one: the bare language covers it
127
+ // in both namespaces, because there is no distinction left to express.
128
+ sr_Cyrl: { posix: ['sr'], bcp47: 'sr' },
129
+ uz_Latn: { posix: ['uz'], bcp47: 'uz' },
130
+ az_Latn: { posix: ['az'], bcp47: 'az' },
131
+ bs_Latn: { posix: ['bs'], bcp47: 'bs' },
132
+ mn_Cyrl: { posix: ['mn'], bcp47: 'mn' },
133
+ tg_Cyrl: { posix: ['tg'], bcp47: 'tg' },
134
+ kk_Cyrl: { posix: ['kk'], bcp47: 'kk' },
135
+ ky_Cyrl: { posix: ['ky'], bcp47: 'ky' },
136
+ // Scripts glibc separates by territory, which Android can qualify too.
137
+ pa_Guru: { posix: ['pa_IN'], bcp47: 'pa-IN' },
138
+ pa_Arab: { posix: ['pa_PK'], bcp47: 'pa-PK' },
139
+ };
140
+ /**
141
+ * `language`, optional `Script`, optional `TERRITORY` of a catalog name.
142
+ *
143
+ * Matched after hyphens are folded to underscores, so one pattern reads both the
144
+ * BCP-47 spelling Weblate writes (`pt-BR`, `zh-Hans`) and the POSIX one. A name
145
+ * already carrying an `@modifier` is handled before this: it is POSIX by
146
+ * construction and needs no mapping.
147
+ */
148
+ const CATALOG_NAME = /^([a-z]{2,3})(?:_([A-Z][a-z]{3}))?(?:_([A-Z]{2}|\d{3}))?$/;
149
+ /** The script mapping for a catalog name, if it carries a script subtag at all. */
150
+ function scriptMappingFor(normalized) {
151
+ const parsed = CATALOG_NAME.exec(normalized);
152
+ if (!parsed?.[2] || parsed[3]) {
153
+ return undefined;
154
+ }
155
+ return SCRIPT_NAMES[`${parsed[1]}_${parsed[2]}`];
156
+ }
157
+ /**
158
+ * How one catalog is spelled everywhere, or `undefined` when its script subtag
159
+ * has no name in the namespace asked for.
160
+ *
161
+ * The ORIGINAL name is always kept among the POSIX directories. It is additive —
162
+ * no deployment that already found a catalog can stop finding it — and it keeps
163
+ * the compiled tree greppable against the catalogs it came from. It is never the
164
+ * only entry, because on its own it is precisely what nothing probes. The JSON
165
+ * side gets no such alias: a filename is one name, and a second copy under the
166
+ * dead spelling is what left two generations in the tree to begin with.
167
+ */
168
+ export function catalogNames(catalog, overrides) {
169
+ // A configured override answers for the name as written AND as normalised,
170
+ // so a project need not know which spelling this module reasons about.
171
+ const normalized = catalog.replace(/-/g, '_');
172
+ const override = overrides?.[catalog] ?? overrides?.[normalized];
173
+ if (override && override.length > 0) {
174
+ return {
175
+ catalog,
176
+ posix: dedupe([...override, catalog]),
177
+ bcp47: override[0].split('@')[0].replace(/_/g, '-'),
178
+ };
179
+ }
180
+ // A name carrying an @modifier is POSIX by construction — the modifier is
181
+ // the thing BCP-47 has no spelling for, so nothing but a hand-written POSIX
182
+ // name can have produced it. Android cannot express it either; the bare
183
+ // language is the honest remainder.
184
+ if (normalized.includes('@')) {
185
+ return { catalog, posix: [normalized], bcp47: normalized.split('@')[0].replace(/_/g, '-') };
186
+ }
187
+ const parsed = CATALOG_NAME.exec(normalized);
188
+ if (!parsed) {
189
+ // Not a shape this module recognises. Passed through rather than
190
+ // reported: gettext's own fallback still reaches a plain `<lang>`
191
+ // prefix, and reporting every unusual name would make the guard noise
192
+ // instead of signal. Only a SCRIPT subtag is a measured silent failure.
193
+ return { catalog, posix: [normalized], bcp47: normalized.replace(/_/g, '-') };
194
+ }
195
+ const [, language, script, territory] = parsed;
196
+ if (!script) {
197
+ // `de`, `pt_BR`, `zh_CN` — already exactly what glibc probes. The only
198
+ // work a hyphenated `pt-BR` needed was the underscore; Android needs the
199
+ // hyphen back, which is why the two columns differ here for free.
200
+ return { catalog, posix: dedupe([normalized, catalog]), bcp47: normalized.replace(/_/g, '-') };
201
+ }
202
+ // An explicit territory beside the script settles it without the table:
203
+ // `zh_Hans_CN` is `zh_CN`, whatever the script says.
204
+ if (territory) {
205
+ return {
206
+ catalog,
207
+ posix: dedupe([`${language}_${territory}`, catalog]),
208
+ bcp47: `${language}-${territory}`,
209
+ };
210
+ }
211
+ const mapped = SCRIPT_NAMES[`${language}_${script}`];
212
+ if (!mapped?.bcp47) {
213
+ return undefined;
214
+ }
215
+ return { catalog, posix: dedupe([...mapped.posix, catalog]), bcp47: mapped.bcp47 };
216
+ }
217
+ /**
218
+ * The POSIX directories alone.
219
+ *
220
+ * A script with a POSIX mapping but no safe Android qualifier (`sr_Latn`) is
221
+ * answerable here and not by {@link catalogNames}: the `.mo` side knows exactly
222
+ * where it goes. Splitting the lookup this way is what lets the gettext plugin
223
+ * stay correct for catalogs the JSON plugin has to refuse.
224
+ */
225
+ export function posixLocaleDirectories(catalog, overrides) {
226
+ const both = catalogNames(catalog, overrides);
227
+ if (both) {
228
+ return both.posix;
229
+ }
230
+ const mapped = scriptMappingFor(catalog.replace(/-/g, '_'));
231
+ return mapped ? dedupe([...mapped.posix, catalog]) : undefined;
232
+ }
233
+ /**
234
+ * Plans every catalog for ONE namespace, and fails if any cannot be spelled.
235
+ *
236
+ * FAILING IS THE DEFAULT ON PURPOSE, on the same asymmetry the source-pattern
237
+ * guard is built on: a false failure costs one build and one line of config, a
238
+ * missed one ships a whole language that silently renders in English — which is
239
+ * what `zh_Hans` did, undetected, for as long as the catalog existed. A build
240
+ * that cannot say where a catalog goes must not quietly put it somewhere,
241
+ * because "somewhere" is indistinguishable from "correct" until a user of that
242
+ * language complains.
243
+ *
244
+ * Every unmappable name is named in ONE error rather than the first aborting, so
245
+ * a project adopting several Weblate languages fixes them in a single pass
246
+ * instead of a build-fix-build loop.
247
+ */
248
+ export function planCatalogNames(catalogs, context) {
249
+ const plans = [];
250
+ const unmappable = [];
251
+ for (const catalog of catalogs) {
252
+ if (context.namespace === 'posix') {
253
+ const posix = posixLocaleDirectories(catalog, context.localeNames);
254
+ if (!posix) {
255
+ unmappable.push(catalog);
256
+ continue;
257
+ }
258
+ plans.push({ catalog, posix, bcp47: '' });
259
+ continue;
260
+ }
261
+ const names = catalogNames(catalog, context.localeNames);
262
+ if (!names) {
263
+ unmappable.push(catalog);
264
+ continue;
265
+ }
266
+ plans.push(names);
267
+ }
268
+ if (unmappable.length > 0) {
269
+ throw new UnmappableCatalogNameError(unmappableMessage(unmappable, context), unmappable);
270
+ }
271
+ assertNoCollision(plans, context);
272
+ return plans;
273
+ }
274
+ function unmappableMessage(unmappable, context) {
275
+ const list = unmappable.map((name) => ` - ${name}.po`).join('\n');
276
+ const subject = unmappable.length === 1 ? 'a catalog carries' : `${unmappable.length} catalogs carry`;
277
+ if (context.namespace === 'posix') {
278
+ return (`[${context.pluginName}] ${subject} a script subtag with no known POSIX locale:\n` +
279
+ `${list}\n` +
280
+ 'Compiling it under that name produces a directory glibc never probes, so the language ships ' +
281
+ 'complete and renders in English — the build would exit 0 and nothing would look wrong.\n' +
282
+ "Map it explicitly in the plugin's `localeNames`, e.g. `{ 'az_Arab': ['az_IR'] }`, using the " +
283
+ 'name `locale -a` lists on the target system.');
284
+ }
285
+ return (`[${context.pluginName}] ${subject} a script subtag that has no Android resource qualifier:\n` +
286
+ `${list}\n` +
287
+ 'A qualifier is built as `values-<lang>[-r<REGION>]` and cannot express a script, so the generated ' +
288
+ 'directory would be one Android never consults — and the build would exit 0.\n' +
289
+ "Map it explicitly in the plugin's `localeNames`, e.g. `{ 'sr_Latn': ['sr'] }`, choosing the " +
290
+ 'language or region tag the app should serve it as.');
291
+ }
292
+ /**
293
+ * Fails when two catalogs claim the same output name.
294
+ *
295
+ * Reachable as soon as a project keeps both spellings of one language — a
296
+ * `zh_Hans.po` from Weblate beside a hand-made `zh_CN.po` both resolve to
297
+ * `zh_CN`. Without this, whichever is written last wins by iteration order, the
298
+ * other's translations are simply absent, and the build still exits 0. That is
299
+ * the same shape as the bug this module fixes, so it is guarded here rather than
300
+ * left to be discovered the same way.
301
+ */
302
+ function assertNoCollision(plans, context) {
303
+ const claimedBy = new Map();
304
+ for (const plan of plans) {
305
+ const claims = context.namespace === 'posix' ? plan.posix : [plan.bcp47];
306
+ for (const claim of claims) {
307
+ const claimants = claimedBy.get(claim);
308
+ if (claimants) {
309
+ claimants.push(plan.catalog);
310
+ }
311
+ else {
312
+ claimedBy.set(claim, [plan.catalog]);
313
+ }
314
+ }
315
+ }
316
+ for (const [claimed, catalogs] of claimedBy) {
317
+ if (catalogs.length > 1) {
318
+ const where = context.namespace === 'posix' ? `locale/${claimed}/` : `${claimed}.json`;
319
+ throw new CollidingCatalogNameError(`[${context.pluginName}] ${catalogs.map((name) => `${name}.po`).join(' and ')} both compile to ` +
320
+ `${where}. One would overwrite the other and the build would still exit 0.\n` +
321
+ "Keep one catalog per language, or pin them apart with the plugin's `localeNames`.", claimed, catalogs);
322
+ }
323
+ }
324
+ }
325
+ /** First occurrence wins, so the mapped name stays ahead of the original. */
326
+ function dedupe(values) {
327
+ return [...new Set(values)];
328
+ }
package/lib/gettext.d.ts CHANGED
@@ -4,6 +4,10 @@ import type { GettextPluginOptions } from './types.js';
4
4
  * Creates a Vite plugin that compiles PO translation files to binary MO format
5
5
  * The MO files are placed in the standard gettext directory structure:
6
6
  * {moDirectory}/locale/{lang}/LC_MESSAGES/messages.mo
7
+ *
8
+ * The directory is the catalog's POSIX locale name, which is NOT always the
9
+ * `.po` basename — a Weblate `zh_Hans.po` is installed as `zh_CN`, because
10
+ * `zh_Hans` is a name glibc never probes. See `catalog-names.ts`.
7
11
  * @param options Configuration options for the plugin
8
12
  * @returns A Vite plugin that handles PO compilation
9
13
  */
package/lib/gettext.js CHANGED
@@ -1,15 +1,21 @@
1
1
  import { execa } from 'execa';
2
2
  import path from 'node:path';
3
+ import { GettextGuardError } from './guards.js';
4
+ import { planCatalogNames } from './catalog-names.js';
3
5
  import { checkDependencies, findAvailableLanguages, generateLinguasFile, ensureDirectory } from './utils.js';
4
6
  /**
5
7
  * Creates a Vite plugin that compiles PO translation files to binary MO format
6
8
  * The MO files are placed in the standard gettext directory structure:
7
9
  * {moDirectory}/locale/{lang}/LC_MESSAGES/messages.mo
10
+ *
11
+ * The directory is the catalog's POSIX locale name, which is NOT always the
12
+ * `.po` basename — a Weblate `zh_Hans.po` is installed as `zh_CN`, because
13
+ * `zh_Hans` is a name glibc never probes. See `catalog-names.ts`.
8
14
  * @param options Configuration options for the plugin
9
15
  * @returns A Vite plugin that handles PO compilation
10
16
  */
11
17
  export function gettextPlugin(options) {
12
- const { poDirectory, moDirectory, filename = 'messages.mo', verbose = false } = options;
18
+ const { poDirectory, moDirectory, filename = 'messages.mo', verbose = false, localeNames } = options;
13
19
  const pluginName = 'vite-plugin-gettext';
14
20
  async function compileMoFiles() {
15
21
  try {
@@ -35,20 +41,31 @@ export function gettextPlugin(options) {
35
41
  }
36
42
  // Generate LINGUAS file
37
43
  await generateLinguasFile(languages, poDirectory, verbose);
44
+ // Resolve every catalog's locale directories BEFORE compiling any of
45
+ // them, so a name nobody can place stops the build instead of
46
+ // leaving a half-written tree that looks complete.
47
+ const plans = planCatalogNames(languages, { pluginName, namespace: 'posix', localeNames });
38
48
  // Create MO directory
39
49
  await ensureDirectory(path.join(moDirectory, 'locale'));
40
- for (const lang of languages) {
41
- const poFile = path.join(poDirectory, `${lang}.po`);
42
- const moPath = path.join(moDirectory, 'locale', lang, 'LC_MESSAGES');
43
- const moFile = path.join(moPath, filename);
44
- await ensureDirectory(moPath);
45
- if (verbose) {
46
- console.log(`[${pluginName}] Compiling ${poFile} to ${moFile}`);
50
+ for (const plan of plans) {
51
+ const poFile = path.join(poDirectory, `${plan.catalog}.po`);
52
+ for (const localeDir of plan.posix) {
53
+ const moPath = path.join(moDirectory, 'locale', localeDir, 'LC_MESSAGES');
54
+ const moFile = path.join(moPath, filename);
55
+ await ensureDirectory(moPath);
56
+ if (verbose) {
57
+ console.log(`[${pluginName}] Compiling ${poFile} to ${moFile}`);
58
+ }
59
+ await execa('msgfmt', ['--output-file=' + moFile, poFile]);
47
60
  }
48
- await execa('msgfmt', ['--output-file=' + moFile, poFile]);
49
61
  }
50
62
  }
51
63
  catch (error) {
64
+ // A guard's message IS the guard — wrapping it in "Failed to compile
65
+ // MO files: Error: …" buries the instruction that makes it useful.
66
+ if (error instanceof GettextGuardError) {
67
+ throw error;
68
+ }
52
69
  throw new Error(`Failed to compile MO files: ${error}`);
53
70
  }
54
71
  }
package/lib/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export { CatalogShrinkError, EmptySourcePatternError, GettextGuardError, InvalidEntryLossError } from './guards.js';
2
+ export { catalogNames, CollidingCatalogNameError, planCatalogNames, posixLocaleDirectories, UnmappableCatalogNameError, } from './catalog-names.js';
3
+ export type { CatalogNames, CatalogNamespace } from './catalog-names.js';
2
4
  export { gettextPlugin } from './gettext.js';
3
5
  export { msgfmtPlugin } from './msgfmt.js';
4
6
  export { xgettextPlugin } from './xgettext.js';
5
7
  export { po2jsonPlugin } from './po2json.js';
6
- export type { GettextPluginOptions, MsgfmtPluginOptions, MsgfmtFormat, XGettextPluginOptions } from './types.js';
8
+ export type { GettextPluginOptions, GettextPo2JsonPluginOptions, MsgfmtPluginOptions, MsgfmtFormat, XGettextPluginOptions, } from './types.js';
7
9
  export * from './utils.js';
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { CatalogShrinkError, EmptySourcePatternError, GettextGuardError, InvalidEntryLossError } from './guards.js';
2
+ export { catalogNames, CollidingCatalogNameError, planCatalogNames, posixLocaleDirectories, UnmappableCatalogNameError, } from './catalog-names.js';
2
3
  export { gettextPlugin } from './gettext.js';
3
4
  export { msgfmtPlugin } from './msgfmt.js';
4
5
  export { xgettextPlugin } from './xgettext.js';
package/lib/msgfmt.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { execa } from 'execa';
2
2
  import path from 'node:path';
3
3
  import fs from 'node:fs/promises';
4
+ import { GettextGuardError } from './guards.js';
5
+ import { planCatalogNames } from './catalog-names.js';
4
6
  import { checkDependencies, findAvailableLanguages, ensureDirectory } from './utils.js';
5
7
  /**
6
8
  * Remove XML comments from a file content
@@ -61,7 +63,7 @@ function getOutputExtension(format) {
61
63
  * @returns A Vite plugin that handles PO compilation
62
64
  */
63
65
  export function msgfmtPlugin(options) {
64
- const { poDirectory, outputDirectory, domain = 'messages', format = 'mo', templateFile, verbose = false, msgfmtOptions = [], useLocaleStructure = true, removeComments = true, } = options;
66
+ const { poDirectory, outputDirectory, domain = 'messages', format = 'mo', templateFile, verbose = false, msgfmtOptions = [], useLocaleStructure = true, removeComments = true, localeNames, } = options;
65
67
  const pluginName = 'vite-plugin-msgfmt';
66
68
  async function compilePoFiles() {
67
69
  try {
@@ -125,37 +127,44 @@ export function msgfmtPlugin(options) {
125
127
  }
126
128
  return;
127
129
  }
130
+ // Only the gettext locale structure is looked up by glibc, so
131
+ // only it needs POSIX names. The flat `<outputDirectory>/<lang>`
132
+ // layout is read by whatever the project points at it, and
133
+ // renaming there would break a consumer to fix nobody.
134
+ const usesLocaleLookup = useLocaleStructure && format === 'mo';
135
+ const plans = usesLocaleLookup
136
+ ? planCatalogNames(languages, { pluginName, namespace: 'posix', localeNames })
137
+ : languages.map((catalog) => ({ catalog, posix: [catalog], bcp47: '' }));
128
138
  // Process each language individually for other formats
129
- for (const lang of languages) {
130
- const poFile = path.join(poDirectory, `${lang}.po`);
131
- let outputPath;
132
- let outputFile;
133
- if (useLocaleStructure && format === 'mo') {
134
- // Use standard gettext locale structure
135
- outputPath = path.join(outputDirectory, 'locale', lang, 'LC_MESSAGES');
136
- outputFile = path.join(outputPath, options.filename || `${domain}${getOutputExtension(format)}`);
137
- }
138
- else {
139
- // Use simple language-based structure
140
- outputPath = path.join(outputDirectory, lang);
141
- outputFile = path.join(outputPath, options.filename || `${domain}${getOutputExtension(format)}`);
142
- }
143
- // Create the directory structure
144
- await ensureDirectory(outputPath);
145
- if (verbose) {
146
- console.log(`[${pluginName}] Compiling ${poFile} to ${outputFile}`);
147
- }
148
- // Build arguments for individual processing
149
- const baseArgs = ['--output-file=' + outputFile, `--${format}`, poFile];
150
- const args = buildMsgfmtArgs(baseArgs, { msgfmtOptions });
151
- if (verbose) {
152
- console.log(`[${pluginName}] Running msgfmt with: ${args.join(' ')}`);
139
+ for (const plan of plans) {
140
+ const poFile = path.join(poDirectory, `${plan.catalog}.po`);
141
+ for (const name of plan.posix) {
142
+ const outputPath = usesLocaleLookup
143
+ ? path.join(outputDirectory, 'locale', name, 'LC_MESSAGES')
144
+ : path.join(outputDirectory, name);
145
+ const outputFile = path.join(outputPath, options.filename || `${domain}${getOutputExtension(format)}`);
146
+ // Create the directory structure
147
+ await ensureDirectory(outputPath);
148
+ if (verbose) {
149
+ console.log(`[${pluginName}] Compiling ${poFile} to ${outputFile}`);
150
+ }
151
+ // Build arguments for individual processing
152
+ const baseArgs = ['--output-file=' + outputFile, `--${format}`, poFile];
153
+ const args = buildMsgfmtArgs(baseArgs, { msgfmtOptions });
154
+ if (verbose) {
155
+ console.log(`[${pluginName}] Running msgfmt with: ${args.join(' ')}`);
156
+ }
157
+ await execa('msgfmt', args);
153
158
  }
154
- await execa('msgfmt', args);
155
159
  }
156
160
  }
157
161
  }
158
162
  catch (error) {
163
+ // A guard's message IS the guard — wrapping it buries the
164
+ // instruction that makes it useful.
165
+ if (error instanceof GettextGuardError) {
166
+ throw error;
167
+ }
159
168
  throw new Error(`Failed to compile files: ${error}`);
160
169
  }
161
170
  }
package/lib/po2json.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs/promises';
3
3
  import * as gettextParser from 'gettext-parser';
4
+ import { GettextGuardError } from './guards.js';
5
+ import { planCatalogNames } from './catalog-names.js';
4
6
  import { findAvailableLanguages, ensureDirectory } from './utils.js';
5
7
  /**
6
8
  * Simplifies the gettext-parser output to a clean key-value object
@@ -82,7 +84,7 @@ async function createDefaultLanguageJson(jsonDirectory, allTranslations, default
82
84
  * @returns A Vite plugin that handles PO to JSON conversion
83
85
  */
84
86
  export function po2jsonPlugin(options) {
85
- const { poDirectory, jsonDirectory, defaultLanguage = 'en', verbose = false, additionalTranslations = {}, } = options;
87
+ const { poDirectory, jsonDirectory, defaultLanguage = 'en', verbose = false, additionalTranslations = {}, localeNames, } = options;
86
88
  const pluginName = 'vite-plugin-gettext-po2json';
87
89
  async function convertPoToJson() {
88
90
  try {
@@ -110,8 +112,12 @@ export function po2jsonPlugin(options) {
110
112
  await ensureDirectory(jsonDirectory);
111
113
  // Collection of all translations to create the default language file
112
114
  const allTranslations = {};
115
+ // The JSON filename is the ANDROID namespace, not the catalog name:
116
+ // `@nativescript/localize` turns it straight into a resource
117
+ // qualifier, where an underscore is illegal. See `catalog-names.ts`.
118
+ const plans = planCatalogNames(languages, { pluginName, namespace: 'bcp47', localeNames });
113
119
  // Skip the default language if it exists in the list
114
- const nonDefaultLanguages = languages.filter((lang) => lang !== defaultLanguage);
120
+ const nonDefaultPlans = plans.filter((plan) => plan.catalog !== defaultLanguage);
115
121
  // Handle default language if it exists in the list
116
122
  if (languages.includes(defaultLanguage)) {
117
123
  const poFile = path.join(poDirectory, `${defaultLanguage}.po`);
@@ -134,9 +140,10 @@ export function po2jsonPlugin(options) {
134
140
  await fs.writeFile(jsonFile, JSON.stringify(finalTranslations, null, 2));
135
141
  }
136
142
  // Add additional translations for all languages
137
- for (const lang of nonDefaultLanguages) {
143
+ for (const plan of nonDefaultPlans) {
144
+ const lang = plan.catalog;
138
145
  const poFile = path.join(poDirectory, `${lang}.po`);
139
- const jsonFile = path.join(jsonDirectory, `${lang}.json`);
146
+ const jsonFile = path.join(jsonDirectory, `${plan.bcp47}.json`);
140
147
  if (verbose) {
141
148
  console.log(`[${pluginName}] Converting ${poFile} to ${jsonFile}`);
142
149
  }
@@ -167,6 +174,11 @@ export function po2jsonPlugin(options) {
167
174
  await createDefaultLanguageJson(jsonDirectory, allTranslations, defaultLanguage, verbose, pluginName, additionalTranslations);
168
175
  }
169
176
  catch (error) {
177
+ // A guard's message IS the guard — wrapping it buries the
178
+ // instruction that makes it useful.
179
+ if (error instanceof GettextGuardError) {
180
+ throw error;
181
+ }
170
182
  throw new Error(`Failed to convert PO files to JSON: ${error}`);
171
183
  }
172
184
  }
package/lib/types.d.ts CHANGED
@@ -76,6 +76,14 @@ export interface GettextPluginOptions {
76
76
  filename?: string;
77
77
  /** Enable verbose logging */
78
78
  verbose?: boolean;
79
+ /**
80
+ * Output names for catalogs whose name carries a script subtag the built-in
81
+ * table does not know, e.g. `{ az_Arab: ['az_IR'] }`.
82
+ *
83
+ * Per catalog rather than one blanket switch, so mapping one language does
84
+ * not disarm the check for the rest.
85
+ */
86
+ localeNames?: Record<string, readonly string[]>;
79
87
  }
80
88
  /**
81
89
  * Output format types for msgfmt
@@ -106,6 +114,12 @@ export interface MsgfmtPluginOptions {
106
114
  useLocaleStructure?: boolean;
107
115
  /** Whether to remove XML comments from output files, defaults to true */
108
116
  removeComments?: boolean;
117
+ /**
118
+ * Locale directories for catalogs whose name carries a script subtag the
119
+ * built-in table does not know. Applies only to the gettext locale
120
+ * structure, which is the layout glibc looks up.
121
+ */
122
+ localeNames?: Record<string, readonly string[]>;
109
123
  }
110
124
  export interface PluginOptions {
111
125
  pluginName: string;
@@ -137,4 +151,12 @@ export interface GettextPo2JsonPluginOptions {
137
151
  * The English text will be translated for non-default languages if translations exist
138
152
  */
139
153
  additionalTranslations?: Record<string, string>;
154
+ /**
155
+ * JSON basenames for catalogs whose name carries a script subtag with no
156
+ * Android resource qualifier, e.g. `{ sr_Latn: ['sr'] }`.
157
+ *
158
+ * The first entry is used, with `_` folded to `-`: a qualifier is
159
+ * `values-<lang>[-r<REGION>]` and cannot carry a script.
160
+ */
161
+ localeNames?: Record<string, readonly string[]>;
140
162
  }
package/lib/utils.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * @param command The command to check (msgfmt, xgettext, etc.)
4
4
  * @param pluginName Name of the plugin for logging
5
5
  * @param verbose Enable verbose logging
6
- * @throws Error if the command is not found
6
+ * @throws Error if the command is not found, or is there and refused to run
7
7
  */
8
8
  export declare function checkDependencies(command: string, pluginName: string, verbose: boolean): Promise<void>;
9
9
  /**
package/lib/utils.js CHANGED
@@ -6,7 +6,7 @@ import fs from 'node:fs/promises';
6
6
  * @param command The command to check (msgfmt, xgettext, etc.)
7
7
  * @param pluginName Name of the plugin for logging
8
8
  * @param verbose Enable verbose logging
9
- * @throws Error if the command is not found
9
+ * @throws Error if the command is not found, or is there and refused to run
10
10
  */
11
11
  export async function checkDependencies(command, pluginName, verbose) {
12
12
  try {
@@ -15,7 +15,21 @@ export async function checkDependencies(command, pluginName, verbose) {
15
15
  console.log(`[${pluginName}] Found ${command}`);
16
16
  }
17
17
  }
18
- catch (_error) {
18
+ catch (error) {
19
+ // ABSENT and BROKEN are different answers, and this used to give the
20
+ // first one to both — `catch (_error)`, install hint, every time. A
21
+ // gettext that is installed but cannot run (a missing libintl after a
22
+ // partial upgrade, a noexec mount, the wrong arch under emulation) then
23
+ // tells the reader to install what they have already got, and its own
24
+ // stderr — the part that says which library is missing — is dropped.
25
+ //
26
+ // The same distinction `@gjsify/vite-plugin-blueprint`'s
27
+ // `resolve-compiler.ts` was written for, after a "not found" for a
28
+ // compiler that WAS there cost a day.
29
+ if (error.code !== 'ENOENT') {
30
+ const detail = error.stderr || error.message;
31
+ throw new Error(`${command} is installed but failed to run:\n${detail}`);
32
+ }
19
33
  throw new Error(`${command} not found. Please install gettext:\n` +
20
34
  ' Ubuntu/Debian: sudo apt-get install gettext\n' +
21
35
  ' Fedora: sudo dnf install gettext\n' +
@@ -33,7 +47,23 @@ export async function checkDependencies(command, pluginName, verbose) {
33
47
  export async function findAvailableLanguages(poDirectory, pluginName, verbose) {
34
48
  try {
35
49
  const files = await fs.readdir(poDirectory);
36
- const languages = files.filter((file) => file.endsWith('.po')).map((file) => path.basename(file, '.po'));
50
+ const languages = files
51
+ .filter((file) => file.endsWith('.po'))
52
+ .map((file) => path.basename(file, '.po'))
53
+ // SORTED AT THE SOURCE, so every consumer is deterministic at once —
54
+ // the LINGUAS file, the compile order, and the locale-directory plan.
55
+ // `fs.readdir` returns filesystem order, which Node explicitly does
56
+ // not specify: on ext4 a small directory reads back in CREATION
57
+ // order, so a language that was removed and re-added moves to the end
58
+ // and LINGUAS is rewritten on a tree nobody touched
59
+ // (JumpLink/Learn6502#179).
60
+ //
61
+ // No comparator ON PURPOSE. The default is UTF-16 code-unit order —
62
+ // total, and identical on every host. `localeCompare` would order by
63
+ // the BUILDER's locale, which makes committed output depend on who
64
+ // built it: the same host-dependent-artifact bug in a new place.
65
+ // Locale names are ASCII, so code-unit order is also byte order.
66
+ .sort();
37
67
  if (verbose) {
38
68
  console.log(`[${pluginName}] Found languages: ${languages.join(', ')}`);
39
69
  }
@@ -54,7 +84,12 @@ export async function findAvailableLanguages(poDirectory, pluginName, verbose) {
54
84
  */
55
85
  export async function generateLinguasFile(languages, poDirectory, verbose = false) {
56
86
  const linguasPath = path.join(poDirectory, 'LINGUAS');
57
- const content = languages.join('\n');
87
+ // Trailing newline: a POSIX text file ends with one. Without it git reports
88
+ // "" on every regeneration, and any tool that
89
+ // appends to the list silently joins its first entry onto the last language.
90
+ // Sorted here as well as in `findAvailableLanguages` so a caller that
91
+ // assembles the list itself still gets a stable file.
92
+ const content = `${[...languages].sort().join('\n')}\n`;
58
93
  try {
59
94
  await fs.writeFile(linguasPath, content);
60
95
  if (verbose) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/vite-plugin-gettext",
3
- "version": "0.48.0",
3
+ "version": "0.50.0",
4
4
  "description": "Gettext PO/MO/JSON pipeline for Vite / Rollup / Rolldown — extract via xgettext, compile via msgfmt, JSON for browser targets.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -59,8 +59,8 @@
59
59
  }
60
60
  },
61
61
  "devDependencies": {
62
- "@gjsify/cli": "^0.48.0",
63
- "@gjsify/unit": "^0.48.0",
62
+ "@gjsify/cli": "^0.50.0",
63
+ "@gjsify/unit": "^0.50.0",
64
64
  "@types/gettext-parser": "^9.0.0",
65
65
  "@types/node": "^25.9.2",
66
66
  "typescript": "^6.0.3",