@gjsify/vite-plugin-gettext 0.2.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/src/utils.ts ADDED
@@ -0,0 +1,119 @@
1
+ import { execa } from "execa";
2
+ import path from "node:path";
3
+ import fs from "node:fs/promises";
4
+
5
+ /**
6
+ * Checks if a gettext utility is installed and available
7
+ * @param command The command to check (msgfmt, xgettext, etc.)
8
+ * @param pluginName Name of the plugin for logging
9
+ * @param verbose Enable verbose logging
10
+ * @throws Error if the command is not found
11
+ */
12
+ export async function checkDependencies(
13
+ command: string,
14
+ pluginName: string,
15
+ verbose: boolean
16
+ ) {
17
+ try {
18
+ await execa(command, ["--version"]);
19
+ if (verbose) {
20
+ console.log(`[${pluginName}] Found ${command}`);
21
+ }
22
+ } catch (error) {
23
+ throw new Error(
24
+ `${command} not found. Please install gettext:\n` +
25
+ " Ubuntu/Debian: sudo apt-get install gettext\n" +
26
+ " Fedora: sudo dnf install gettext\n" +
27
+ " Arch: sudo pacman -S gettext\n" +
28
+ " macOS: brew install gettext"
29
+ );
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Scans the PO directory to find available language translations
35
+ * @param poDirectory Directory containing PO files
36
+ * @param pluginName Name of the plugin for logging
37
+ * @param verbose Enable verbose logging
38
+ * @returns Array of language codes found (e.g. ['de', 'fr', 'es'])
39
+ */
40
+ export async function findAvailableLanguages(
41
+ poDirectory: string,
42
+ pluginName: string,
43
+ verbose: boolean
44
+ ): Promise<string[]> {
45
+ try {
46
+ const files = await fs.readdir(poDirectory);
47
+ const languages = files
48
+ .filter((file) => file.endsWith(".po"))
49
+ .map((file) => path.basename(file, ".po"));
50
+
51
+ if (verbose) {
52
+ console.log(`[${pluginName}] Found languages: ${languages.join(", ")}`);
53
+ }
54
+
55
+ return languages;
56
+ } catch (error) {
57
+ if (verbose) {
58
+ console.log(`[${pluginName}] No PO directory found at ${poDirectory}`);
59
+ }
60
+ return [];
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Generates a LINGUAS file containing the list of available languages
66
+ * @param languages List of language codes
67
+ * @param poDirectory Directory where the LINGUAS file should be created
68
+ * @param verbose Enable verbose logging
69
+ */
70
+ export async function generateLinguasFile(
71
+ languages: string[],
72
+ poDirectory: string,
73
+ verbose = false
74
+ ) {
75
+ const linguasPath = path.join(poDirectory, "LINGUAS");
76
+ const content = languages.join("\n");
77
+
78
+ try {
79
+ await fs.writeFile(linguasPath, content);
80
+ if (verbose) {
81
+ console.log(
82
+ `Generated LINGUAS file with languages: ${languages.join(", ")}`
83
+ );
84
+ }
85
+ } catch (error) {
86
+ console.error("Error writing LINGUAS file:", error);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Creates directory structure recursively
92
+ * @param directory Directory path to create
93
+ */
94
+ export async function ensureDirectory(directory: string): Promise<void> {
95
+ await fs.mkdir(directory, { recursive: true });
96
+ }
97
+
98
+ /**
99
+ * Processes a filename with potential .in suffix
100
+ * @param filePath Original file path or filename
101
+ * @returns Object with processed filename and extension
102
+ */
103
+ export function processFilename(filePath: string): {
104
+ filename: string;
105
+ extension: string;
106
+ } {
107
+ // Extract just the filename if a path is provided
108
+ const filename = path.basename(filePath);
109
+ let extension = path.extname(filename).toLowerCase();
110
+ let processedFilename = filename;
111
+
112
+ // Handle .in extension
113
+ if (filename.endsWith(".in")) {
114
+ processedFilename = filename.substring(0, filename.length - 3);
115
+ extension = path.extname(processedFilename).toLowerCase();
116
+ }
117
+
118
+ return { filename: processedFilename, extension };
119
+ }
@@ -0,0 +1,337 @@
1
+ import { type Plugin } from "vite";
2
+ import { execa } from "execa";
3
+ import path from "node:path";
4
+ import fs from "node:fs/promises";
5
+ import { existsSync } from "node:fs";
6
+ import glob from "fast-glob";
7
+ import type { XGettextPluginOptions } from "./types.js";
8
+ import {
9
+ checkDependencies,
10
+ ensureDirectory,
11
+ processFilename,
12
+ } from "./utils.js";
13
+
14
+ // Add GLib preset constants
15
+ // From https://github.com/mesonbuild/meson/blob/467da051c859ba3112803b035e317bddadd756ef/mesonbuild/modules/i18n.py
16
+ const GLIB_PRESET_ARGS = [
17
+ "--from-code=UTF-8",
18
+ "--add-comments",
19
+ // https://developer.gnome.org/glib/stable/glib-I18N.html
20
+ "--keyword=_",
21
+ "--keyword=N_",
22
+ "--keyword=C_:1c,2",
23
+ "--keyword=NC_:1c,2",
24
+ "--keyword=g_dcgettext:2",
25
+ "--keyword=g_dngettext:2,3",
26
+ "--keyword=g_dpgettext2:2c,3",
27
+ "--flag=N_:1:pass-c-format",
28
+ "--flag=C_:2:pass-c-format",
29
+ "--flag=NC_:2:pass-c-format",
30
+ "--flag=g_dngettext:2:pass-c-format",
31
+ "--flag=g_strdup_printf:1:c-format",
32
+ "--flag=g_string_printf:2:c-format",
33
+ "--flag=g_string_append_printf:2:c-format",
34
+ "--flag=g_error_new:3:c-format",
35
+ "--flag=g_set_error:4:c-format",
36
+ "--flag=g_markup_printf_escaped:1:c-format",
37
+ "--flag=g_log:3:c-format",
38
+ "--flag=g_print:1:c-format",
39
+ "--flag=g_printerr:1:c-format",
40
+ "--flag=g_printf:1:c-format",
41
+ "--flag=g_fprintf:2:c-format",
42
+ "--flag=g_sprintf:2:c-format",
43
+ "--flag=g_snprintf:3:c-format",
44
+ ];
45
+
46
+ /**
47
+ * Creates a Vite plugin that extracts translatable strings from source files
48
+ * Uses GNU xgettext to generate a POT template file that can be used as basis for translations
49
+ * @param options Configuration options for the plugin
50
+ * @returns A Vite plugin that handles string extraction
51
+ */
52
+ export function xgettextPlugin(options: XGettextPluginOptions): Plugin {
53
+ const pluginName = "vite-plugin-xgettext";
54
+
55
+ return {
56
+ name: pluginName,
57
+
58
+ async buildStart() {
59
+ await checkDependencies("xgettext", pluginName, options.verbose ?? false);
60
+ const files = await glob(options.sources);
61
+ await extractStrings(files, options, pluginName);
62
+ },
63
+
64
+ configureServer(server) {
65
+ server.watcher.add(options.sources);
66
+
67
+ server.watcher.on("change", async (file) => {
68
+ if (options.sources.some((pattern) => file.match(pattern))) {
69
+ if (options.verbose) {
70
+ console.log(
71
+ `[${pluginName}] Source file changed: ${file}, re-running extraction`
72
+ );
73
+ }
74
+ const files = await glob(options.sources);
75
+ await extractStrings(files, options, pluginName);
76
+ }
77
+ });
78
+ },
79
+ };
80
+ }
81
+
82
+ async function generatePotfiles(
83
+ files: string[],
84
+ outputDir: string,
85
+ pluginName: string,
86
+ verbose = false
87
+ ) {
88
+ // Group files by extension
89
+ const fileGroups = new Map<string, string[]>();
90
+
91
+ files.forEach((file) => {
92
+ const filename = path.basename(file);
93
+ const group = getFileGroup(filename);
94
+ if (!fileGroups.has(group)) {
95
+ fileGroups.set(group, []);
96
+ }
97
+ fileGroups.get(group)?.push(file);
98
+ });
99
+
100
+ // Generate POTFILES for each group
101
+ const potFiles: string[] = [];
102
+
103
+ for (const [group, groupFiles] of fileGroups) {
104
+ const potfilePath = path.join(outputDir, `${group}.POTFILES`);
105
+ const content = groupFiles.join("\n");
106
+
107
+ try {
108
+ await fs.writeFile(potfilePath, content);
109
+ potFiles.push(potfilePath);
110
+ if (verbose) {
111
+ console.log(
112
+ `[${pluginName}] Generated ${group}.POTFILES with ${groupFiles.length} source files`
113
+ );
114
+ }
115
+ } catch (error) {
116
+ console.error(`[${pluginName}] Error writing ${group}.POTFILES:`, error);
117
+ }
118
+ }
119
+
120
+ return potFiles;
121
+ }
122
+
123
+ function getFileGroup(fullFilename: string): string {
124
+ // Process filename to handle .in extension
125
+ const { filename, extension } = processFilename(fullFilename);
126
+
127
+ // Special handling for metainfo.xml files
128
+ if (filename.endsWith(".metainfo.xml") || filename.endsWith(".appdata.xml")) {
129
+ return "metainfo";
130
+ }
131
+
132
+ switch (extension) {
133
+ case ".ts":
134
+ case ".js":
135
+ case ".tsx":
136
+ return "js";
137
+ case ".ui":
138
+ case ".xml":
139
+ return "ui";
140
+ case ".blp":
141
+ return "blp";
142
+ case ".desktop":
143
+ return "desktop";
144
+ default:
145
+ return "other";
146
+ }
147
+ }
148
+
149
+ async function extractStrings(
150
+ files: string[],
151
+ options: XGettextPluginOptions,
152
+ pluginName: string
153
+ ) {
154
+ const {
155
+ output,
156
+ domain = "messages",
157
+ keywords = [],
158
+ preset,
159
+ verbose = false,
160
+ } = options;
161
+
162
+ try {
163
+ const outputDir = path.dirname(output);
164
+ await ensureDirectory(outputDir);
165
+
166
+ // Generate grouped POTFILES
167
+ const potFiles = await generatePotfiles(
168
+ files,
169
+ outputDir,
170
+ pluginName,
171
+ verbose
172
+ );
173
+
174
+ // Create temporary POT files for each group
175
+ const tempPotFiles: string[] = [];
176
+
177
+ for (const potFile of potFiles) {
178
+ const group = path.basename(potFile).split(".")[0];
179
+ const tempOutput = path.join(outputDir, `temp_${group}.pot`);
180
+
181
+ // Base arguments
182
+ let args = [
183
+ "--package-name=" + domain,
184
+ options.version ? "--package-version=" + options.version : "",
185
+ "--output=" + tempOutput,
186
+ "--files-from=" + potFile,
187
+ "--from-code=UTF-8",
188
+ "--add-comments",
189
+ ];
190
+
191
+ // Add bug report address if specified
192
+ if (options.msgidBugsAddress) {
193
+ args.push("--msgid-bugs-address=" + options.msgidBugsAddress);
194
+ }
195
+
196
+ // Add copyright holder if specified
197
+ if (options.copyrightHolder) {
198
+ args.push("--copyright-holder=" + options.copyrightHolder);
199
+ }
200
+
201
+ // Add language-specific settings
202
+ switch (group) {
203
+ case "js":
204
+ case "blp":
205
+ args.push("--language=JavaScript");
206
+ args.push(...keywords.map((k) => `--keyword=${k}`));
207
+ if (preset === "glib") {
208
+ args.push(...GLIB_PRESET_ARGS);
209
+ }
210
+ break;
211
+ case "ui":
212
+ args.push("--language=Glade");
213
+ break;
214
+ case "metainfo":
215
+ // Find the first existing metainfo.its file
216
+ const metainfoItsPath = await findMetainfoItsPath();
217
+
218
+ if (!metainfoItsPath) {
219
+ console.warn(
220
+ "Warning: Could not find metainfo.its in any of the expected locations"
221
+ );
222
+ // Continue without the ITS file
223
+ } else {
224
+ args.push(`--its=${metainfoItsPath}`);
225
+ }
226
+ break;
227
+ case "desktop":
228
+ args.push("--language=Desktop");
229
+ break;
230
+ }
231
+
232
+ if (verbose) {
233
+ console.log(
234
+ `[${pluginName}] Running xgettext for ${group}:`,
235
+ args.join(" ")
236
+ );
237
+ }
238
+
239
+ await execa("xgettext", args);
240
+
241
+ // Check if file exists before adding to tempPotFiles
242
+ try {
243
+ await fs.access(tempOutput);
244
+ tempPotFiles.push(tempOutput);
245
+ if (verbose) {
246
+ console.log(
247
+ `[${pluginName}] Successfully created temporary POT file: ${tempOutput}`
248
+ );
249
+ }
250
+ } catch (error) {
251
+ console.warn(
252
+ `[${pluginName}] Failed to create temporary POT file: ${tempOutput}`
253
+ );
254
+ }
255
+ }
256
+
257
+ // Combine all temporary POT files using msgcat
258
+ if (tempPotFiles.length > 0) {
259
+ const msgcatArgs = ["--use-first", "-o", output, ...tempPotFiles];
260
+ await execa("msgcat", msgcatArgs);
261
+
262
+ // Clean up temporary files
263
+ for (const tempFile of tempPotFiles) {
264
+ await fs.unlink(tempFile);
265
+ }
266
+ for (const potFile of potFiles) {
267
+ await fs.unlink(potFile);
268
+ }
269
+ }
270
+
271
+ if (options.autoUpdatePo) {
272
+ await updatePoFiles(options.output, pluginName, options.verbose || false);
273
+ }
274
+ } catch (error) {
275
+ throw new Error(`Failed to extract translations: ${error}`);
276
+ }
277
+ }
278
+
279
+ async function updatePoFiles(
280
+ potFile: string,
281
+ pluginName: string,
282
+ verbose: boolean
283
+ ) {
284
+ try {
285
+ const linguasPath = path.join(path.dirname(potFile), "LINGUAS");
286
+ const languages = (await fs.readFile(linguasPath, "utf-8"))
287
+ .split("\n")
288
+ .filter(Boolean);
289
+
290
+ for (const lang of languages) {
291
+ const poFile = path.join(path.dirname(potFile), `${lang}.po`);
292
+ if (verbose) {
293
+ console.log(`[${pluginName}] Updating ${poFile}`);
294
+ }
295
+ await execa("msgmerge", ["--update", "--backup=none", poFile, potFile]);
296
+ }
297
+ } catch (error) {
298
+ console.error(`[${pluginName}] Error updating PO files:`, error);
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Finds the first existing metainfo.its file from installed gettext versions
304
+ * @returns The path to the metainfo.its file if found, otherwise undefined
305
+ */
306
+ async function findMetainfoItsPath(): Promise<string | undefined> {
307
+ // Default path
308
+ const defaultPath = "/usr/share/gettext/its/metainfo.its";
309
+
310
+ // Check default path first
311
+ if (existsSync(defaultPath)) {
312
+ return defaultPath;
313
+ }
314
+
315
+ try {
316
+ // Use glob to find all potential gettext version directories
317
+ const getTextDirs = await glob("/usr/share/gettext-*");
318
+
319
+ // Sort by version (newest first) if possible
320
+ getTextDirs.sort((a, b) => {
321
+ const versionA = a.replace("/usr/share/gettext-", "");
322
+ const versionB = b.replace("/usr/share/gettext-", "");
323
+ return versionB.localeCompare(versionA);
324
+ });
325
+
326
+ // Add specific version paths we know about
327
+ const metainfoItsPaths = getTextDirs.map(
328
+ (dir) => `${dir}/its/metainfo.its`
329
+ );
330
+
331
+ // Find first existing path
332
+ return metainfoItsPaths.find((path) => existsSync(path));
333
+ } catch (error) {
334
+ console.warn("Error searching for metainfo.its:", error);
335
+ return undefined;
336
+ }
337
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "preserveWatchOutput": true,
4
+ "lib": ["ESNext"],
5
+ "types": ["node"],
6
+ "outDir": "./dist",
7
+ "module": "NodeNext",
8
+ "target": "ESNext",
9
+ "moduleResolution": "NodeNext",
10
+ "strict": true,
11
+ "esModuleInterop": true,
12
+ "skipLibCheck": true,
13
+ "forceConsistentCasingInFileNames": true,
14
+ "declaration": true,
15
+ "sourceMap": true,
16
+ "diagnostics": true,
17
+ "verbatimModuleSyntax": true,
18
+ "isolatedModules": true
19
+ },
20
+ "include": ["src/**/*.ts"],
21
+ "exclude": ["node_modules", "dist"]
22
+ }