@gjsify/vite-plugin-gettext 0.47.0 → 0.48.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 +48 -0
- package/lib/guards.d.ts +121 -0
- package/lib/guards.js +234 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/types.d.ts +22 -1
- package/lib/xgettext.js +147 -46
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -16,6 +16,54 @@ yarn add @gjsify/vite-plugin-gettext
|
|
|
16
16
|
|
|
17
17
|
Requires `gettext` tools (`msgfmt`, `xgettext`) to be installed on the system.
|
|
18
18
|
|
|
19
|
+
## Extraction refuses to destroy translations
|
|
20
|
+
|
|
21
|
+
`xgettextPlugin` fails the build instead of writing a POT that would silently gut the catalogs:
|
|
22
|
+
|
|
23
|
+
- **Every `sources` pattern must match at least one file**, and the run as a whole must find at
|
|
24
|
+
least one. A pattern that matches nothing is almost always a build-order or path mistake —
|
|
25
|
+
typically it points at a build artifact of another package that has not been built yet — and
|
|
26
|
+
extracting anyway drops that whole group of strings. A group that really is optional is named in
|
|
27
|
+
`optionalSources`, one pattern at a time. Negated patterns (`!plugins/**/*.ui`) are exclusions
|
|
28
|
+
over the whole set, not groups of their own, so they are never required to match.
|
|
29
|
+
- **`autoUpdatePo` will not prune a catalog set beyond `maxCatalogEntryLoss`** (default `1/3`).
|
|
30
|
+
What is compared is the *set of msgids* the new POT still carries against the largest catalog's,
|
|
31
|
+
because that is what `msgmerge` acts on: it moves every entry the POT no longer has into `#~`
|
|
32
|
+
comments, which `msgfmt` ignores. A POT that came out short costs real translations, and so does
|
|
33
|
+
one that is the same length but re-worded — `msgmerge` fuzzy-matches those, and `msgfmt` leaves
|
|
34
|
+
fuzzy entries out of the `.mo`. A run that really does replace that many strings raises the
|
|
35
|
+
option.
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
xgettextPlugin({
|
|
39
|
+
sources: ['src/**/*.blp', 'data/**/*.desktop.in', '!src/**/*.generated.blp'],
|
|
40
|
+
output: 'po/messages.pot',
|
|
41
|
+
autoUpdatePo: true,
|
|
42
|
+
// optionalSources: ['plugins/**/*.ui'],
|
|
43
|
+
// maxCatalogEntryLoss: 0.5, // a FRACTION, not a percentage; 1 turns the check off
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Both guards throw. `xgettext`, `msgcat` and `msgmerge` failures are no longer reported to the
|
|
48
|
+
console beside a zero exit code either — a build that could not write the catalogs it was asked to
|
|
49
|
+
write fails.
|
|
50
|
+
|
|
51
|
+
### A `sources` pattern reaching into a sibling package is a build dependency
|
|
52
|
+
|
|
53
|
+
`sources` is read at extraction time, so a pattern like `../learn/dist/**/*.ui` requires that the
|
|
54
|
+
sibling package has already been built. Declaring it in `dependencies` is not enough on its own:
|
|
55
|
+
building the one package (`gjsify workspace <name> build`) does not build what it depends on.
|
|
56
|
+
Either run the extracting package with `-t`, which pre-builds its workspace dependencies in
|
|
57
|
+
topological order:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
gjsify workspace @scope/translations build -t
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
or make the dependency's build an explicit step of the script that needs it. Without one of the
|
|
64
|
+
two, the artifact is whatever the last run left behind, which is the state the pattern guard above
|
|
65
|
+
exists to catch.
|
|
66
|
+
|
|
19
67
|
## Usage
|
|
20
68
|
|
|
21
69
|
```typescript
|
package/lib/guards.d.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** Base class so the plugin can rethrow a guard verbatim instead of wrapping it. */
|
|
2
|
+
export declare class GettextGuardError extends Error {
|
|
3
|
+
}
|
|
4
|
+
/** A `sources` pattern matched nothing, and was not declared optional. */
|
|
5
|
+
export declare class EmptySourcePatternError extends GettextGuardError {
|
|
6
|
+
/** The patterns that matched no files, verbatim as configured. */
|
|
7
|
+
readonly patterns: readonly string[];
|
|
8
|
+
constructor(message: string,
|
|
9
|
+
/** The patterns that matched no files, verbatim as configured. */
|
|
10
|
+
patterns: readonly string[]);
|
|
11
|
+
}
|
|
12
|
+
/** `msgmerge` would obsolete more of the catalogs than this build allows. */
|
|
13
|
+
export declare class CatalogShrinkError extends GettextGuardError {
|
|
14
|
+
/** Entries the reference catalog holds that the new POT no longer carries. */
|
|
15
|
+
readonly lostEntries: number;
|
|
16
|
+
readonly catalogEntries: number;
|
|
17
|
+
constructor(message: string,
|
|
18
|
+
/** Entries the reference catalog holds that the new POT no longer carries. */
|
|
19
|
+
lostEntries: number, catalogEntries: number);
|
|
20
|
+
}
|
|
21
|
+
/** A `maxCatalogEntryLoss` that is not a fraction — the guard cannot act on it. */
|
|
22
|
+
export declare class InvalidEntryLossError extends GettextGuardError {
|
|
23
|
+
constructor(message: string);
|
|
24
|
+
}
|
|
25
|
+
/** One `sources` pattern and how many files it actually resolved to. */
|
|
26
|
+
export interface SourcePatternMatch {
|
|
27
|
+
/** The pattern verbatim as configured — it is what an error has to name. */
|
|
28
|
+
pattern: string;
|
|
29
|
+
fileCount: number;
|
|
30
|
+
}
|
|
31
|
+
/** A catalog's language and the msgids it currently still uses. */
|
|
32
|
+
export interface CatalogMsgids {
|
|
33
|
+
language: string;
|
|
34
|
+
msgids: ReadonlySet<string>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Fraction of its entries a catalog set may lose in one extraction.
|
|
38
|
+
*
|
|
39
|
+
* Why a RATIO: an absolute count cannot be right for both a twelve-string dialog
|
|
40
|
+
* and a nine-hundred-string tutorial. Why a THIRD: the two populations this has
|
|
41
|
+
* to separate are far apart. Ordinary churn between two runs of the same build is
|
|
42
|
+
* a handful of strings — single-digit percent of any real catalog — while the
|
|
43
|
+
* failure above drops a whole source group at once. A third sits in the empty
|
|
44
|
+
* space between them. Half, the other candidate, is worse: a project with two
|
|
45
|
+
* source groups of similar size loses one of them at ~50% and slips under.
|
|
46
|
+
*
|
|
47
|
+
* A deliberate rewrite that really does delete more says so with
|
|
48
|
+
* `maxCatalogEntryLoss`, which is the point of it being an option.
|
|
49
|
+
*/
|
|
50
|
+
export declare const DEFAULT_MAX_CATALOG_ENTRY_LOSS: number;
|
|
51
|
+
/**
|
|
52
|
+
* Fails unless every source pattern resolved to at least one file.
|
|
53
|
+
*
|
|
54
|
+
* FAILING IS THE DEFAULT ON PURPOSE. A pattern that matches nothing is almost
|
|
55
|
+
* always a build-order or path mistake, and the cost of being wrong is
|
|
56
|
+
* asymmetric: a false failure costs one build, a missed one costs every
|
|
57
|
+
* translation the group carried. A project that genuinely has an optional group
|
|
58
|
+
* names those patterns in `optionalSources` — per pattern, never a blanket
|
|
59
|
+
* boolean, so the guard stays armed for every other entry.
|
|
60
|
+
*/
|
|
61
|
+
export declare function assertEverySourcePatternMatched(matches: readonly SourcePatternMatch[], context: {
|
|
62
|
+
pluginName: string;
|
|
63
|
+
cwd: string;
|
|
64
|
+
optionalSources?: readonly string[];
|
|
65
|
+
ignore?: readonly string[];
|
|
66
|
+
}): void;
|
|
67
|
+
/**
|
|
68
|
+
* Reads `maxCatalogEntryLoss`, which has to be a FRACTION.
|
|
69
|
+
*
|
|
70
|
+
* Validated separately, and before the catalogs are looked at, because the two
|
|
71
|
+
* ways of getting it wrong both end in silence. `50` — the option read as a
|
|
72
|
+
* percentage — is `<= 50` for every possible ratio, so the guard passes
|
|
73
|
+
* everything and reports nothing; `NaN` fails every comparison, so the guard
|
|
74
|
+
* fires on a build that lost nothing and gets raised out of the way. Neither
|
|
75
|
+
* would ever be traced back to the option.
|
|
76
|
+
*/
|
|
77
|
+
export declare function resolveMaxEntryLoss(value: number | undefined, pluginName: string): number;
|
|
78
|
+
/**
|
|
79
|
+
* Fails when merging the freshly written POT would obsolete more of the catalogs
|
|
80
|
+
* than `maxEntryLoss` allows.
|
|
81
|
+
*
|
|
82
|
+
* What is measured is the SET of msgids the POT no longer carries, not how much
|
|
83
|
+
* shorter it got. `msgmerge` matches by msgid, so an equally long POT whose
|
|
84
|
+
* strings have changed obsoletes just as much as a short one — and measured on
|
|
85
|
+
* gettext 0.26, a msgid that only gained a space is fuzzy-matched, keeps its
|
|
86
|
+
* translation, and is then excluded from the `.mo` by `msgfmt` anyway. A count
|
|
87
|
+
* cannot see that; a set difference is the quantity `msgmerge` acts on.
|
|
88
|
+
*
|
|
89
|
+
* The reference is the LARGEST catalog rather than each one in turn: `msgmerge`
|
|
90
|
+
* gives every catalog the POT's msgid set (measured — a 1-entry and a 2-entry
|
|
91
|
+
* catalog both came back with the POT's 3), so the biggest one is the best
|
|
92
|
+
* evidence of what the project had before this run. Comparing per language would
|
|
93
|
+
* let one catalog that a previous bad run already gutted excuse gutting the rest.
|
|
94
|
+
*/
|
|
95
|
+
export declare function assertCatalogsSurviveMerge(args: {
|
|
96
|
+
potMsgids: ReadonlySet<string>;
|
|
97
|
+
catalogs: readonly CatalogMsgids[];
|
|
98
|
+
potFile: string;
|
|
99
|
+
pluginName: string;
|
|
100
|
+
maxEntryLoss?: number;
|
|
101
|
+
}): void;
|
|
102
|
+
/**
|
|
103
|
+
* The msgids a PO/POT file still USES, keyed the way gettext keys them.
|
|
104
|
+
*
|
|
105
|
+
* Obsolete entries (`#~ msgid`) are excluded because `msgfmt` excludes them: a
|
|
106
|
+
* gutted catalog keeps every line and loses every translation, so a line count
|
|
107
|
+
* would report it as healthy. The header is excluded by being the one entry whose
|
|
108
|
+
* msgid is empty and uncontextualised.
|
|
109
|
+
*
|
|
110
|
+
* Continuation lines are CONCATENATED rather than counted, which is what makes a
|
|
111
|
+
* POT comparable with a catalog at all: the same string is wrapped differently
|
|
112
|
+
* depending on `--no-wrap` and on how long the surrounding lines were, and only
|
|
113
|
+
* the joined value is stable. `msgctxt` joins its msgid through gettext's own
|
|
114
|
+
* `\u0004` separator, so two entries that differ only in context stay two.
|
|
115
|
+
*/
|
|
116
|
+
export declare function activeMsgids(text: string): Set<string>;
|
|
117
|
+
/**
|
|
118
|
+
* Entries a PO/POT file still USES — the size of {@link activeMsgids}, and the
|
|
119
|
+
* same measurement, so the number and the set can never disagree.
|
|
120
|
+
*/
|
|
121
|
+
export declare function countActiveEntries(text: string): number;
|
package/lib/guards.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// What must be true before a POT is written over catalogs people have already
|
|
2
|
+
// translated.
|
|
3
|
+
//
|
|
4
|
+
// THE INCIDENT (JumpLink/Learn6502, 2026-09-03). Its `xgettext` sources included
|
|
5
|
+
// `../learn/dist/**/*.ui`, a build artifact of a SIBLING workspace package.
|
|
6
|
+
// Build `@learn6502/translations` before `@learn6502/learn` and that pattern
|
|
7
|
+
// matches nothing — the whole `ui` group silently ceases to exist. `xgettext`
|
|
8
|
+
// happily extracted the rest, `msgcat` wrote a POT 902 lines shorter, and
|
|
9
|
+
// `autoUpdatePo` then ran `msgmerge --update` over all 16 catalogs, which moved
|
|
10
|
+
// every entry the POT had lost into `#~` obsolete comments: ~1573 lines per
|
|
11
|
+
// language, every MDX-derived tutorial string among them. `msgfmt` ignores `#~`,
|
|
12
|
+
// so those translations were gone. Exit code 0, no warning. It was caught by
|
|
13
|
+
// reading the diff before committing; in CI, or on a machine with a stale
|
|
14
|
+
// `dist/`, it would have landed.
|
|
15
|
+
//
|
|
16
|
+
// The build-order dependency is real and implicit — extraction reads an artifact
|
|
17
|
+
// another package produces and nothing declares that edge — so the fix cannot be
|
|
18
|
+
// "order the build correctly". It has to be that the mistake FAILS.
|
|
19
|
+
//
|
|
20
|
+
// The decisions live here, apart from the I/O that feeds them, so they can be
|
|
21
|
+
// tested without gettext installed and without a filesystem.
|
|
22
|
+
/** Base class so the plugin can rethrow a guard verbatim instead of wrapping it. */
|
|
23
|
+
export class GettextGuardError extends Error {
|
|
24
|
+
}
|
|
25
|
+
/** A `sources` pattern matched nothing, and was not declared optional. */
|
|
26
|
+
export class EmptySourcePatternError extends GettextGuardError {
|
|
27
|
+
patterns;
|
|
28
|
+
constructor(message,
|
|
29
|
+
/** The patterns that matched no files, verbatim as configured. */
|
|
30
|
+
patterns) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.patterns = patterns;
|
|
33
|
+
this.name = 'EmptySourcePatternError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** `msgmerge` would obsolete more of the catalogs than this build allows. */
|
|
37
|
+
export class CatalogShrinkError extends GettextGuardError {
|
|
38
|
+
lostEntries;
|
|
39
|
+
catalogEntries;
|
|
40
|
+
constructor(message,
|
|
41
|
+
/** Entries the reference catalog holds that the new POT no longer carries. */
|
|
42
|
+
lostEntries, catalogEntries) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.lostEntries = lostEntries;
|
|
45
|
+
this.catalogEntries = catalogEntries;
|
|
46
|
+
this.name = 'CatalogShrinkError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** A `maxCatalogEntryLoss` that is not a fraction — the guard cannot act on it. */
|
|
50
|
+
export class InvalidEntryLossError extends GettextGuardError {
|
|
51
|
+
constructor(message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = 'InvalidEntryLossError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Fraction of its entries a catalog set may lose in one extraction.
|
|
58
|
+
*
|
|
59
|
+
* Why a RATIO: an absolute count cannot be right for both a twelve-string dialog
|
|
60
|
+
* and a nine-hundred-string tutorial. Why a THIRD: the two populations this has
|
|
61
|
+
* to separate are far apart. Ordinary churn between two runs of the same build is
|
|
62
|
+
* a handful of strings — single-digit percent of any real catalog — while the
|
|
63
|
+
* failure above drops a whole source group at once. A third sits in the empty
|
|
64
|
+
* space between them. Half, the other candidate, is worse: a project with two
|
|
65
|
+
* source groups of similar size loses one of them at ~50% and slips under.
|
|
66
|
+
*
|
|
67
|
+
* A deliberate rewrite that really does delete more says so with
|
|
68
|
+
* `maxCatalogEntryLoss`, which is the point of it being an option.
|
|
69
|
+
*/
|
|
70
|
+
export const DEFAULT_MAX_CATALOG_ENTRY_LOSS = 1 / 3;
|
|
71
|
+
/**
|
|
72
|
+
* Fails unless every source pattern resolved to at least one file.
|
|
73
|
+
*
|
|
74
|
+
* FAILING IS THE DEFAULT ON PURPOSE. A pattern that matches nothing is almost
|
|
75
|
+
* always a build-order or path mistake, and the cost of being wrong is
|
|
76
|
+
* asymmetric: a false failure costs one build, a missed one costs every
|
|
77
|
+
* translation the group carried. A project that genuinely has an optional group
|
|
78
|
+
* names those patterns in `optionalSources` — per pattern, never a blanket
|
|
79
|
+
* boolean, so the guard stays armed for every other entry.
|
|
80
|
+
*/
|
|
81
|
+
export function assertEverySourcePatternMatched(matches, context) {
|
|
82
|
+
const optional = new Set(context.optionalSources ?? []);
|
|
83
|
+
const empty = matches
|
|
84
|
+
.filter((match) => match.fileCount === 0 && !optional.has(match.pattern))
|
|
85
|
+
.map((match) => match.pattern);
|
|
86
|
+
if (empty.length === 0) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const list = empty.map((pattern) => ` - ${pattern}`).join('\n');
|
|
90
|
+
// The negated patterns are named whenever there are any: a pattern whose every
|
|
91
|
+
// hit was excluded looks exactly like a wrong path, and the two have different
|
|
92
|
+
// fixes.
|
|
93
|
+
const excluded = context.ignore?.length
|
|
94
|
+
? `After excluding ${context.ignore.map((pattern) => `!${pattern}`).join(', ')}.\n`
|
|
95
|
+
: '';
|
|
96
|
+
throw new EmptySourcePatternError(`[${context.pluginName}] ${empty.length === 1 ? 'a source pattern' : `${empty.length} source patterns`} matched no files:\n` +
|
|
97
|
+
`${list}\n` +
|
|
98
|
+
`Resolved relative to ${context.cwd}.\n` +
|
|
99
|
+
excluded +
|
|
100
|
+
'Nothing was extracted. Extracting anyway writes a POT without those strings, and with ' +
|
|
101
|
+
'autoUpdatePo that prunes the same strings out of every catalog. Usual cause: the pattern ' +
|
|
102
|
+
'points at a build artifact of another package that has not been built yet.\n' +
|
|
103
|
+
"If the group really is optional, list the pattern verbatim in the plugin's `optionalSources`.", empty);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Reads `maxCatalogEntryLoss`, which has to be a FRACTION.
|
|
107
|
+
*
|
|
108
|
+
* Validated separately, and before the catalogs are looked at, because the two
|
|
109
|
+
* ways of getting it wrong both end in silence. `50` — the option read as a
|
|
110
|
+
* percentage — is `<= 50` for every possible ratio, so the guard passes
|
|
111
|
+
* everything and reports nothing; `NaN` fails every comparison, so the guard
|
|
112
|
+
* fires on a build that lost nothing and gets raised out of the way. Neither
|
|
113
|
+
* would ever be traced back to the option.
|
|
114
|
+
*/
|
|
115
|
+
export function resolveMaxEntryLoss(value, pluginName) {
|
|
116
|
+
if (value === undefined) {
|
|
117
|
+
return DEFAULT_MAX_CATALOG_ENTRY_LOSS;
|
|
118
|
+
}
|
|
119
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) {
|
|
120
|
+
throw new InvalidEntryLossError(`[${pluginName}] maxCatalogEntryLoss must be a fraction between 0 and 1, and is ${String(value)}. ` +
|
|
121
|
+
'It is a fraction, not a percentage: a third is 0.33, half is 0.5, and 1 turns the check off.');
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Fails when merging the freshly written POT would obsolete more of the catalogs
|
|
127
|
+
* than `maxEntryLoss` allows.
|
|
128
|
+
*
|
|
129
|
+
* What is measured is the SET of msgids the POT no longer carries, not how much
|
|
130
|
+
* shorter it got. `msgmerge` matches by msgid, so an equally long POT whose
|
|
131
|
+
* strings have changed obsoletes just as much as a short one — and measured on
|
|
132
|
+
* gettext 0.26, a msgid that only gained a space is fuzzy-matched, keeps its
|
|
133
|
+
* translation, and is then excluded from the `.mo` by `msgfmt` anyway. A count
|
|
134
|
+
* cannot see that; a set difference is the quantity `msgmerge` acts on.
|
|
135
|
+
*
|
|
136
|
+
* The reference is the LARGEST catalog rather than each one in turn: `msgmerge`
|
|
137
|
+
* gives every catalog the POT's msgid set (measured — a 1-entry and a 2-entry
|
|
138
|
+
* catalog both came back with the POT's 3), so the biggest one is the best
|
|
139
|
+
* evidence of what the project had before this run. Comparing per language would
|
|
140
|
+
* let one catalog that a previous bad run already gutted excuse gutting the rest.
|
|
141
|
+
*/
|
|
142
|
+
export function assertCatalogsSurviveMerge(args) {
|
|
143
|
+
const limit = resolveMaxEntryLoss(args.maxEntryLoss, args.pluginName);
|
|
144
|
+
const reference = args.catalogs.reduce((largest, candidate) => (largest && largest.msgids.size >= candidate.msgids.size ? largest : candidate), undefined);
|
|
145
|
+
// No catalogs yet, or an empty one: there is nothing a merge can destroy, and
|
|
146
|
+
// a ratio against zero is not a number.
|
|
147
|
+
if (!reference || reference.msgids.size === 0) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const held = reference.msgids.size;
|
|
151
|
+
let lost = 0;
|
|
152
|
+
for (const msgid of reference.msgids) {
|
|
153
|
+
if (!args.potMsgids.has(msgid)) {
|
|
154
|
+
lost++;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (lost === 0 || lost / held <= limit) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const percent = (value) => `${Math.round(value * 100)}%`;
|
|
161
|
+
throw new CatalogShrinkError(`[${args.pluginName}] refusing to update the catalogs: ${args.potFile} no longer carries ${lost} of the ` +
|
|
162
|
+
`${held} entries ${reference.language}.po holds. That is a loss of ${percent(lost / held)}, over the ` +
|
|
163
|
+
`${percent(limit)} this build allows, across ${args.catalogs.length} ` +
|
|
164
|
+
`${args.catalogs.length === 1 ? 'catalog' : 'catalogs'}.\n` +
|
|
165
|
+
'msgmerge would move every missing entry into `#~` comments, which msgfmt ignores — the ' +
|
|
166
|
+
'translations would be gone. Usual cause: a source group went missing from this run.\n' +
|
|
167
|
+
"If the strings really were deleted, raise the plugin's `maxCatalogEntryLoss` for the run that does it.", lost, held);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The msgids a PO/POT file still USES, keyed the way gettext keys them.
|
|
171
|
+
*
|
|
172
|
+
* Obsolete entries (`#~ msgid`) are excluded because `msgfmt` excludes them: a
|
|
173
|
+
* gutted catalog keeps every line and loses every translation, so a line count
|
|
174
|
+
* would report it as healthy. The header is excluded by being the one entry whose
|
|
175
|
+
* msgid is empty and uncontextualised.
|
|
176
|
+
*
|
|
177
|
+
* Continuation lines are CONCATENATED rather than counted, which is what makes a
|
|
178
|
+
* POT comparable with a catalog at all: the same string is wrapped differently
|
|
179
|
+
* depending on `--no-wrap` and on how long the surrounding lines were, and only
|
|
180
|
+
* the joined value is stable. `msgctxt` joins its msgid through gettext's own
|
|
181
|
+
* `\u0004` separator, so two entries that differ only in context stay two.
|
|
182
|
+
*/
|
|
183
|
+
export function activeMsgids(text) {
|
|
184
|
+
const msgids = new Set();
|
|
185
|
+
let reading;
|
|
186
|
+
let pieces = [];
|
|
187
|
+
let context;
|
|
188
|
+
const finish = () => {
|
|
189
|
+
if (reading === 'msgctxt') {
|
|
190
|
+
context = pieces.join('');
|
|
191
|
+
}
|
|
192
|
+
else if (reading === 'msgid') {
|
|
193
|
+
const msgid = pieces.join('');
|
|
194
|
+
if (msgid !== '' || context !== undefined) {
|
|
195
|
+
msgids.add(context === undefined ? msgid : `${context}\u0004${msgid}`);
|
|
196
|
+
}
|
|
197
|
+
context = undefined;
|
|
198
|
+
}
|
|
199
|
+
reading = undefined;
|
|
200
|
+
pieces = [];
|
|
201
|
+
};
|
|
202
|
+
for (const line of text.split('\n')) {
|
|
203
|
+
const opener = /^(msgctxt|msgid)[ \t]/.exec(line);
|
|
204
|
+
if (opener) {
|
|
205
|
+
finish();
|
|
206
|
+
reading = opener[1];
|
|
207
|
+
pieces = [literal(line)];
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
// A bare string literal continues whatever keyword opened the entry. An
|
|
211
|
+
// obsolete entry's own continuations are prefixed `#~ `, so they never
|
|
212
|
+
// reach here and never re-open one either.
|
|
213
|
+
if (reading && /^[ \t]*"/.test(line)) {
|
|
214
|
+
pieces.push(literal(line));
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
finish();
|
|
218
|
+
}
|
|
219
|
+
finish();
|
|
220
|
+
return msgids;
|
|
221
|
+
}
|
|
222
|
+
/** The quoted payload of a PO line, unescaped no further than both files are. */
|
|
223
|
+
function literal(line) {
|
|
224
|
+
const first = line.indexOf('"');
|
|
225
|
+
const last = line.lastIndexOf('"');
|
|
226
|
+
return first < 0 || last <= first ? '' : line.slice(first + 1, last);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Entries a PO/POT file still USES — the size of {@link activeMsgids}, and the
|
|
230
|
+
* same measurement, so the number and the set can never disagree.
|
|
231
|
+
*/
|
|
232
|
+
export function countActiveEntries(text) {
|
|
233
|
+
return activeMsgids(text).size;
|
|
234
|
+
}
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
package/lib/types.d.ts
CHANGED
|
@@ -3,8 +3,29 @@
|
|
|
3
3
|
* Used to extract translatable strings from source files
|
|
4
4
|
*/
|
|
5
5
|
export interface XGettextPluginOptions {
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Glob patterns for source files to extract strings from.
|
|
8
|
+
*
|
|
9
|
+
* fast-glob syntax: a leading `!` excludes, and applies to the whole set
|
|
10
|
+
* rather than standing on its own as a group.
|
|
11
|
+
*/
|
|
7
12
|
sources: string[];
|
|
13
|
+
/**
|
|
14
|
+
* Entries of `sources` that are allowed to match no files.
|
|
15
|
+
*
|
|
16
|
+
* Every other pattern that matches nothing fails the build — see
|
|
17
|
+
* `assertEverySourcePatternMatched`. Listed per pattern rather than as one
|
|
18
|
+
* boolean so declaring one group optional does not disarm the rest.
|
|
19
|
+
*/
|
|
20
|
+
optionalSources?: string[];
|
|
21
|
+
/**
|
|
22
|
+
* Fraction of its entries the catalog set may lose in one run before
|
|
23
|
+
* `autoUpdatePo` refuses to merge. Defaults to 1/3; `1` disables the check.
|
|
24
|
+
*
|
|
25
|
+
* A FRACTION, not a percentage — `50` would wave every possible loss through,
|
|
26
|
+
* so anything outside 0…1 is refused rather than obeyed.
|
|
27
|
+
*/
|
|
28
|
+
maxCatalogEntryLoss?: number;
|
|
8
29
|
/** Output path for the POT template file */
|
|
9
30
|
output: string;
|
|
10
31
|
/** The gettext domain name, defaults to 'messages' */
|
package/lib/xgettext.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import fs from 'node:fs/promises';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
5
|
import glob from 'fast-glob';
|
|
6
|
+
import { activeMsgids, assertCatalogsSurviveMerge, assertEverySourcePatternMatched, EmptySourcePatternError, GettextGuardError, } from './guards.js';
|
|
6
7
|
import { checkDependencies, ensureDirectory, processFilename } from './utils.js';
|
|
7
8
|
// Add GLib preset constants
|
|
8
9
|
// From https://github.com/mesonbuild/meson/blob/467da051c859ba3112803b035e317bddadd756ef/mesonbuild/modules/i18n.py
|
|
@@ -74,23 +75,67 @@ export function xgettextPlugin(options) {
|
|
|
74
75
|
name: pluginName,
|
|
75
76
|
async buildStart() {
|
|
76
77
|
await checkDependencies('xgettext', pluginName, options.verbose ?? false);
|
|
77
|
-
const files = await
|
|
78
|
+
const files = await resolveSources(options, pluginName);
|
|
78
79
|
await extractStrings(files, options, pluginName);
|
|
79
80
|
},
|
|
80
81
|
configureServer(server) {
|
|
81
82
|
server.watcher.add(options.sources);
|
|
82
83
|
server.watcher.on('change', async (file) => {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
// Membership in the RESOLVED set, not `file.match(pattern)`: that
|
|
85
|
+
// compiled the glob as a regular expression, and any `**` in it is
|
|
86
|
+
// `Nothing to repeat`, so every change event threw a SyntaxError
|
|
87
|
+
// before it could decide anything. Re-extraction in `vite dev` had
|
|
88
|
+
// never run, which is also why the guards below had never been
|
|
89
|
+
// reached from here.
|
|
90
|
+
const files = await resolveSources(options, pluginName);
|
|
91
|
+
const changed = path.resolve(file);
|
|
92
|
+
if (!files.some((candidate) => path.resolve(candidate) === changed)) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (options.verbose) {
|
|
96
|
+
console.log(`[${pluginName}] Source file changed: ${file}, re-running extraction`);
|
|
89
97
|
}
|
|
98
|
+
await extractStrings(files, options, pluginName);
|
|
90
99
|
});
|
|
91
100
|
},
|
|
92
101
|
};
|
|
93
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Resolves `sources` to files, globbing each POSITIVE pattern separately under
|
|
105
|
+
* the negative ones.
|
|
106
|
+
*
|
|
107
|
+
* The union `glob(options.sources)` used to return cannot say WHICH pattern came
|
|
108
|
+
* up empty, and in the incident `guards.ts` records only one of several did — so
|
|
109
|
+
* the union was non-empty and there was nothing left to notice. Per-pattern is
|
|
110
|
+
* what makes the guard able to name the offender.
|
|
111
|
+
*
|
|
112
|
+
* The negative patterns have to stay a property of the WHOLE set, though.
|
|
113
|
+
* fast-glob reads a leading `!` as an ignore filter over the other patterns, and
|
|
114
|
+
* returns nothing at all for a list that is only negations — so globbing one on
|
|
115
|
+
* its own yields zero files, which the guard would report as a missing source
|
|
116
|
+
* group, and silencing that with `optionalSources` would then extract the very
|
|
117
|
+
* files the `!` was there to keep out. They are lifted into `ignore` instead,
|
|
118
|
+
* which is what fast-glob does with them internally.
|
|
119
|
+
*/
|
|
120
|
+
async function resolveSources(options, pluginName) {
|
|
121
|
+
const included = options.sources.filter((pattern) => !pattern.startsWith('!'));
|
|
122
|
+
const ignore = options.sources.filter((pattern) => pattern.startsWith('!')).map((pattern) => pattern.slice(1));
|
|
123
|
+
const perPattern = await Promise.all(included.map((pattern) => glob(pattern, { ignore })));
|
|
124
|
+
assertEverySourcePatternMatched(included.map((pattern, index) => ({ pattern, fileCount: perPattern[index].length })), { pluginName, cwd: process.cwd(), optionalSources: options.optionalSources, ignore });
|
|
125
|
+
// Two patterns may legitimately reach the same file; xgettext would then scan
|
|
126
|
+
// it twice and msgcat would have to fold the duplicate back out.
|
|
127
|
+
const files = [...new Set(perPattern.flat())];
|
|
128
|
+
// The one hole the per-pattern guard leaves: no positive pattern at all, or
|
|
129
|
+
// every pattern declared optional and every one empty. Extraction would run
|
|
130
|
+
// over nothing, write an empty POT and prune every catalog against it — the
|
|
131
|
+
// incident again, reached by a different route.
|
|
132
|
+
if (files.length === 0) {
|
|
133
|
+
throw new EmptySourcePatternError(`[${pluginName}] no source file to extract from: ${options.sources.length} pattern(s) resolved to ` +
|
|
134
|
+
`nothing under ${process.cwd()}.\n` +
|
|
135
|
+
'Extracting anyway writes an empty POT, and with autoUpdatePo that empties every catalog.', options.sources);
|
|
136
|
+
}
|
|
137
|
+
return files;
|
|
138
|
+
}
|
|
94
139
|
async function generatePotfiles(files, outputDir, pluginName, verbose = false) {
|
|
95
140
|
// Group files by extension
|
|
96
141
|
const fileGroups = new Map();
|
|
@@ -107,15 +152,14 @@ async function generatePotfiles(files, outputDir, pluginName, verbose = false) {
|
|
|
107
152
|
for (const [group, groupFiles] of fileGroups) {
|
|
108
153
|
const potfilePath = path.join(outputDir, `${group}.POTFILES`);
|
|
109
154
|
const content = groupFiles.join('\n');
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
console.error(`[${pluginName}] Error writing ${group}.POTFILES:`, error);
|
|
155
|
+
// Deliberately unguarded. A caught write failure used to leave the group
|
|
156
|
+
// out of `potFiles`, so xgettext never ran for it and the whole group left
|
|
157
|
+
// the POT — the incident in `guards.ts`, reached without any pattern being
|
|
158
|
+
// wrong. Nothing here can be recovered from; it has to end the build.
|
|
159
|
+
await fs.writeFile(potfilePath, content);
|
|
160
|
+
potFiles.push(potfilePath);
|
|
161
|
+
if (verbose) {
|
|
162
|
+
console.log(`[${pluginName}] Generated ${group}.POTFILES with ${groupFiles.length} source files`);
|
|
119
163
|
}
|
|
120
164
|
}
|
|
121
165
|
return potFiles;
|
|
@@ -306,48 +350,105 @@ async function extractStrings(files, options, pluginName) {
|
|
|
306
350
|
}
|
|
307
351
|
}
|
|
308
352
|
if (options.autoUpdatePo) {
|
|
353
|
+
await assertCatalogsSurviveNextMerge(options, pluginName);
|
|
309
354
|
await updatePoFiles(options.output, pluginName, options.verbose || false, options);
|
|
310
355
|
}
|
|
311
356
|
}
|
|
312
357
|
catch (error) {
|
|
358
|
+
// A guard's message IS the guard — wrapping it in "Failed to extract
|
|
359
|
+
// translations: Error: …" buries the instruction that makes it useful.
|
|
360
|
+
if (error instanceof GettextGuardError) {
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
313
363
|
throw new Error(`Failed to extract translations: ${error}`);
|
|
314
364
|
}
|
|
315
365
|
}
|
|
316
|
-
|
|
366
|
+
/** The catalogs LINGUAS declares beside a POT. */
|
|
367
|
+
async function listCatalogs(potFile) {
|
|
368
|
+
const directory = path.dirname(potFile);
|
|
369
|
+
const linguas = await readTextOrEmpty(path.join(directory, 'LINGUAS'));
|
|
370
|
+
return linguas
|
|
371
|
+
.split('\n')
|
|
372
|
+
.filter(Boolean)
|
|
373
|
+
.map((language) => ({ language, file: path.join(directory, `${language}.po`) }));
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* A file that is not there yet reads as empty rather than as a failure: a project
|
|
377
|
+
* with no LINGUAS has no translations, and a language named in LINGUAS before its
|
|
378
|
+
* catalog exists holds no entries. Both are states a first run is legitimately
|
|
379
|
+
* in, and neither is something a merge can destroy.
|
|
380
|
+
*/
|
|
381
|
+
async function readTextOrEmpty(file) {
|
|
317
382
|
try {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
383
|
+
return await fs.readFile(file, 'utf-8');
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
// ONLY "not there yet" reads as empty. A permission or I/O error must not:
|
|
387
|
+
// an unreadable catalog counted as holding nothing is the guard talking
|
|
388
|
+
// itself out of firing.
|
|
389
|
+
if (error?.code === 'ENOENT') {
|
|
390
|
+
return '';
|
|
391
|
+
}
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Reads what `msgmerge` is about to rewrite and hands the counts to the pure
|
|
397
|
+
* check.
|
|
398
|
+
*
|
|
399
|
+
* Called from `extractStrings` BEFORE `updatePoFiles` and outside that function's
|
|
400
|
+
* catch — a refusal that became one more `console.error` beside a zero exit code
|
|
401
|
+
* would be the exact silence this guards against.
|
|
402
|
+
*/
|
|
403
|
+
async function assertCatalogsSurviveNextMerge(options, pluginName) {
|
|
404
|
+
const catalogs = await listCatalogs(options.output);
|
|
405
|
+
const sizes = await Promise.all(catalogs.map(async ({ language, file }) => ({
|
|
406
|
+
language,
|
|
407
|
+
msgids: activeMsgids(await readTextOrEmpty(file)),
|
|
408
|
+
})));
|
|
409
|
+
assertCatalogsSurviveMerge({
|
|
410
|
+
potMsgids: activeMsgids(await readTextOrEmpty(options.output)),
|
|
411
|
+
catalogs: sizes,
|
|
412
|
+
potFile: options.output,
|
|
413
|
+
pluginName,
|
|
414
|
+
maxEntryLoss: options.maxCatalogEntryLoss,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Merges the POT into every catalog LINGUAS declares.
|
|
419
|
+
*
|
|
420
|
+
* Deliberately unguarded, like `generatePotfiles`. A caught error here used to be
|
|
421
|
+
* one `console.error` beside exit 0 — and by then `msgmerge --update` may already
|
|
422
|
+
* have rewritten the catalogs it got to, so "an error was printed" and "the
|
|
423
|
+
* catalogs are intact" were unrelated facts.
|
|
424
|
+
*/
|
|
425
|
+
async function updatePoFiles(potFile, pluginName, verbose, options) {
|
|
426
|
+
for (const { file: poFile } of await listCatalogs(potFile)) {
|
|
427
|
+
if (verbose) {
|
|
428
|
+
console.log(`[${pluginName}] Updating ${poFile}`);
|
|
429
|
+
}
|
|
430
|
+
const baseMsgmergeArgs = ['--update', '--backup=none', poFile, potFile];
|
|
431
|
+
const args = buildCommandArgs(baseMsgmergeArgs, {
|
|
432
|
+
noLocation: options.noLocation,
|
|
433
|
+
noWrap: options.noWrap,
|
|
434
|
+
});
|
|
435
|
+
const env = { ...process.env };
|
|
436
|
+
if (options.deterministic) {
|
|
437
|
+
const epoch = typeof options.sourceDateEpoch === 'number' ? options.sourceDateEpoch : 0;
|
|
438
|
+
env.SOURCE_DATE_EPOCH = String(epoch);
|
|
439
|
+
}
|
|
440
|
+
await execa('msgmerge', args, { env });
|
|
441
|
+
// Post-process with msgcat to unwrap existing wrapped lines
|
|
442
|
+
if (options.noWrap) {
|
|
443
|
+
const tempFile = poFile + '.tmp';
|
|
444
|
+
const msgcatArgs = ['--width=0', '--no-wrap', '-o', tempFile, poFile];
|
|
445
|
+
await execa('msgcat', msgcatArgs, { env });
|
|
446
|
+
await fs.rename(tempFile, poFile);
|
|
322
447
|
if (verbose) {
|
|
323
|
-
console.log(`[${pluginName}]
|
|
324
|
-
}
|
|
325
|
-
const baseMsgmergeArgs = ['--update', '--backup=none', poFile, potFile];
|
|
326
|
-
const args = buildCommandArgs(baseMsgmergeArgs, {
|
|
327
|
-
noLocation: options.noLocation,
|
|
328
|
-
noWrap: options.noWrap,
|
|
329
|
-
});
|
|
330
|
-
const env = { ...process.env };
|
|
331
|
-
if (options.deterministic) {
|
|
332
|
-
const epoch = typeof options.sourceDateEpoch === 'number' ? options.sourceDateEpoch : 0;
|
|
333
|
-
env.SOURCE_DATE_EPOCH = String(epoch);
|
|
334
|
-
}
|
|
335
|
-
await execa('msgmerge', args, { env });
|
|
336
|
-
// Post-process with msgcat to unwrap existing wrapped lines
|
|
337
|
-
if (options.noWrap) {
|
|
338
|
-
const tempFile = poFile + '.tmp';
|
|
339
|
-
const msgcatArgs = ['--width=0', '--no-wrap', '-o', tempFile, poFile];
|
|
340
|
-
await execa('msgcat', msgcatArgs, { env });
|
|
341
|
-
await fs.rename(tempFile, poFile);
|
|
342
|
-
if (verbose) {
|
|
343
|
-
console.log(`[${pluginName}] Unwrapped lines in ${poFile}`);
|
|
344
|
-
}
|
|
448
|
+
console.log(`[${pluginName}] Unwrapped lines in ${poFile}`);
|
|
345
449
|
}
|
|
346
450
|
}
|
|
347
451
|
}
|
|
348
|
-
catch (error) {
|
|
349
|
-
console.error(`[${pluginName}] Error updating PO files:`, error);
|
|
350
|
-
}
|
|
351
452
|
}
|
|
352
453
|
/**
|
|
353
454
|
* Formats a date in gettext header format using an epoch (seconds) in UTC timezone
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/vite-plugin-gettext",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.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",
|
|
@@ -16,9 +16,12 @@
|
|
|
16
16
|
"lib"
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
|
-
"clear": "gjsify clear lib tsconfig.tsbuildinfo",
|
|
19
|
+
"clear": "gjsify clear lib tsconfig.tsbuildinfo test.node.mjs",
|
|
20
20
|
"check": "gjsify tsc --noEmit",
|
|
21
|
-
"build": "gjsify tsc"
|
|
21
|
+
"build": "gjsify tsc -p tsconfig.build.json",
|
|
22
|
+
"build:test:node": "gjsify build src/test.mts --app node --outfile test.node.mjs",
|
|
23
|
+
"test": "gjsify run build:test:node && gjsify run test:node",
|
|
24
|
+
"test:node": "node test.node.mjs"
|
|
22
25
|
},
|
|
23
26
|
"repository": {
|
|
24
27
|
"type": "git",
|
|
@@ -56,6 +59,8 @@
|
|
|
56
59
|
}
|
|
57
60
|
},
|
|
58
61
|
"devDependencies": {
|
|
62
|
+
"@gjsify/cli": "^0.48.0",
|
|
63
|
+
"@gjsify/unit": "^0.48.0",
|
|
59
64
|
"@types/gettext-parser": "^9.0.0",
|
|
60
65
|
"@types/node": "^25.9.2",
|
|
61
66
|
"typescript": "^6.0.3",
|