@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 +38 -0
- package/lib/catalog-names.d.ts +71 -0
- package/lib/catalog-names.js +328 -0
- package/lib/gettext.d.ts +4 -0
- package/lib/gettext.js +26 -9
- package/lib/index.d.ts +3 -1
- package/lib/index.js +1 -0
- package/lib/msgfmt.js +35 -26
- package/lib/po2json.js +16 -4
- package/lib/types.d.ts +22 -0
- package/lib/utils.d.ts +1 -1
- package/lib/utils.js +39 -4
- package/package.json +3 -3
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
|
|
41
|
-
const poFile = path.join(poDirectory, `${
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
130
|
-
const poFile = path.join(poDirectory, `${
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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
|
|
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, `${
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
63
|
-
"@gjsify/unit": "^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",
|