PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 All 129 releases
wdesignkit / bin / generate-i18n-strings.js

generate-i18n-strings.js in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.0, at bin/generate-i18n-strings.js

212 lines 7.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Generate build/wdk-i18n-strings.js — a small, readable catalog of every
3 * translatable JS string in src/.
4 *
5 * Why this exists
6 * ---------------
7 * The production app ships as a large minified bundle (build/index.min.js).
8 * WordPress.org generates the translation template with `wp i18n make-pot`,
9 * whose JS parser (Peast) exhausts its memory/time budget on a multi-megabyte
10 * minified file — so no JS strings ever reach translate.wordpress.org.
11 *
12 * `make-pot` skips `*.min.js` files, so it never parses the bundle. Instead it
13 * parses this generated catalog, which contains the SAME `__()` calls found in
14 * src/ (readable, parses in well under a second, a few MB of RAM — succeeds at
15 * any memory limit). The file is also enqueued and registered with
16 * wp_set_script_translations(), so the resulting per-locale JSON carries every
17 * string into the shared wp.i18n store at runtime — meaning the whole React app
18 * is translated even though its own bundle isn't scanned.
19 *
20 * Runs automatically after `wp-scripts build` (see the "build" script in
21 * package.json). Node-only; uses the @babel/* packages provided by
22 * @wordpress/scripts.
23 */
24
25 const fs = require('fs');
26 const path = require('path');
27 const parser = require('@babel/parser');
28 const traverseModule = require('@babel/traverse');
29 const traverse = traverseModule.default || traverseModule;
30
31 const ROOT = path.resolve(__dirname, '..');
32 const SRC_DIR = path.join(ROOT, 'src');
33 const OUT_FILE = path.join(ROOT, 'build', 'wdk-i18n-strings.js');
34 const TEXT_DOMAIN = 'wdesignkit';
35
36 // @wordpress/i18n translation functions and the index of their message/context args.
37 // We only emit calls whose message argument(s) are static strings — that is
38 // exactly what make-pot can extract, and it keeps the generated file runtime-safe.
39 const I18N_FUNCTIONS = {
40 __: { messageArgs: [0] },
41 _e: { messageArgs: [0] },
42 _x: { messageArgs: [0], contextArg: 1 },
43 _ex: { messageArgs: [0], contextArg: 1 },
44 _n: { messageArgs: [0, 1] },
45 _nx: { messageArgs: [0, 1], contextArg: 3 },
46 };
47
48 /** Recursively collect .js/.jsx/.ts/.tsx files under a directory. */
49 function collectFiles(dir, out = []) {
50 for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
51 const full = path.join(dir, entry.name);
52 if (entry.isDirectory()) {
53 collectFiles(full, out);
54 } else if (/\.(jsx?|tsx?)$/.test(entry.name)) {
55 out.push(full);
56 }
57 }
58 return out;
59 }
60
61 /**
62 * Static string value of a node, or null if not a plain string. Handles string
63 * literals and template literals with no `${}` substitutions (make-pot extracts
64 * both; anything dynamic it — and we — ignore).
65 */
66 function stringValue(node) {
67 if (!node) {
68 return null;
69 }
70 if (node.type === 'StringLiteral') {
71 return node.value;
72 }
73 if (node.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length === 1) {
74 return node.quasis[0].value.cooked;
75 }
76 return null;
77 }
78
79 const seen = new Map();
80 const entries = [];
81
82 /**
83 * Translator comment for a gettext call, or null.
84 *
85 * `make-pot` only honours a `translators:` comment that sits immediately before
86 * the call, so a string carrying a placeholder ( %s, %1$s ) warns without one.
87 * In src/ the comment is usually attached to the `__()` argument of a wrapping
88 * `sprintf()`, so walk a few parents up as well before giving up.
89 */
90 function translatorComment(pathNode) {
91 let current = pathNode;
92 for (let depth = 0; current && depth < 3; depth++, current = current.parentPath) {
93 const comments = current.node.leadingComments || [];
94 for (let i = comments.length - 1; i >= 0; i--) {
95 const text = comments[i].value.replace(/\s+/g, ' ').trim();
96 if (/^translators:/i.test(text)) {
97 // `*/` inside the value would close the emitted block comment early.
98 return text.replace(/\*\//g, '*\\/');
99 }
100 }
101 }
102 return null;
103 }
104
105 function record(fnName, spec, args, comment) {
106 const messages = [];
107 for (const idx of spec.messageArgs) {
108 const value = stringValue(args[idx]);
109 if (value === null) {
110 return;
111 }
112 messages.push(value);
113 }
114 // Skip empty / whitespace-only msgids: an empty msgid returns the PO header in
115 // gettext, and a blank string is never a real translatable message. (Padded
116 // strings with real content, e.g. " Create ", are kept so the runtime lookup
117 // still matches the app's __() call.)
118 if (messages[0].trim() === '') {
119 return;
120 }
121 let context = null;
122 if (spec.contextArg != null) {
123 context = stringValue(args[spec.contextArg]);
124 if (context === null) {
125 return;
126 }
127 }
128
129 const key = fnName + '' + messages.join('') + '' + (context || '');
130 if (seen.has(key)) {
131 // A later occurrence carrying a translator comment upgrades an earlier one
132 // that had none — the comment is what silences make-pot's warning.
133 const existing = seen.get(key);
134 if (comment && !existing.comment) {
135 existing.comment = comment;
136 }
137 return;
138 }
139
140 // Normalise echo/context/plural variants to their return-value equivalents
141 // (__ / _x / _n / _nx) so the catalog is valid, runtime-safe and complete.
142 const q = (s) => JSON.stringify(s);
143 let call;
144 if (fnName === '_n' || fnName === '_nx') {
145 call = `${fnName === '_nx' ? '_nx' : '_n'}( ${q(messages[0])}, ${q(messages[1])}, 1${
146 context != null ? `, ${q(context)}` : ''
147 }, ${q(TEXT_DOMAIN)} )`;
148 } else if (context != null) {
149 call = `_x( ${q(messages[0])}, ${q(context)}, ${q(TEXT_DOMAIN)} )`;
150 } else {
151 call = `__( ${q(messages[0])}, ${q(TEXT_DOMAIN)} )`;
152 }
153 const entry = { call, comment: comment || null };
154 seen.set(key, entry);
155 entries.push(entry);
156 }
157
158 const files = collectFiles(SRC_DIR);
159 for (const file of files) {
160 const code = fs.readFileSync(file, 'utf8');
161 let ast;
162 try {
163 ast = parser.parse(code, {
164 sourceType: 'module',
165 plugins: ['jsx', 'classProperties', 'objectRestSpread', 'dynamicImport', 'optionalChaining', 'nullishCoalescingOperator'],
166 });
167 } catch (e) {
168 console.warn(`[i18n] skipped unparseable file ${path.relative(ROOT, file)}: ${e.message}`);
169 continue;
170 }
171 traverse(ast, {
172 CallExpression(pathNode) {
173 const callee = pathNode.node.callee;
174 if (callee.type !== 'Identifier') {
175 return;
176 }
177 const spec = I18N_FUNCTIONS[callee.name];
178 if (!spec) {
179 return;
180 }
181 record(callee.name, spec, pathNode.node.arguments, translatorComment(pathNode));
182 },
183 });
184 }
185
186 entries.sort((a, b) => (a.call < b.call ? -1 : a.call > b.call ? 1 : 0));
187
188 const header = `/**
189 * WDesignKit — auto-generated translatable-string catalog. DO NOT EDIT.
190 *
191 * Generated from src/ by bin/generate-i18n-strings.js. Exists so WordPress.org
192 * can build the translation template (\`wp i18n make-pot\`) from this small,
193 * readable file instead of the large minified bundle (which it skips because it
194 * is named *.min.js). Also loads the JS translations at runtime via
195 * wp_set_script_translations().
196 *
197 * ${entries.length} strings.
198 */
199 `;
200
201 const body = `( function () {
202 var i18n = ( typeof window !== 'undefined' && window.wp && window.wp.i18n ) ? window.wp.i18n : null;
203 if ( ! i18n ) { return; }
204 var __ = i18n.__, _x = i18n._x, _n = i18n._n, _nx = i18n._nx;
205 ${entries.map((e) => (e.comment ? `\t/* ${e.comment} */\n` : '') + `\t${e.call};`).join('\n')}
206 } )();
207 `;
208
209 fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
210 fs.writeFileSync(OUT_FILE, header + '\n' + body, 'utf8');
211 console.log(`[i18n] wrote ${path.relative(ROOT, OUT_FILE)} with ${entries.length} strings from ${files.length} source files.`);
212