@npm-questionpro/wick-ui-i18n 0.8.0 → 0.9.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/index.js +71 -166
- package/package.json +1 -1
- package/src/debug.js +80 -0
- package/src/processor.js +153 -0
- package/src/transform.js +178 -0
- package/wickuii18n.test.js +118 -8
package/index.js
CHANGED
|
@@ -1,181 +1,83 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"WuMenuIcon",
|
|
21
|
-
"WuScrollArea",
|
|
22
|
-
"WuDrawer",
|
|
23
|
-
"WuLoader",
|
|
24
|
-
"WuContentEditor",
|
|
25
|
-
].concat(options.ignoreComponents || []),
|
|
26
|
-
);
|
|
27
|
-
this.dictionary = new Map();
|
|
28
|
-
this.debugEnabled = options.debug || false;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
log(...args) {
|
|
32
|
-
if (this.debugEnabled) console.log("[wick-i18n]", ...args);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
record(key, text, file) {
|
|
36
|
-
if (this.dictionary.has(key) && this.dictionary.get(key) !== text) {
|
|
37
|
-
console.warn(
|
|
38
|
-
`[wick-i18n] Collision in ${file}\nKey: "${key}"\nNew: "${text}"`,
|
|
39
|
-
);
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
this.dictionary.set(key, text);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
shouldTranslate(path) {
|
|
46
|
-
let isIgnored = false;
|
|
47
|
-
let targetFound = false;
|
|
48
|
-
|
|
49
|
-
path.findParent((p) => {
|
|
50
|
-
if (!p.isJSXElement()) return false;
|
|
51
|
-
|
|
52
|
-
const name =
|
|
53
|
-
p.node.openingElement.name.name ||
|
|
54
|
-
p.node.openingElement.name.property?.name;
|
|
55
|
-
const attrs = p.node.openingElement.attributes || [];
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
attrs.some((a) =>
|
|
59
|
-
["data-skip", "data-i18n-skip"].includes(a.name?.name),
|
|
60
|
-
)
|
|
61
|
-
) {
|
|
62
|
-
isIgnored = true;
|
|
63
|
-
return true;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (this.ignoreComponents.has(name)) {
|
|
67
|
-
isIgnored = true;
|
|
68
|
-
return true;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const hasWrapper = attrs.some(
|
|
72
|
-
(a) => a.name?.name === "data-i18n-wrapper",
|
|
73
|
-
);
|
|
74
|
-
const isTarget = this.components.has(name) || name?.startsWith("Wu");
|
|
75
|
-
// const isTarget = this.components.has(name);
|
|
76
|
-
|
|
77
|
-
if (hasWrapper || isTarget) {
|
|
78
|
-
targetFound = true;
|
|
79
|
-
return true;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
return false;
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
return targetFound && !isIgnored;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
getExplicitKey(path) {
|
|
89
|
-
const parent = path.findParent((p) => p.isJSXElement());
|
|
90
|
-
const attr = parent?.node.openingElement.attributes.find(
|
|
91
|
-
(a) => a.name?.name === "data-i18n-key",
|
|
92
|
-
);
|
|
93
|
-
if (!attr) return null;
|
|
94
|
-
return attr.value.type === "StringLiteral"
|
|
95
|
-
? attr.value.value
|
|
96
|
-
: attr.value.expression?.value;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview wick-ui-i18n — Vite plugin that automatically wraps static JSX
|
|
3
|
+
* text inside Wick UI components with `<WuTranslate>` at build/dev time and
|
|
4
|
+
* emits a `wick-ui-i18n.json` translation dictionary.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* // vite.config.js
|
|
8
|
+
* import wickI18n from '@npm-questionpro/wick-ui-i18n';
|
|
9
|
+
*
|
|
10
|
+
* export default {
|
|
11
|
+
* plugins: [
|
|
12
|
+
* wickI18n({
|
|
13
|
+
* components: ['MyWidget'],
|
|
14
|
+
* ignoreComponents: ['MyRawHtml'],
|
|
15
|
+
* debug: true,
|
|
16
|
+
* }),
|
|
17
|
+
* ],
|
|
18
|
+
* };
|
|
19
|
+
*/
|
|
99
20
|
|
|
21
|
+
import { createFilter } from "vite";
|
|
22
|
+
import { TranslationProcessor } from "./src/processor.js";
|
|
23
|
+
import { transformFile } from "./src/transform.js";
|
|
24
|
+
import { printReport } from "./src/debug.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {object} WickI18nOptions
|
|
28
|
+
* @property {string[]} [components] - Extra component names that trigger translation.
|
|
29
|
+
* @property {string[]} [ignoreComponents] - Component names to exclude from translation.
|
|
30
|
+
* @property {string|string[]|RegExp} [excludeFiles] - Files to skip (passed as `exclude` to Vite's createFilter).
|
|
31
|
+
* @property {boolean} [debug] - Log transform activity to the console.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Vite plugin factory for wick-ui-i18n.
|
|
36
|
+
*
|
|
37
|
+
* @param {WickI18nOptions} [options]
|
|
38
|
+
* @returns {import('vite').Plugin}
|
|
39
|
+
*/
|
|
100
40
|
export default function wickuiI18nPlugin(options = {}) {
|
|
101
|
-
const processor = new TranslationProcessor(
|
|
102
|
-
|
|
41
|
+
const processor = new TranslationProcessor({
|
|
42
|
+
components: options.components || [],
|
|
43
|
+
ignoreComponents: options.ignoreComponents,
|
|
44
|
+
debug: options.debug,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const filter = createFilter([/\.(jsx|tsx)$/], options.excludeFiles);
|
|
103
48
|
|
|
104
49
|
return {
|
|
105
50
|
name: "wick-ui-i18n",
|
|
106
51
|
enforce: "pre",
|
|
107
52
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
sourceType: "module",
|
|
113
|
-
plugins: ["jsx", "typescript"],
|
|
114
|
-
});
|
|
115
|
-
const ms = new MagicString(code);
|
|
116
|
-
let [needsImport, hasImport] = [false, false];
|
|
117
|
-
|
|
118
|
-
const handleCapture = (path, text, start, end) => {
|
|
119
|
-
const cleanText = text.trim();
|
|
120
|
-
if (!cleanText || !processor.shouldTranslate(path)) return;
|
|
121
|
-
|
|
122
|
-
const key = processor.getExplicitKey(path) || cleanText;
|
|
123
|
-
processor.record(key, cleanText, id);
|
|
124
|
-
|
|
125
|
-
const original = ms.slice(start, end);
|
|
126
|
-
ms.overwrite(
|
|
127
|
-
start,
|
|
128
|
-
end,
|
|
129
|
-
`<WuTranslate __i18nKey=${JSON.stringify(key)}></WuTranslate>`,
|
|
130
|
-
);
|
|
131
|
-
needsImport = true;
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
traverse(ast, {
|
|
135
|
-
ImportDeclaration(path) {
|
|
136
|
-
if (path.node.source.value.includes("wick-ui-lib")) {
|
|
137
|
-
hasImport = path.node.specifiers.some(
|
|
138
|
-
(s) => s.imported?.name === "WuTranslate",
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
},
|
|
142
|
-
JSXText(path) {
|
|
143
|
-
const text = path.node.value;
|
|
144
|
-
const trimmed = text.trim();
|
|
145
|
-
const start = path.node.start + text.indexOf(trimmed);
|
|
146
|
-
handleCapture(path, trimmed, start, start + trimmed.length);
|
|
147
|
-
},
|
|
148
|
-
JSXExpressionContainer(path) {
|
|
149
|
-
const expr = path.node.expression;
|
|
150
|
-
let text = null;
|
|
151
|
-
if (expr.type === "StringLiteral") text = expr.value;
|
|
152
|
-
else if (
|
|
153
|
-
expr.type === "TemplateLiteral" &&
|
|
154
|
-
!expr.expressions.length
|
|
155
|
-
) {
|
|
156
|
-
text = expr.quasis[0].value.cooked;
|
|
157
|
-
}
|
|
158
|
-
if (text) handleCapture(path, text, path.node.start, path.node.end);
|
|
159
|
-
},
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
if (needsImport && !hasImport) {
|
|
163
|
-
ms.prepend(
|
|
164
|
-
`import { WuTranslate } from '@npm-questionpro/wick-ui-lib';\n`,
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return needsImport
|
|
169
|
-
? { code: ms.toString(), map: ms.generateMap({ hires: true }) }
|
|
170
|
-
: null;
|
|
171
|
-
},
|
|
172
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Clear state on every build so stale keys don't accumulate across
|
|
55
|
+
* watch-mode rebuilds.
|
|
56
|
+
*/
|
|
173
57
|
buildStart() {
|
|
174
58
|
processor.dictionary.clear();
|
|
59
|
+
processor.entries = [];
|
|
175
60
|
},
|
|
176
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Transform a single file: skip fast if it has no "Wu" tokens, then run
|
|
64
|
+
* the full AST rewrite.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} code
|
|
67
|
+
* @param {string} id
|
|
68
|
+
*/
|
|
69
|
+
transform(code, id) {
|
|
70
|
+
if (!filter(id) || !code.includes("Wu")) return null;
|
|
71
|
+
return transformFile(code, id, processor);
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Expose the collected dictionary at `GET /wick-ui-i18n.json` during dev.
|
|
76
|
+
*
|
|
77
|
+
* @param {import('vite').ViteDevServer} server
|
|
78
|
+
*/
|
|
177
79
|
configureServer(server) {
|
|
178
|
-
server.middlewares.use("/wick-ui-i18n.json", (
|
|
80
|
+
server.middlewares.use("/wick-ui-i18n.json", (_req, res) => {
|
|
179
81
|
res.setHeader("Content-Type", "application/json");
|
|
180
82
|
res.end(
|
|
181
83
|
JSON.stringify(Object.fromEntries(processor.dictionary), null, 2),
|
|
@@ -183,6 +85,7 @@ export default function wickuiI18nPlugin(options = {}) {
|
|
|
183
85
|
});
|
|
184
86
|
},
|
|
185
87
|
|
|
88
|
+
/** Emit the translation dictionary as a build asset and print debug table. */
|
|
186
89
|
generateBundle() {
|
|
187
90
|
this.emitFile({
|
|
188
91
|
type: "asset",
|
|
@@ -193,6 +96,8 @@ export default function wickuiI18nPlugin(options = {}) {
|
|
|
193
96
|
2,
|
|
194
97
|
),
|
|
195
98
|
});
|
|
99
|
+
|
|
100
|
+
printReport(processor.entries);
|
|
196
101
|
},
|
|
197
102
|
};
|
|
198
103
|
}
|
package/package.json
CHANGED
package/src/debug.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Build-time debug reporter — prints a formatted table of every
|
|
3
|
+
* captured translation entry (text, source file, JSX component tree) to stdout
|
|
4
|
+
* after the bundle is generated.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { basename } from "node:path";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Walk up the Babel path and collect JSX element names from outermost inward.
|
|
11
|
+
*
|
|
12
|
+
* @param {import('@babel/traverse').NodePath} path
|
|
13
|
+
* @returns {string} e.g. `"WuProvider > div > WuButton"`
|
|
14
|
+
*/
|
|
15
|
+
export function getComponentTree(path) {
|
|
16
|
+
const parts = [];
|
|
17
|
+
path.findParent((p) => {
|
|
18
|
+
if (p.isJSXElement()) {
|
|
19
|
+
const name =
|
|
20
|
+
p.node.openingElement.name.name ||
|
|
21
|
+
p.node.openingElement.name.property?.name;
|
|
22
|
+
if (name) parts.unshift(name);
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
});
|
|
26
|
+
return parts.join(" > ") || "(root)";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} DebugEntry
|
|
31
|
+
* @property {string} key - i18n key used in WuTranslate.
|
|
32
|
+
* @property {string} text - Original source text.
|
|
33
|
+
* @property {string} file - Absolute path of the source file.
|
|
34
|
+
* @property {string} componentTree - Ancestor JSX chain, outermost first.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Print a box-drawing ASCII table of all captured translation entries.
|
|
39
|
+
* Columns are auto-sized to content.
|
|
40
|
+
*
|
|
41
|
+
* @param {DebugEntry[]} entries
|
|
42
|
+
*/
|
|
43
|
+
export function printReport(entries) {
|
|
44
|
+
if (!entries.length) {
|
|
45
|
+
console.log("\n[wick-i18n] No translations captured.\n");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const headers = ["Text", "File", "Component Tree"];
|
|
50
|
+
|
|
51
|
+
const rows = entries.map((e) => [e.text, basename(e.file), e.componentTree]);
|
|
52
|
+
|
|
53
|
+
const cols = headers.length;
|
|
54
|
+
const widths = headers.map((h, i) =>
|
|
55
|
+
Math.max(h.length, ...rows.map((r) => r[i].length)),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const pad = (str, w) => str.padEnd(w);
|
|
59
|
+
const sep = (l, m, r, fill) =>
|
|
60
|
+
l + widths.map((w) => fill.repeat(w + 2)).join(m) + r;
|
|
61
|
+
|
|
62
|
+
const top = sep("┌", "┬", "┐", "─");
|
|
63
|
+
const mid = sep("├", "┼", "┤", "─");
|
|
64
|
+
const bottom = sep("└", "┴", "┘", "─");
|
|
65
|
+
const row = (cells) =>
|
|
66
|
+
"│" + cells.map((c, i) => ` ${pad(c, widths[i])} `).join("│") + "│";
|
|
67
|
+
|
|
68
|
+
const lines = [
|
|
69
|
+
"",
|
|
70
|
+
`[wick-i18n] Build report — ${entries.length} translation(s) captured`,
|
|
71
|
+
top,
|
|
72
|
+
row(headers),
|
|
73
|
+
mid,
|
|
74
|
+
...rows.map(row),
|
|
75
|
+
bottom,
|
|
76
|
+
"",
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
console.log(lines.join("\n"));
|
|
80
|
+
}
|
package/src/processor.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview TranslationProcessor — maintains the translation dictionary and
|
|
3
|
+
* decides which JSX nodes should be wrapped with WuTranslate.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Components always excluded from translation regardless of user config. */
|
|
7
|
+
const DEFAULT_IGNORE = [
|
|
8
|
+
"WuIcon",
|
|
9
|
+
"WuTranslateProvider",
|
|
10
|
+
"WuHelpButton",
|
|
11
|
+
"WuActivityLog",
|
|
12
|
+
"WuAppHeader",
|
|
13
|
+
"WuAPpHeadeMenu",
|
|
14
|
+
"WuCopyToClipboard",
|
|
15
|
+
"WuMenuIcon",
|
|
16
|
+
"WuScrollArea",
|
|
17
|
+
"WuDrawer",
|
|
18
|
+
"WuLoader",
|
|
19
|
+
"WuContentEditor",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export class TranslationProcessor {
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} options
|
|
25
|
+
* @param {string[]} options.components - Component names that trigger translation.
|
|
26
|
+
* @param {string[]} [options.ignoreComponents] - Extra components to exclude.
|
|
27
|
+
* @param {boolean} [options.debug] - Enable verbose logging.
|
|
28
|
+
*/
|
|
29
|
+
constructor(options) {
|
|
30
|
+
this.components = new Set(options.components);
|
|
31
|
+
this.ignoreComponents = new Set(
|
|
32
|
+
DEFAULT_IGNORE.concat(options.ignoreComponents || []),
|
|
33
|
+
);
|
|
34
|
+
/** @type {Map<string, string>} key → original text */
|
|
35
|
+
this.dictionary = new Map();
|
|
36
|
+
/** @type {import('./debug.js').DebugEntry[]} */
|
|
37
|
+
this.entries = [];
|
|
38
|
+
this.debugEnabled = options.debug || false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Conditional logger — only emits when `debug: true`.
|
|
43
|
+
* @param {...unknown} args
|
|
44
|
+
*/
|
|
45
|
+
log(...args) {
|
|
46
|
+
if (this.debugEnabled) console.log("[wick-i18n]", ...args);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Store a key→text pair and append a debug entry.
|
|
51
|
+
* Warns on collision (same key, different text).
|
|
52
|
+
*
|
|
53
|
+
* @param {string} key
|
|
54
|
+
* @param {string} text
|
|
55
|
+
* @param {string} file - Source file path.
|
|
56
|
+
* @param {string} componentTree - JSX ancestor chain for the debug report.
|
|
57
|
+
*/
|
|
58
|
+
record(key, text, file, componentTree) {
|
|
59
|
+
if (this.dictionary.has(key) && this.dictionary.get(key) !== text) {
|
|
60
|
+
console.warn(
|
|
61
|
+
`[wick-i18n] Collision in ${file}\nKey: "${key}"\nNew: "${text}"`,
|
|
62
|
+
);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.dictionary.set(key, text);
|
|
66
|
+
this.entries.push({ key, text, file, componentTree });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Walk up the JSX tree from `path` to determine if the node is inside a
|
|
71
|
+
* component that should be translated.
|
|
72
|
+
*
|
|
73
|
+
* Rules (first match wins, walking outward):
|
|
74
|
+
* - `data-skip` / `data-i18n-skip` attr → ignored
|
|
75
|
+
* - component in `ignoreComponents` → ignored
|
|
76
|
+
* - `data-i18n-wrapper` attr OR component starts with "Wu" / is in `components` → translate
|
|
77
|
+
*
|
|
78
|
+
* @param {import('@babel/traverse').NodePath} path
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
shouldTranslate(path) {
|
|
82
|
+
let isIgnored = false;
|
|
83
|
+
let targetFound = false;
|
|
84
|
+
|
|
85
|
+
path.findParent((p) => {
|
|
86
|
+
if (!p.isJSXElement()) return false;
|
|
87
|
+
|
|
88
|
+
const name =
|
|
89
|
+
p.node.openingElement.name.name ||
|
|
90
|
+
p.node.openingElement.name.property?.name;
|
|
91
|
+
const attrs = p.node.openingElement.attributes || [];
|
|
92
|
+
|
|
93
|
+
if (
|
|
94
|
+
attrs.some((a) =>
|
|
95
|
+
["data-skip", "data-i18n-skip"].includes(a.name?.name),
|
|
96
|
+
)
|
|
97
|
+
) {
|
|
98
|
+
isIgnored = true;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (this.ignoreComponents.has(name)) {
|
|
103
|
+
isIgnored = true;
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const hasWrapper = attrs.some(
|
|
108
|
+
(a) => a.name?.name === "data-i18n-wrapper",
|
|
109
|
+
);
|
|
110
|
+
const isTarget = this.components.has(name) || name?.startsWith("Wu");
|
|
111
|
+
|
|
112
|
+
if (hasWrapper || isTarget) {
|
|
113
|
+
targetFound = true;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return false;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return targetFound && !isIgnored;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Return the explicit i18n key from the nearest ancestor JSX element's
|
|
125
|
+
* `data-i18n-key` attribute, or `null` if absent.
|
|
126
|
+
*
|
|
127
|
+
* @param {import('@babel/traverse').NodePath} path
|
|
128
|
+
* @returns {string|null}
|
|
129
|
+
*/
|
|
130
|
+
getExplicitKey(path) {
|
|
131
|
+
let result = null;
|
|
132
|
+
path.findParent((p) => {
|
|
133
|
+
if (!p.isJSXElement()) return false;
|
|
134
|
+
const attr = p.node.openingElement.attributes.find(
|
|
135
|
+
(a) => a.name?.name === "data-i18n-key",
|
|
136
|
+
);
|
|
137
|
+
if (!attr) return false;
|
|
138
|
+
const val =
|
|
139
|
+
attr.value.type === "StringLiteral"
|
|
140
|
+
? attr.value.value
|
|
141
|
+
: attr.value.expression?.value;
|
|
142
|
+
if (!val) {
|
|
143
|
+
console.warn(
|
|
144
|
+
`[wick-i18n] data-i18n-key on <${p.node.openingElement.name.name}> is dynamic or empty — falling back to text content.`,
|
|
145
|
+
);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
result = val;
|
|
149
|
+
return true;
|
|
150
|
+
});
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
}
|
package/src/transform.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview AST transform — parses a JSX/TSX file, rewrites translatable
|
|
3
|
+
* text nodes to `<WuTranslate>`, and prepends the import if needed.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import MagicString from "magic-string";
|
|
7
|
+
import { parse } from "@babel/parser";
|
|
8
|
+
import _traverse from "@babel/traverse";
|
|
9
|
+
import { getComponentTree } from "./debug.js";
|
|
10
|
+
|
|
11
|
+
const traverse = _traverse.default || _traverse;
|
|
12
|
+
|
|
13
|
+
/** Babel parser plugins applied to every file. */
|
|
14
|
+
const BABEL_PLUGINS = ["jsx", "typescript"];
|
|
15
|
+
|
|
16
|
+
/** @param {import('@babel/types').Node} node @returns {string|null} */
|
|
17
|
+
function getStaticString(node) {
|
|
18
|
+
if (node.type === "StringLiteral") return node.value;
|
|
19
|
+
if (node.type === "TemplateLiteral" && !node.expressions.length)
|
|
20
|
+
return node.quasis[0].value.cooked;
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Replace a translatable text node with a `<WuTranslate>` element and record
|
|
26
|
+
* the key in the processor's dictionary.
|
|
27
|
+
*
|
|
28
|
+
* @param {import('@babel/traverse').NodePath} path - Path of the text node.
|
|
29
|
+
* @param {string} text - Trimmed text content.
|
|
30
|
+
* @param {number} start - Absolute start offset in source.
|
|
31
|
+
* @param {number} end - Absolute end offset in source.
|
|
32
|
+
* @param {MagicString} ms - Mutable source string.
|
|
33
|
+
* @param {import('./processor.js').TranslationProcessor} processor
|
|
34
|
+
* @param {string} id - File path (for collision warnings).
|
|
35
|
+
* @returns {boolean} `true` when replacement was made.
|
|
36
|
+
*/
|
|
37
|
+
function handleCapture(path, text, start, end, ms, processor, id, skipExplicitKey = false) {
|
|
38
|
+
const cleanText = text.trim().replace(/\n/g, " ").replace(/\s{2,}/g, " ");
|
|
39
|
+
if (!cleanText || !processor.shouldTranslate(path)) return false;
|
|
40
|
+
|
|
41
|
+
const key = (!skipExplicitKey && processor.getExplicitKey(path)) || cleanText;
|
|
42
|
+
processor.record(key, cleanText, id, getComponentTree(path));
|
|
43
|
+
|
|
44
|
+
ms.overwrite(
|
|
45
|
+
start,
|
|
46
|
+
end,
|
|
47
|
+
`<WuTranslate __i18nKey=${JSON.stringify(key)}></WuTranslate>`,
|
|
48
|
+
);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse `code`, replace all translatable JSX text nodes, and prepend the
|
|
54
|
+
* `WuTranslate` import when necessary.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} code - Raw source of the file.
|
|
57
|
+
* @param {string} id - File path.
|
|
58
|
+
* @param {import('./processor.js').TranslationProcessor} processor
|
|
59
|
+
* @returns {{ code: string, map: object } | null} Transformed result, or
|
|
60
|
+
* `null` when no changes were made.
|
|
61
|
+
*/
|
|
62
|
+
export function transformFile(code, id, processor) {
|
|
63
|
+
const ast = parse(code, {
|
|
64
|
+
sourceType: "module",
|
|
65
|
+
plugins: BABEL_PLUGINS,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const ms = new MagicString(code);
|
|
69
|
+
let needsImport = false;
|
|
70
|
+
let hasImport = false;
|
|
71
|
+
|
|
72
|
+
traverse(ast, {
|
|
73
|
+
/**
|
|
74
|
+
* Track whether `WuTranslate` is already imported from wick-ui-lib so we
|
|
75
|
+
* don't duplicate the import statement.
|
|
76
|
+
*/
|
|
77
|
+
ImportDeclaration(path) {
|
|
78
|
+
if (path.node.source.value.includes("wick-ui-lib")) {
|
|
79
|
+
hasImport = path.node.specifiers.some(
|
|
80
|
+
(s) => s.imported?.name === "WuTranslate",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
/** Plain JSX text: `<Foo>Hello world</Foo>` */
|
|
86
|
+
JSXText(path) {
|
|
87
|
+
const text = path.node.value;
|
|
88
|
+
const trimmed = text.trim();
|
|
89
|
+
const start = path.node.start + text.indexOf(trimmed);
|
|
90
|
+
if (
|
|
91
|
+
handleCapture(
|
|
92
|
+
path,
|
|
93
|
+
trimmed,
|
|
94
|
+
start,
|
|
95
|
+
start + trimmed.length,
|
|
96
|
+
ms,
|
|
97
|
+
processor,
|
|
98
|
+
id,
|
|
99
|
+
)
|
|
100
|
+
) {
|
|
101
|
+
needsImport = true;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* JSX expression containers with a static string value:
|
|
107
|
+
* `<Foo>{"Hello"}</Foo>` or `<Foo>{\`Hello\`}</Foo>`
|
|
108
|
+
*
|
|
109
|
+
* Attribute values (`variant={'secondary'}`) are skipped — their parent is
|
|
110
|
+
* a JSXAttribute, not a JSXElement child position.
|
|
111
|
+
*/
|
|
112
|
+
JSXExpressionContainer(path) {
|
|
113
|
+
if (path.parent.type === "JSXAttribute") return;
|
|
114
|
+
|
|
115
|
+
const expr = path.node.expression;
|
|
116
|
+
let text = null;
|
|
117
|
+
|
|
118
|
+
if (expr.type === "StringLiteral") {
|
|
119
|
+
text = expr.value;
|
|
120
|
+
} else if (expr.type === "TemplateLiteral" && !expr.expressions.length) {
|
|
121
|
+
text = expr.quasis[0].value.cooked;
|
|
122
|
+
} else if (expr.type === "ConditionalExpression") {
|
|
123
|
+
const consText = getStaticString(expr.consequent);
|
|
124
|
+
const altText = getStaticString(expr.alternate);
|
|
125
|
+
let changed = false;
|
|
126
|
+
if (consText !== null)
|
|
127
|
+
changed =
|
|
128
|
+
handleCapture(
|
|
129
|
+
path,
|
|
130
|
+
consText,
|
|
131
|
+
expr.consequent.start,
|
|
132
|
+
expr.consequent.end,
|
|
133
|
+
ms,
|
|
134
|
+
processor,
|
|
135
|
+
id,
|
|
136
|
+
true,
|
|
137
|
+
) || changed;
|
|
138
|
+
if (altText !== null)
|
|
139
|
+
changed =
|
|
140
|
+
handleCapture(
|
|
141
|
+
path,
|
|
142
|
+
altText,
|
|
143
|
+
expr.alternate.start,
|
|
144
|
+
expr.alternate.end,
|
|
145
|
+
ms,
|
|
146
|
+
processor,
|
|
147
|
+
id,
|
|
148
|
+
true,
|
|
149
|
+
) || changed;
|
|
150
|
+
if (changed) needsImport = true;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (
|
|
155
|
+
text &&
|
|
156
|
+
handleCapture(
|
|
157
|
+
path,
|
|
158
|
+
text,
|
|
159
|
+
path.node.start,
|
|
160
|
+
path.node.end,
|
|
161
|
+
ms,
|
|
162
|
+
processor,
|
|
163
|
+
id,
|
|
164
|
+
)
|
|
165
|
+
) {
|
|
166
|
+
needsImport = true;
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (!needsImport) return null;
|
|
172
|
+
|
|
173
|
+
if (!hasImport) {
|
|
174
|
+
ms.prepend(`import { WuTranslate } from '@npm-questionpro/wick-ui-lib';\n`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { code: ms.toString(), map: ms.generateMap({ hires: true }) };
|
|
178
|
+
}
|
package/wickuii18n.test.js
CHANGED
|
@@ -9,6 +9,8 @@ function transform(code, options = {}) {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
describe("Wick UI i18n Vite Plugin", () => {
|
|
12
|
+
// ─── Core translation ──────────────────────────────────────────────────────
|
|
13
|
+
|
|
12
14
|
it("1. Translates basic Wu* components and injects import", () => {
|
|
13
15
|
const code = `<WuButton>Submit</WuButton>`;
|
|
14
16
|
const result = transform(code);
|
|
@@ -50,6 +52,21 @@ describe("Wick UI i18n Vite Plugin", () => {
|
|
|
50
52
|
);
|
|
51
53
|
});
|
|
52
54
|
|
|
55
|
+
it("6b. data-i18n-key propagates through nested elements", () => {
|
|
56
|
+
const code = `<WuButton data-i18n-key="btn_login"><span>Log In</span></WuButton>`;
|
|
57
|
+
const result = transform(code);
|
|
58
|
+
expect(result).toContain(`<WuTranslate __i18nKey="btn_login"></WuTranslate>`);
|
|
59
|
+
expect(result).not.toContain(`__i18nKey="Log In"`);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("6c. data-i18n-key on ternary parent is ignored — each branch uses its own text as key", () => {
|
|
63
|
+
const code = `<WuButton data-i18n-key="btn">{flag ? "Yes" : "No"}</WuButton>`;
|
|
64
|
+
const result = transform(code);
|
|
65
|
+
expect(result).toContain(`<WuTranslate __i18nKey="Yes"></WuTranslate>`);
|
|
66
|
+
expect(result).toContain(`<WuTranslate __i18nKey="No"></WuTranslate>`);
|
|
67
|
+
expect(result).not.toContain(`__i18nKey="btn"`);
|
|
68
|
+
});
|
|
69
|
+
|
|
53
70
|
it("7. Translates JSX Expression Containers (Strings in braces)", () => {
|
|
54
71
|
const code = `<WuButton>{"Hello World"}</WuButton>`;
|
|
55
72
|
const result = transform(code);
|
|
@@ -58,14 +75,13 @@ describe("Wick UI i18n Vite Plugin", () => {
|
|
|
58
75
|
);
|
|
59
76
|
});
|
|
60
77
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
//
|
|
78
|
+
it("8. Does not translate JSX attribute string values", () => {
|
|
79
|
+
const code = `<WuButton variant={'secondary'}>Submit</WuButton>`;
|
|
80
|
+
const result = transform(code);
|
|
81
|
+
expect(result).not.toContain(`__i18nKey="secondary"`);
|
|
82
|
+
expect(result).toContain(`<WuTranslate __i18nKey="Submit"></WuTranslate>`);
|
|
83
|
+
});
|
|
84
|
+
|
|
69
85
|
it("9. Supports data-i18n-wrapper on non-target tags", () => {
|
|
70
86
|
const code = `<span data-i18n-wrapper>Wrapped Text</span>`;
|
|
71
87
|
const triggerCode = `import { WuPlaceholder } from 'lib';\n` + code;
|
|
@@ -74,4 +90,98 @@ describe("Wick UI i18n Vite Plugin", () => {
|
|
|
74
90
|
`<WuTranslate __i18nKey="Wrapped Text"></WuTranslate>`,
|
|
75
91
|
);
|
|
76
92
|
});
|
|
93
|
+
|
|
94
|
+
it("10. Skips files matching excludeFiles", () => {
|
|
95
|
+
const code = `<WuButton>Submit</WuButton>`;
|
|
96
|
+
const plugin = wickuiI18nPlugin({ excludeFiles: ["**/ignored/**"] });
|
|
97
|
+
const hit = plugin.transform(code, "src/components/Form.jsx");
|
|
98
|
+
const miss = plugin.transform(code, "src/ignored/Form.jsx");
|
|
99
|
+
expect(hit?.code).toContain("WuTranslate");
|
|
100
|
+
expect(miss).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ─── Newline stripping ─────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
it("11. Strips newline from JSX text node", () => {
|
|
106
|
+
const code = `<WuButton>Hello\nWorld</WuButton>`;
|
|
107
|
+
const result = transform(code);
|
|
108
|
+
expect(result).toContain(
|
|
109
|
+
`<WuTranslate __i18nKey="Hello World"></WuTranslate>`,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("11b. Collapses multiple newlines/spaces into a single space", () => {
|
|
114
|
+
const code = `<WuButton>Hello\n\n World</WuButton>`;
|
|
115
|
+
const result = transform(code);
|
|
116
|
+
expect(result).toContain(
|
|
117
|
+
`<WuTranslate __i18nKey="Hello World"></WuTranslate>`,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ─── Ternary expressions ───────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
it("12. Translates both static branches of a ternary", () => {
|
|
124
|
+
const code = `<WuButton>{isActive ? "Deactivate" : "Activate"}</WuButton>`;
|
|
125
|
+
const result = transform(code);
|
|
126
|
+
expect(result).toContain(
|
|
127
|
+
`<WuTranslate __i18nKey="Deactivate"></WuTranslate>`,
|
|
128
|
+
);
|
|
129
|
+
expect(result).toContain(
|
|
130
|
+
`<WuTranslate __i18nKey="Activate"></WuTranslate>`,
|
|
131
|
+
);
|
|
132
|
+
expect(result).toContain(`isActive ?`);
|
|
133
|
+
expect(result).toContain(`import { WuTranslate }`);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("12b. Translates only the static branch when one side is dynamic", () => {
|
|
137
|
+
const code = `<WuButton>{isActive ? "Deactivate" : dynamicLabel}</WuButton>`;
|
|
138
|
+
const result = transform(code);
|
|
139
|
+
expect(result).toContain(
|
|
140
|
+
`<WuTranslate __i18nKey="Deactivate"></WuTranslate>`,
|
|
141
|
+
);
|
|
142
|
+
expect(result).toContain(`dynamicLabel`);
|
|
143
|
+
expect(result).not.toContain(`__i18nKey="dynamicLabel"`);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("12c. Skips ternary when both branches are dynamic", () => {
|
|
147
|
+
const code = `<WuButton>{isActive ? labelA : labelB}</WuButton>`;
|
|
148
|
+
const result = transform(code);
|
|
149
|
+
expect(result).not.toContain(`WuTranslate`);
|
|
150
|
+
expect(result).toContain(`labelA`);
|
|
151
|
+
expect(result).toContain(`labelB`);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("12d. Translates ternary with template literal branches", () => {
|
|
155
|
+
const code = "<WuButton>{flag ? `Yes` : `No`}</WuButton>";
|
|
156
|
+
const result = transform(code);
|
|
157
|
+
expect(result).toContain(`<WuTranslate __i18nKey="Yes"></WuTranslate>`);
|
|
158
|
+
expect(result).toContain(`<WuTranslate __i18nKey="No"></WuTranslate>`);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("12e. Skips ternary inside ignored component", () => {
|
|
162
|
+
const code = `<WuIcon>{flag ? "A" : "B"}</WuIcon>`;
|
|
163
|
+
const result = transform(code);
|
|
164
|
+
expect(result).not.toContain(`WuTranslate`);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("12f. Respects data-skip on parent of ternary", () => {
|
|
168
|
+
const code = `<WuButton data-skip>{flag ? "A" : "B"}</WuButton>`;
|
|
169
|
+
const result = transform(code);
|
|
170
|
+
expect(result).not.toContain(`WuTranslate`);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("12g. Nested ternary: translates first branch, skips inner ternary", () => {
|
|
174
|
+
// {a ? "A" : b ? "B" : "C"} — alternate is ConditionalExpression, not a static string
|
|
175
|
+
const code = `<WuButton>{a ? "A" : b ? "B" : "C"}</WuButton>`;
|
|
176
|
+
const result = transform(code);
|
|
177
|
+
expect(result).toContain(`<WuTranslate __i18nKey="A"></WuTranslate>`);
|
|
178
|
+
// inner ternary branches are not translated (known v1 limitation)
|
|
179
|
+
expect(result).toContain(`b ? "B" : "C"`);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("12h. Ternary outside any Wu/wrapper component is not translated", () => {
|
|
183
|
+
const code = `<div>{flag ? "A" : "B"}</div>`;
|
|
184
|
+
const result = transform(code);
|
|
185
|
+
expect(result).not.toContain(`WuTranslate`);
|
|
186
|
+
});
|
|
77
187
|
});
|