@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 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
@@ -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
@@ -1,3 +1,4 @@
1
+ export { CatalogShrinkError, EmptySourcePatternError, GettextGuardError, InvalidEntryLossError } from './guards.js';
1
2
  export { gettextPlugin } from './gettext.js';
2
3
  export { msgfmtPlugin } from './msgfmt.js';
3
4
  export { xgettextPlugin } from './xgettext.js';
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { CatalogShrinkError, EmptySourcePatternError, GettextGuardError, InvalidEntryLossError } from './guards.js';
1
2
  export { gettextPlugin } from './gettext.js';
2
3
  export { msgfmtPlugin } from './msgfmt.js';
3
4
  export { xgettextPlugin } from './xgettext.js';
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
- /** Glob patterns for source files to extract strings from */
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 glob(options.sources);
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
- if (options.sources.some((pattern) => file.match(pattern))) {
84
- if (options.verbose) {
85
- console.log(`[${pluginName}] Source file changed: ${file}, re-running extraction`);
86
- }
87
- const files = await glob(options.sources);
88
- await extractStrings(files, options, pluginName);
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
- try {
111
- await fs.writeFile(potfilePath, content);
112
- potFiles.push(potfilePath);
113
- if (verbose) {
114
- console.log(`[${pluginName}] Generated ${group}.POTFILES with ${groupFiles.length} source files`);
115
- }
116
- }
117
- catch (error) {
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
- async function updatePoFiles(potFile, pluginName, verbose, options) {
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
- const linguasPath = path.join(path.dirname(potFile), 'LINGUAS');
319
- const languages = (await fs.readFile(linguasPath, 'utf-8')).split('\n').filter(Boolean);
320
- for (const lang of languages) {
321
- const poFile = path.join(path.dirname(potFile), `${lang}.po`);
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}] Updating ${poFile}`);
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.47.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",