@gtkx/cli 1.4.0 → 1.5.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 +1 -1
- package/dist/builder.d.ts +1 -0
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +2 -2
- package/dist/builder.js.map +1 -1
- package/dist/codegen/run-codegen.d.ts +2 -0
- package/dist/codegen/run-codegen.d.ts.map +1 -1
- package/dist/codegen/run-codegen.js +30 -6
- package/dist/codegen/run-codegen.js.map +1 -1
- package/dist/commands/codegen.js +1 -1
- package/dist/commands/codegen.js.map +1 -1
- package/dist/deploy/freedesktop/localize.d.ts +6 -0
- package/dist/deploy/freedesktop/localize.d.ts.map +1 -0
- package/dist/deploy/freedesktop/localize.js +107 -0
- package/dist/deploy/freedesktop/localize.js.map +1 -0
- package/dist/deploy/install-hints.d.ts.map +1 -1
- package/dist/deploy/install-hints.js +5 -0
- package/dist/deploy/install-hints.js.map +1 -1
- package/dist/deploy/payload/launcher.d.ts.map +1 -1
- package/dist/deploy/payload/launcher.js +2 -0
- package/dist/deploy/payload/launcher.js.map +1 -1
- package/dist/deploy/payload/stage.d.ts +1 -1
- package/dist/deploy/payload/stage.d.ts.map +1 -1
- package/dist/deploy/payload/stage.js +20 -11
- package/dist/deploy/payload/stage.js.map +1 -1
- package/dist/deploy/run-deploy.d.ts.map +1 -1
- package/dist/deploy/run-deploy.js +87 -15
- package/dist/deploy/run-deploy.js.map +1 -1
- package/dist/deploy/targets/flatpak-source.d.ts.map +1 -1
- package/dist/deploy/targets/flatpak-source.js +21 -10
- package/dist/deploy/targets/flatpak-source.js.map +1 -1
- package/dist/deploy/tools.d.ts +6 -1
- package/dist/deploy/tools.d.ts.map +1 -1
- package/dist/deploy/tools.js +26 -1
- package/dist/deploy/tools.js.map +1 -1
- package/dist/dev/locale-dir.d.ts +3 -0
- package/dist/dev/locale-dir.d.ts.map +1 -0
- package/dist/dev/locale-dir.js +17 -0
- package/dist/dev/locale-dir.js.map +1 -0
- package/dist/dev/runner-main.d.ts.map +1 -1
- package/dist/dev/runner-main.js +2 -0
- package/dist/dev/runner-main.js.map +1 -1
- package/dist/dev/runner.d.ts.map +1 -1
- package/dist/dev/runner.js +6 -0
- package/dist/dev/runner.js.map +1 -1
- package/dist/dev/vite-dev-server.d.ts +1 -0
- package/dist/dev/vite-dev-server.d.ts.map +1 -1
- package/dist/dev/vite-dev-server.js.map +1 -1
- package/dist/i18n/binding-extractor.d.ts +30 -0
- package/dist/i18n/binding-extractor.d.ts.map +1 -0
- package/dist/i18n/binding-extractor.js +1324 -0
- package/dist/i18n/binding-extractor.js.map +1 -0
- package/dist/i18n/catalog-template.d.ts +3 -0
- package/dist/i18n/catalog-template.d.ts.map +1 -0
- package/dist/i18n/catalog-template.js +18 -0
- package/dist/i18n/catalog-template.js.map +1 -0
- package/dist/i18n/catalogs.d.ts +21 -0
- package/dist/i18n/catalogs.d.ts.map +1 -0
- package/dist/i18n/catalogs.js +139 -0
- package/dist/i18n/catalogs.js.map +1 -0
- package/dist/i18n/extractor-plugin.d.ts +8 -0
- package/dist/i18n/extractor-plugin.d.ts.map +1 -0
- package/dist/i18n/extractor-plugin.js +247 -0
- package/dist/i18n/extractor-plugin.js.map +1 -0
- package/dist/i18n/metadata-templates.d.ts +11 -0
- package/dist/i18n/metadata-templates.d.ts.map +1 -0
- package/dist/i18n/metadata-templates.js +11 -0
- package/dist/i18n/metadata-templates.js.map +1 -0
- package/dist/i18n/source-messages.d.ts +23 -0
- package/dist/i18n/source-messages.d.ts.map +1 -0
- package/dist/i18n/source-messages.js +403 -0
- package/dist/i18n/source-messages.js.map +1 -0
- package/dist/i18n/types.d.ts +10 -0
- package/dist/i18n/types.d.ts.map +1 -0
- package/dist/i18n/types.js +108 -0
- package/dist/i18n/types.js.map +1 -0
- package/dist/internal/source-imports.d.ts +5 -1
- package/dist/internal/source-imports.d.ts.map +1 -1
- package/dist/internal/source-imports.js +23 -44
- package/dist/internal/source-imports.js.map +1 -1
- package/dist/mcp/client.d.ts +0 -1
- package/dist/mcp/client.d.ts.map +1 -1
- package/dist/mcp/client.js +11 -39
- package/dist/mcp/client.js.map +1 -1
- package/dist/mcp/handlers.d.ts +2 -1
- package/dist/mcp/handlers.d.ts.map +1 -1
- package/dist/mcp/handlers.js.map +1 -1
- package/dist/settings/render.d.ts +4 -1
- package/dist/settings/render.d.ts.map +1 -1
- package/dist/settings/render.js +11 -2
- package/dist/settings/render.js.map +1 -1
- package/dist/settings/schema.d.ts.map +1 -1
- package/dist/settings/schema.js +3 -1
- package/dist/settings/schema.js.map +1 -1
- package/dist/vite-plugins/asset-imports.d.ts.map +1 -1
- package/dist/vite-plugins/asset-imports.js +2 -12
- package/dist/vite-plugins/asset-imports.js.map +1 -1
- package/dist/vite-plugins/i18n.d.ts +5 -0
- package/dist/vite-plugins/i18n.d.ts.map +1 -0
- package/dist/vite-plugins/i18n.js +88 -0
- package/dist/vite-plugins/i18n.js.map +1 -0
- package/dist/vite-plugins/index.d.ts +1 -1
- package/dist/vite-plugins/index.d.ts.map +1 -1
- package/dist/vite-plugins/index.js +3 -1
- package/dist/vite-plugins/index.js.map +1 -1
- package/dist/vite-plugins/resources.d.ts.map +1 -1
- package/dist/vite-plugins/resources.js +10 -55
- package/dist/vite-plugins/resources.js.map +1 -1
- package/dist/vite-plugins/self-contained.d.ts.map +1 -1
- package/dist/vite-plugins/self-contained.js +128 -135
- package/dist/vite-plugins/self-contained.js.map +1 -1
- package/package.json +10 -9
- package/src/builder.ts +3 -2
- package/src/codegen/run-codegen.ts +53 -7
- package/src/commands/codegen.ts +1 -1
- package/src/deploy/freedesktop/localize.ts +144 -0
- package/src/deploy/install-hints.ts +5 -0
- package/src/deploy/payload/launcher.ts +2 -0
- package/src/deploy/payload/stage.ts +35 -10
- package/src/deploy/run-deploy.ts +128 -13
- package/src/deploy/targets/flatpak-source.ts +33 -10
- package/src/deploy/tools.ts +35 -0
- package/src/dev/locale-dir.ts +22 -0
- package/src/dev/runner-main.ts +2 -0
- package/src/dev/runner.ts +8 -0
- package/src/dev/vite-dev-server.ts +1 -0
- package/src/i18n/binding-extractor.ts +1969 -0
- package/src/i18n/catalog-template.ts +24 -0
- package/src/i18n/catalogs.ts +216 -0
- package/src/i18n/extractor-plugin.ts +413 -0
- package/src/i18n/metadata-templates.ts +29 -0
- package/src/i18n/source-messages.ts +608 -0
- package/src/i18n/types.ts +154 -0
- package/src/internal/source-imports.ts +28 -57
- package/src/mcp/client.ts +17 -44
- package/src/mcp/handlers.ts +7 -6
- package/src/settings/render.ts +16 -2
- package/src/settings/schema.ts +3 -1
- package/src/vite-plugins/asset-imports.ts +3 -15
- package/src/vite-plugins/i18n.ts +146 -0
- package/src/vite-plugins/index.ts +3 -0
- package/src/vite-plugins/resources.ts +10 -72
- package/src/vite-plugins/self-contained.ts +150 -169
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
import type { ExtractedKey, I18nextToolkitConfig, Logger } from "i18next-cli";
|
|
2
|
+
import { findKeys } from "i18next-cli";
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
12
|
+
import type { CatalogProject } from "./catalogs.js";
|
|
13
|
+
import { runCliTool } from "../internal/run-cli-tool.js";
|
|
14
|
+
import { replaceCatalogTemplate } from "./catalog-template.js";
|
|
15
|
+
import { gtkxExtractorPlugin } from "./extractor-plugin.js";
|
|
16
|
+
import { metadataTemplateFiles } from "./metadata-templates.js";
|
|
17
|
+
|
|
18
|
+
type SourceLocation = {
|
|
19
|
+
file: string;
|
|
20
|
+
line?: number | undefined;
|
|
21
|
+
column?: number | undefined;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type SourceMessage = {
|
|
25
|
+
context: string | null;
|
|
26
|
+
kind: "default" | "plural" | "pluralDefaults" | "point";
|
|
27
|
+
locations: SourceLocation[];
|
|
28
|
+
msgid: string;
|
|
29
|
+
namespace: string;
|
|
30
|
+
plural: string | null;
|
|
31
|
+
singular: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type SourceCatalog = {
|
|
35
|
+
messages: SourceMessage[];
|
|
36
|
+
sourceFiles: string[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type SyntheticEntry = {
|
|
40
|
+
call: string;
|
|
41
|
+
line: number | undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type SourceExtraction = {
|
|
45
|
+
messages: SourceMessage[];
|
|
46
|
+
output: string;
|
|
47
|
+
project: CatalogProject;
|
|
48
|
+
sourceFiles: string[];
|
|
49
|
+
workDir: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type CatalogTemplateExtraction = {
|
|
53
|
+
messages: SourceMessage[];
|
|
54
|
+
output?: string | undefined;
|
|
55
|
+
project: CatalogProject;
|
|
56
|
+
shouldPreserveMetadataMessages: boolean;
|
|
57
|
+
sourceFiles: string[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type PluralVariant = {
|
|
61
|
+
category: string;
|
|
62
|
+
defaultValue: string;
|
|
63
|
+
locations: SourceLocation[];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type PluralGroup = {
|
|
67
|
+
baseKey: string;
|
|
68
|
+
namespace: string;
|
|
69
|
+
variants: PluralVariant[];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const CONTEXT_SEPARATOR = "\u{4}";
|
|
73
|
+
const DEFAULT_NAMESPACE = "translation";
|
|
74
|
+
const POTFILES_FILENAME = "POTFILES.in";
|
|
75
|
+
const SYNTHETIC_FILENAME = "messages.js";
|
|
76
|
+
|
|
77
|
+
const quietLogger = (): { logger: Logger; reports: string[] } => {
|
|
78
|
+
const reports: string[] = [];
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
reports,
|
|
82
|
+
logger: {
|
|
83
|
+
info() {
|
|
84
|
+
return;
|
|
85
|
+
},
|
|
86
|
+
warn(message, more) {
|
|
87
|
+
const detail = more === undefined ? "" : ` ${String(more)}`;
|
|
88
|
+
reports.push(`${message}${detail}`);
|
|
89
|
+
},
|
|
90
|
+
error(message) {
|
|
91
|
+
reports.push(String(message));
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const extractorConfig = (sourceFiles: string[]): I18nextToolkitConfig => {
|
|
98
|
+
const integration = gtkxExtractorPlugin();
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
locales: ["en"],
|
|
102
|
+
plugins: [integration.plugin],
|
|
103
|
+
extract: {
|
|
104
|
+
input: sourceFiles,
|
|
105
|
+
output: "unused/{{language}}/{{namespace}}.json",
|
|
106
|
+
contextSeparator: CONTEXT_SEPARATOR,
|
|
107
|
+
defaultNS: DEFAULT_NAMESPACE,
|
|
108
|
+
disablePlurals: false,
|
|
109
|
+
extractFromComments: false,
|
|
110
|
+
functions: [],
|
|
111
|
+
generateBasePluralForms: false,
|
|
112
|
+
keySeparator: ".",
|
|
113
|
+
nsSeparator: false,
|
|
114
|
+
pluralSeparator: "_",
|
|
115
|
+
transComponents: integration.transComponents,
|
|
116
|
+
useTranslationNames: [],
|
|
117
|
+
warnOnConflicts: "error",
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const splitContext = (key: string): { context: string | null; msgid: string } => {
|
|
123
|
+
const index = key.lastIndexOf(CONTEXT_SEPARATOR);
|
|
124
|
+
|
|
125
|
+
if (index === -1) {
|
|
126
|
+
return { context: null, msgid: key };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { context: key.slice(index + CONTEXT_SEPARATOR.length), msgid: key.slice(0, index) };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const namespaceFor = (entry: ExtractedKey): string =>
|
|
133
|
+
typeof entry.ns === "string" ? entry.ns : DEFAULT_NAMESPACE;
|
|
134
|
+
|
|
135
|
+
const validateIdentity = (context: string | null, msgid: string): void => {
|
|
136
|
+
if (context === "" || msgid.length === 0) {
|
|
137
|
+
throw new Error("Translation keys and contexts cannot be empty");
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const compareLocations = (left: SourceLocation, right: SourceLocation): number =>
|
|
142
|
+
left.file.localeCompare(right.file) ||
|
|
143
|
+
(left.line ?? 0) - (right.line ?? 0) ||
|
|
144
|
+
(left.column ?? 0) - (right.column ?? 0);
|
|
145
|
+
|
|
146
|
+
const normalizedLocations = (locations: SourceLocation[]): SourceLocation[] => {
|
|
147
|
+
const unique: Map<string, SourceLocation> = new Map();
|
|
148
|
+
|
|
149
|
+
for (const location of locations) {
|
|
150
|
+
const key = `${location.file}\0${String(location.line)}\0${String(location.column)}`;
|
|
151
|
+
unique.set(key, location);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return unique.values().toArray().toSorted(compareLocations);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const pointSource = (entry: ExtractedKey, msgid: string): string => {
|
|
158
|
+
if (entry.explicitDefault !== true || typeof entry.defaultValue !== "string") {
|
|
159
|
+
return msgid;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return entry.defaultValue;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const pointMessage = (entry: ExtractedKey): SourceMessage => {
|
|
166
|
+
const { context, msgid } = splitContext(entry.key);
|
|
167
|
+
validateIdentity(context, msgid);
|
|
168
|
+
const isDefaultExplicit = entry.explicitDefault === true;
|
|
169
|
+
const singular = pointSource(entry, msgid);
|
|
170
|
+
|
|
171
|
+
if (singular.length === 0) {
|
|
172
|
+
throw new Error("Translation source strings cannot be empty");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
context,
|
|
177
|
+
kind: isDefaultExplicit ? "default" : "point",
|
|
178
|
+
locations: normalizedLocations(entry.locations ?? []),
|
|
179
|
+
msgid,
|
|
180
|
+
namespace: namespaceFor(entry),
|
|
181
|
+
plural: null,
|
|
182
|
+
singular,
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const expandedPlural = (entry: ExtractedKey): { baseKey: string; variant: PluralVariant } => {
|
|
187
|
+
if (entry.isOrdinal === true) {
|
|
188
|
+
throw new Error("GNU gettext catalogs do not support i18next ordinal plurals");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const suffix = /_(few|many|one|other|two|zero)$/u.exec(entry.key);
|
|
192
|
+
const defaultValue = entry.defaultValue;
|
|
193
|
+
const category = suffix?.[1];
|
|
194
|
+
|
|
195
|
+
if (suffix === null || category === undefined || typeof defaultValue !== "string") {
|
|
196
|
+
throw new Error("Unable to recover an i18next plural source pair");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
baseKey: entry.key.slice(0, -suffix[0].length),
|
|
201
|
+
variant: {
|
|
202
|
+
category,
|
|
203
|
+
defaultValue,
|
|
204
|
+
locations: entry.locations ?? [],
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const groupedPlurals = (entries: ExtractedKey[]): PluralGroup[] => {
|
|
210
|
+
const groups: Map<string, PluralGroup> = new Map();
|
|
211
|
+
|
|
212
|
+
for (const entry of entries) {
|
|
213
|
+
const { baseKey, variant } = expandedPlural(entry);
|
|
214
|
+
const namespace = namespaceFor(entry);
|
|
215
|
+
const key = `${namespace}\0${baseKey}`;
|
|
216
|
+
const group = groups.get(key) ?? { baseKey, namespace, variants: [] };
|
|
217
|
+
group.variants.push(variant);
|
|
218
|
+
groups.set(key, group);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return groups.values().toArray();
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const pairedVariants = (variants: PluralVariant[]): { one: PluralVariant; other: PluralVariant } => {
|
|
225
|
+
const categories = new Map(variants.map((variant) => [variant.category, variant]));
|
|
226
|
+
|
|
227
|
+
if (categories.size !== 2 || !categories.has("one") || !categories.has("other")) {
|
|
228
|
+
throw new Error("GNU gettext requires one singular and one plural source string");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const one = categories.get("one");
|
|
232
|
+
const other = categories.get("other");
|
|
233
|
+
|
|
234
|
+
if (one === undefined || other === undefined) {
|
|
235
|
+
throw new Error("Unable to recover an i18next plural source pair");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { one, other };
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const pluralMessage = ({ baseKey, namespace, variants }: PluralGroup): SourceMessage => {
|
|
242
|
+
const { one, other } = pairedVariants(variants);
|
|
243
|
+
const { context, msgid } = splitContext(baseKey);
|
|
244
|
+
validateIdentity(context, msgid);
|
|
245
|
+
const hasStableKey = one.defaultValue !== other.defaultValue;
|
|
246
|
+
const singular = hasStableKey ? one.defaultValue : msgid;
|
|
247
|
+
const plural = other.defaultValue;
|
|
248
|
+
|
|
249
|
+
if (singular === plural || singular.length === 0 || plural.length === 0) {
|
|
250
|
+
throw new Error("GNU gettext plural source strings must be distinct and non-empty");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
context,
|
|
255
|
+
kind: hasStableKey ? "pluralDefaults" : "plural",
|
|
256
|
+
locations: normalizedLocations(variants.flatMap((variant) => variant.locations)),
|
|
257
|
+
msgid,
|
|
258
|
+
namespace,
|
|
259
|
+
plural,
|
|
260
|
+
singular,
|
|
261
|
+
};
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const pluralMessages = (entries: ExtractedKey[]): SourceMessage[] =>
|
|
265
|
+
groupedPlurals(entries).map((group) => pluralMessage(group));
|
|
266
|
+
|
|
267
|
+
const assertCardinal = (entry: ExtractedKey): void => {
|
|
268
|
+
if (entry.isOrdinal === true) {
|
|
269
|
+
throw new Error("GNU gettext catalogs do not support i18next ordinal plurals");
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const sourceMessages = (entries: ExtractedKey[]): SourceMessage[] => {
|
|
274
|
+
const points: ExtractedKey[] = [];
|
|
275
|
+
const plurals: ExtractedKey[] = [];
|
|
276
|
+
|
|
277
|
+
for (const entry of entries) {
|
|
278
|
+
assertCardinal(entry);
|
|
279
|
+
|
|
280
|
+
if (entry.isExpandedPlural === true) {
|
|
281
|
+
plurals.push(entry);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (entry.hasCount === true) {
|
|
286
|
+
throw new Error("Count-based translations require an explicit plural source string");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
points.push(entry);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return [...points.map((entry) => pointMessage(entry)), ...pluralMessages(plurals)];
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const compareMessages = (left: SourceMessage, right: SourceMessage): number =>
|
|
296
|
+
left.namespace.localeCompare(right.namespace) ||
|
|
297
|
+
(left.context ?? "").localeCompare(right.context ?? "") ||
|
|
298
|
+
left.msgid.localeCompare(right.msgid) ||
|
|
299
|
+
left.singular.localeCompare(right.singular) ||
|
|
300
|
+
(left.plural ?? "").localeCompare(right.plural ?? "");
|
|
301
|
+
|
|
302
|
+
const normalizedSourceFiles = (root: string, paths: string[]): string[] => {
|
|
303
|
+
const files = paths
|
|
304
|
+
.map((path) => resolve(root, path))
|
|
305
|
+
.filter((path) => {
|
|
306
|
+
const projectPath = relative(root, path);
|
|
307
|
+
|
|
308
|
+
return projectPath !== ".." && !projectPath.startsWith(`..${sep}`) && !isAbsolute(projectPath);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
return new Set(files).values().toArray().toSorted((left, right) => left.localeCompare(right));
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const findSourceMessages = async (sourceFiles: string[]): Promise<SourceMessage[]> => {
|
|
315
|
+
if (sourceFiles.length === 0) {
|
|
316
|
+
return [];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const fileErrors: string[] = [];
|
|
320
|
+
const { logger, reports } = quietLogger();
|
|
321
|
+
const { allKeys } = await findKeys(extractorConfig(sourceFiles), logger, fileErrors);
|
|
322
|
+
|
|
323
|
+
if (fileErrors.length > 0 || reports.length > 0) {
|
|
324
|
+
throw new Error([...fileErrors, ...reports].join("\n"));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return sourceMessages(allKeys.values().toArray()).toSorted(compareMessages);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const projectPath = (root: string, path: string): string | null => {
|
|
331
|
+
const absolute = isAbsolute(path) ? resolve(path) : resolve(root, path);
|
|
332
|
+
const projectRelative = relative(root, absolute);
|
|
333
|
+
|
|
334
|
+
if (
|
|
335
|
+
projectRelative === "" ||
|
|
336
|
+
projectRelative === ".." ||
|
|
337
|
+
projectRelative.startsWith(`..${sep}`) ||
|
|
338
|
+
isAbsolute(projectRelative)
|
|
339
|
+
) {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return projectRelative.replaceAll("\\", "/");
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const writePotfiles = (project: CatalogProject, sourceFiles: string[]): string => {
|
|
347
|
+
const paths = sourceFiles
|
|
348
|
+
.map((path) => projectPath(project.root, path))
|
|
349
|
+
.filter((path): path is string => path !== null);
|
|
350
|
+
|
|
351
|
+
const sorted = [...new Set(paths)].toSorted((left, right) => left.localeCompare(right));
|
|
352
|
+
const target = resolve(project.poDir, POTFILES_FILENAME);
|
|
353
|
+
writeFileSync(target, sorted.length === 0 ? "" : `${sorted.join("\n")}\n`);
|
|
354
|
+
|
|
355
|
+
return target;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const renderCatalogCall = (message: SourceMessage): string => {
|
|
359
|
+
const msgid = JSON.stringify(message.singular);
|
|
360
|
+
|
|
361
|
+
if (message.plural !== null) {
|
|
362
|
+
const plural = JSON.stringify(message.plural);
|
|
363
|
+
|
|
364
|
+
return message.context === null
|
|
365
|
+
? `ngettext(${msgid}, ${plural}, 0);`
|
|
366
|
+
: `npgettext(${JSON.stringify(message.context)}, ${msgid}, ${plural}, 0);`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return message.context === null
|
|
370
|
+
? `gettext(${msgid});`
|
|
371
|
+
: `pgettext(${JSON.stringify(message.context)}, ${msgid});`;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const readSources = (sourceFiles: string[]): Map<string, string> =>
|
|
375
|
+
new Map(sourceFiles.map((path) => [path, readFileSync(path, "utf8")]));
|
|
376
|
+
|
|
377
|
+
const locatedOwners = (
|
|
378
|
+
project: CatalogProject,
|
|
379
|
+
message: SourceMessage,
|
|
380
|
+
): { path: string; line: number | undefined }[] => {
|
|
381
|
+
const owners: { path: string; line: number | undefined }[] = [];
|
|
382
|
+
|
|
383
|
+
for (const location of message.locations) {
|
|
384
|
+
const path = projectPath(project.root, location.file);
|
|
385
|
+
|
|
386
|
+
if (path !== null) {
|
|
387
|
+
owners.push({ path, line: location.line });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const unique = new Map(owners.map((owner) => [`${owner.path}\0${String(owner.line)}`, owner]));
|
|
392
|
+
|
|
393
|
+
return unique.values().toArray();
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const inferredOwner = (
|
|
397
|
+
project: CatalogProject,
|
|
398
|
+
message: SourceMessage,
|
|
399
|
+
sources: Map<string, string>,
|
|
400
|
+
): { path: string; line: number } | null => {
|
|
401
|
+
for (const [path, source] of sources) {
|
|
402
|
+
const index = source.indexOf(message.msgid);
|
|
403
|
+
|
|
404
|
+
if (index !== -1) {
|
|
405
|
+
return {
|
|
406
|
+
path: projectPath(project.root, path) ?? SYNTHETIC_FILENAME,
|
|
407
|
+
line: source.slice(0, index).split("\n").length,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return null;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const sourceOwners = (
|
|
416
|
+
project: CatalogProject,
|
|
417
|
+
message: SourceMessage,
|
|
418
|
+
sources: Map<string, string>,
|
|
419
|
+
): { path: string; line: number | undefined }[] => {
|
|
420
|
+
const located = locatedOwners(project, message);
|
|
421
|
+
|
|
422
|
+
if (located.length > 0) {
|
|
423
|
+
return located;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return [inferredOwner(project, message, sources) ?? { path: SYNTHETIC_FILENAME, line: undefined }];
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const syntheticEntries = (
|
|
430
|
+
project: CatalogProject,
|
|
431
|
+
messages: SourceMessage[],
|
|
432
|
+
sourceFiles: string[],
|
|
433
|
+
): Map<string, SyntheticEntry[]> => {
|
|
434
|
+
const sources = readSources(sourceFiles);
|
|
435
|
+
const entries: Map<string, SyntheticEntry[]> = new Map();
|
|
436
|
+
|
|
437
|
+
for (const message of messages) {
|
|
438
|
+
for (const owner of sourceOwners(project, message, sources)) {
|
|
439
|
+
const owned = entries.get(owner.path) ?? [];
|
|
440
|
+
owned.push({ call: renderCatalogCall(message), line: owner.line });
|
|
441
|
+
entries.set(owner.path, owned);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (entries.size === 0) {
|
|
446
|
+
entries.set(SYNTHETIC_FILENAME, []);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return entries;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const compareSyntheticEntries = (left: SyntheticEntry, right: SyntheticEntry): number =>
|
|
453
|
+
(left.line ?? Number.MAX_SAFE_INTEGER) - (right.line ?? Number.MAX_SAFE_INTEGER);
|
|
454
|
+
|
|
455
|
+
const lastSyntheticLine = (entries: SyntheticEntry[]): number => {
|
|
456
|
+
let line = 0;
|
|
457
|
+
|
|
458
|
+
for (const entry of entries) {
|
|
459
|
+
line = Math.max(line, entry.line ?? 0);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return line;
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const appendSyntheticEntry = (lines: string[], entry: SyntheticEntry, requestedLine: number): void => {
|
|
466
|
+
const index = requestedLine - 1;
|
|
467
|
+
const existing = lines[index];
|
|
468
|
+
|
|
469
|
+
if (existing === undefined || existing.length === 0) {
|
|
470
|
+
lines[index] = entry.call;
|
|
471
|
+
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
lines[index] = `${existing} ${entry.call}`;
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const renderSyntheticSource = (entries: SyntheticEntry[]): string => {
|
|
479
|
+
const lines: string[] = [];
|
|
480
|
+
const sorted = entries.toSorted(compareSyntheticEntries);
|
|
481
|
+
let nextLine = lastSyntheticLine(entries) + 1;
|
|
482
|
+
|
|
483
|
+
for (const entry of sorted) {
|
|
484
|
+
const requestedLine = Math.max(1, entry.line ?? nextLine++);
|
|
485
|
+
appendSyntheticEntry(lines, entry, requestedLine);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return `${lines.join("\n")}\n`;
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const writeSyntheticSources = (
|
|
492
|
+
workDir: string,
|
|
493
|
+
project: CatalogProject,
|
|
494
|
+
messages: SourceMessage[],
|
|
495
|
+
sourceFiles: string[],
|
|
496
|
+
): string => {
|
|
497
|
+
const entries = syntheticEntries(project, messages, sourceFiles);
|
|
498
|
+
const paths: string[] = [];
|
|
499
|
+
|
|
500
|
+
for (const [path, owned] of entries) {
|
|
501
|
+
const target = join(workDir, path);
|
|
502
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
503
|
+
writeFileSync(target, renderSyntheticSource(owned));
|
|
504
|
+
paths.push(path);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const potfiles = join(workDir, POTFILES_FILENAME);
|
|
508
|
+
writeFileSync(potfiles, `${paths.toSorted((left, right) => left.localeCompare(right)).join("\n")}\n`);
|
|
509
|
+
|
|
510
|
+
return potfiles;
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
const extractSourceMessages = ({ project, messages, sourceFiles, output, workDir }: SourceExtraction): void => {
|
|
514
|
+
const potfilesPath = writeSyntheticSources(workDir, project, messages, sourceFiles);
|
|
515
|
+
|
|
516
|
+
runCliTool({
|
|
517
|
+
tool: "xgettext",
|
|
518
|
+
args: [
|
|
519
|
+
"--language=JavaScript",
|
|
520
|
+
"--from-code=UTF-8",
|
|
521
|
+
"--force-po",
|
|
522
|
+
"--keyword=gettext:1",
|
|
523
|
+
"--keyword=ngettext:1,2",
|
|
524
|
+
"--keyword=pgettext:1c,2",
|
|
525
|
+
"--keyword=npgettext:1c,2,3",
|
|
526
|
+
`--directory=${workDir}`,
|
|
527
|
+
`--files-from=${potfilesPath}`,
|
|
528
|
+
`--output=${output}`,
|
|
529
|
+
],
|
|
530
|
+
target: output,
|
|
531
|
+
});
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const extractMetadataFragment = (project: CatalogProject, input: string, output: string): void => {
|
|
535
|
+
runCliTool({
|
|
536
|
+
tool: "msggrep",
|
|
537
|
+
args: [
|
|
538
|
+
"--force-po",
|
|
539
|
+
`--output-file=${output}`,
|
|
540
|
+
...metadataTemplateFiles(project).map((file) => `--location=${file.relativePath}`),
|
|
541
|
+
input,
|
|
542
|
+
],
|
|
543
|
+
target: input,
|
|
544
|
+
});
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const joinMetadataFragment = (output: string, fragment: string): void => {
|
|
548
|
+
runCliTool({
|
|
549
|
+
tool: "xgettext",
|
|
550
|
+
args: ["--language=PO", "--join-existing", "--force-po", `--output=${output}`, fragment],
|
|
551
|
+
target: output,
|
|
552
|
+
});
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
const extractCatalogTemplate = ({
|
|
556
|
+
project,
|
|
557
|
+
messages,
|
|
558
|
+
sourceFiles,
|
|
559
|
+
shouldPreserveMetadataMessages,
|
|
560
|
+
output = resolve(project.poDir, `${project.domain}.pot`),
|
|
561
|
+
}: CatalogTemplateExtraction): void => {
|
|
562
|
+
const workDir = mkdtempSync(join(project.poDir, ".gtkx-i18n-"));
|
|
563
|
+
const source = join(workDir, "source.pot");
|
|
564
|
+
const hasPreviousTemplate = existsSync(output);
|
|
565
|
+
|
|
566
|
+
try {
|
|
567
|
+
if (shouldPreserveMetadataMessages && hasPreviousTemplate) {
|
|
568
|
+
const fragment = join(workDir, "metadata.pot");
|
|
569
|
+
extractMetadataFragment(project, output, fragment);
|
|
570
|
+
extractSourceMessages({ project, messages, sourceFiles, output: source, workDir });
|
|
571
|
+
joinMetadataFragment(source, fragment);
|
|
572
|
+
} else {
|
|
573
|
+
extractSourceMessages({ project, messages, sourceFiles, output: source, workDir });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
replaceCatalogTemplate(source, output);
|
|
577
|
+
} finally {
|
|
578
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
const extractSourceCatalogTo = async (
|
|
583
|
+
project: CatalogProject,
|
|
584
|
+
paths: string[],
|
|
585
|
+
output: string,
|
|
586
|
+
): Promise<SourceCatalog> => {
|
|
587
|
+
const sourceFiles = normalizedSourceFiles(project.root, paths);
|
|
588
|
+
const messages = await findSourceMessages(sourceFiles);
|
|
589
|
+
writePotfiles(project, sourceFiles);
|
|
590
|
+
extractCatalogTemplate({ project, messages, sourceFiles, shouldPreserveMetadataMessages: false, output });
|
|
591
|
+
|
|
592
|
+
return { messages, sourceFiles };
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
const extractSourceCatalog = async (
|
|
596
|
+
project: CatalogProject,
|
|
597
|
+
paths: string[],
|
|
598
|
+
shouldPreserveMetadataMessages = true,
|
|
599
|
+
): Promise<SourceCatalog> => {
|
|
600
|
+
const sourceFiles = normalizedSourceFiles(project.root, paths);
|
|
601
|
+
const messages = await findSourceMessages(sourceFiles);
|
|
602
|
+
writePotfiles(project, sourceFiles);
|
|
603
|
+
extractCatalogTemplate({ project, messages, sourceFiles, shouldPreserveMetadataMessages });
|
|
604
|
+
|
|
605
|
+
return { messages, sourceFiles };
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
export { type SourceMessage, extractSourceCatalog, extractSourceCatalogTo };
|