@npm-questionpro/wick-ui-i18n 0.9.0 → 0.10.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 +14 -2
- package/package.json +1 -1
- package/src/transform.js +47 -1
- package/src/transformJSXTextWithEntities.js +127 -0
- package/src/transformTemplateLiteral.js +90 -0
- package/src/transformWtCalls.js +136 -0
- package/wickuii18n.test.js +417 -9
package/index.js
CHANGED
|
@@ -46,10 +46,22 @@ export default function wickuiI18nPlugin(options = {}) {
|
|
|
46
46
|
|
|
47
47
|
const filter = createFilter([/\.(jsx|tsx)$/], options.excludeFiles);
|
|
48
48
|
|
|
49
|
+
let base = "/";
|
|
50
|
+
|
|
49
51
|
return {
|
|
50
52
|
name: "wick-ui-i18n",
|
|
51
53
|
enforce: "pre",
|
|
52
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Capture the resolved base so the dev-server middleware path matches
|
|
57
|
+
* what `import.meta.env.BASE_URL` resolves to in the consumer app.
|
|
58
|
+
*
|
|
59
|
+
* @param {import('vite').ResolvedConfig} resolvedConfig
|
|
60
|
+
*/
|
|
61
|
+
configResolved(resolvedConfig) {
|
|
62
|
+
base = resolvedConfig.base;
|
|
63
|
+
},
|
|
64
|
+
|
|
53
65
|
/**
|
|
54
66
|
* Clear state on every build so stale keys don't accumulate across
|
|
55
67
|
* watch-mode rebuilds.
|
|
@@ -67,7 +79,7 @@ export default function wickuiI18nPlugin(options = {}) {
|
|
|
67
79
|
* @param {string} id
|
|
68
80
|
*/
|
|
69
81
|
transform(code, id) {
|
|
70
|
-
if (!filter(id) || !code.includes("Wu")) return null;
|
|
82
|
+
if (!filter(id) || (!code.includes("Wu") && !/\bwt\(/.test(code))) return null;
|
|
71
83
|
return transformFile(code, id, processor);
|
|
72
84
|
},
|
|
73
85
|
|
|
@@ -77,7 +89,7 @@ export default function wickuiI18nPlugin(options = {}) {
|
|
|
77
89
|
* @param {import('vite').ViteDevServer} server
|
|
78
90
|
*/
|
|
79
91
|
configureServer(server) {
|
|
80
|
-
server.middlewares.use(
|
|
92
|
+
server.middlewares.use(`${base}wick-ui-i18n.json`, (_req, res) => {
|
|
81
93
|
res.setHeader("Content-Type", "application/json");
|
|
82
94
|
res.end(
|
|
83
95
|
JSON.stringify(Object.fromEntries(processor.dictionary), null, 2),
|
package/package.json
CHANGED
package/src/transform.js
CHANGED
|
@@ -7,12 +7,23 @@ import MagicString from "magic-string";
|
|
|
7
7
|
import { parse } from "@babel/parser";
|
|
8
8
|
import _traverse from "@babel/traverse";
|
|
9
9
|
import { getComponentTree } from "./debug.js";
|
|
10
|
+
import { transformTemplateLiteralExpression } from "./transformTemplateLiteral.js";
|
|
11
|
+
import { transformJSXTextWithEntities } from "./transformJSXTextWithEntities.js";
|
|
12
|
+
import { recordWtCall, transformWtTemplateLiteral } from "./transformWtCalls.js";
|
|
10
13
|
|
|
11
14
|
const traverse = _traverse.default || _traverse;
|
|
12
15
|
|
|
13
16
|
/** Babel parser plugins applied to every file. */
|
|
14
17
|
const BABEL_PLUGINS = ["jsx", "typescript"];
|
|
15
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Matches any HTML entity: named (&), decimal (©), or hex (©).
|
|
21
|
+
* Used to skip text segments that contain entities — they must be left as-is.
|
|
22
|
+
* Note: for JSXText this must be tested against the RAW source, not the Babel
|
|
23
|
+
* decoded .value (Babel turns & → "&", → "\u00a0", etc.).
|
|
24
|
+
*/
|
|
25
|
+
const HTML_ENTITY_RE = /&(?:[a-zA-Z][a-zA-Z0-9]*|#[0-9]+|#x[0-9a-fA-F]+);/;
|
|
26
|
+
|
|
16
27
|
/** @param {import('@babel/types').Node} node @returns {string|null} */
|
|
17
28
|
function getStaticString(node) {
|
|
18
29
|
if (node.type === "StringLiteral") return node.value;
|
|
@@ -37,6 +48,9 @@ function getStaticString(node) {
|
|
|
37
48
|
function handleCapture(path, text, start, end, ms, processor, id, skipExplicitKey = false) {
|
|
38
49
|
const cleanText = text.trim().replace(/\n/g, " ").replace(/\s{2,}/g, " ");
|
|
39
50
|
if (!cleanText || !processor.shouldTranslate(path)) return false;
|
|
51
|
+
// For StringLiteral / TemplateLiteral quasis / ternary branches: entities are
|
|
52
|
+
// not decoded by the JS parser, so cleanText still contains "&" etc.
|
|
53
|
+
if (HTML_ENTITY_RE.test(cleanText)) return false;
|
|
40
54
|
|
|
41
55
|
const key = (!skipExplicitKey && processor.getExplicitKey(path)) || cleanText;
|
|
42
56
|
processor.record(key, cleanText, id, getComponentTree(path));
|
|
@@ -68,12 +82,24 @@ export function transformFile(code, id, processor) {
|
|
|
68
82
|
const ms = new MagicString(code);
|
|
69
83
|
let needsImport = false;
|
|
70
84
|
let hasImport = false;
|
|
85
|
+
let hasWtTransform = false;
|
|
71
86
|
|
|
72
87
|
traverse(ast, {
|
|
73
88
|
/**
|
|
74
89
|
* Track whether `WuTranslate` is already imported from wick-ui-lib so we
|
|
75
90
|
* don't duplicate the import statement.
|
|
76
91
|
*/
|
|
92
|
+
/**
|
|
93
|
+
* wt("static string") call — record the key in the dictionary.
|
|
94
|
+
* No code transformation; wt() handles runtime lookup.
|
|
95
|
+
*/
|
|
96
|
+
CallExpression(path) {
|
|
97
|
+
if (recordWtCall(path, processor, id)) return;
|
|
98
|
+
if (transformWtTemplateLiteral(path, code, ms, processor, id)) {
|
|
99
|
+
hasWtTransform = true;
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
|
|
77
103
|
ImportDeclaration(path) {
|
|
78
104
|
if (path.node.source.value.includes("wick-ui-lib")) {
|
|
79
105
|
hasImport = path.node.specifiers.some(
|
|
@@ -86,6 +112,16 @@ export function transformFile(code, id, processor) {
|
|
|
86
112
|
JSXText(path) {
|
|
87
113
|
const text = path.node.value;
|
|
88
114
|
const trimmed = text.trim();
|
|
115
|
+
// Babel decodes entities in JSXText.value (& → "&", © → "©").
|
|
116
|
+
// Check the raw source slice — if entities found, split around them so
|
|
117
|
+
// translatable text segments are still wrapped while entities stay put.
|
|
118
|
+
const rawSource = code.slice(path.node.start, path.node.end);
|
|
119
|
+
if (HTML_ENTITY_RE.test(rawSource)) {
|
|
120
|
+
if (transformJSXTextWithEntities(path, rawSource, ms, processor, id)) {
|
|
121
|
+
needsImport = true;
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
89
125
|
const start = path.node.start + text.indexOf(trimmed);
|
|
90
126
|
if (
|
|
91
127
|
handleCapture(
|
|
@@ -117,6 +153,16 @@ export function transformFile(code, id, processor) {
|
|
|
117
153
|
|
|
118
154
|
if (expr.type === "StringLiteral") {
|
|
119
155
|
text = expr.value;
|
|
156
|
+
} else if (
|
|
157
|
+
expr.type === "TemplateLiteral" &&
|
|
158
|
+
expr.expressions.length > 0
|
|
159
|
+
) {
|
|
160
|
+
if (
|
|
161
|
+
transformTemplateLiteralExpression(path, code, ms, processor, id)
|
|
162
|
+
) {
|
|
163
|
+
needsImport = true;
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
120
166
|
} else if (expr.type === "TemplateLiteral" && !expr.expressions.length) {
|
|
121
167
|
text = expr.quasis[0].value.cooked;
|
|
122
168
|
} else if (expr.type === "ConditionalExpression") {
|
|
@@ -168,7 +214,7 @@ export function transformFile(code, id, processor) {
|
|
|
168
214
|
},
|
|
169
215
|
});
|
|
170
216
|
|
|
171
|
-
if (!needsImport) return null;
|
|
217
|
+
if (!needsImport && !hasWtTransform) return null;
|
|
172
218
|
|
|
173
219
|
if (!hasImport) {
|
|
174
220
|
ms.prepend(`import { WuTranslate } from '@npm-questionpro/wick-ui-lib';\n`);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview transformJSXTextWithEntities — splits a JSXText node that
|
|
3
|
+
* contains HTML entities into interleaved translated segments and raw entities.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* // Input
|
|
7
|
+
* <WuButton>Hello & World</WuButton>
|
|
8
|
+
*
|
|
9
|
+
* // Output
|
|
10
|
+
* <WuButton><WuTranslate __i18nKey="Hello" /> & <WuTranslate __i18nKey="World" /></WuButton>
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* // Input
|
|
14
|
+
* <WuButton><Tag></WuButton>
|
|
15
|
+
*
|
|
16
|
+
* // Output
|
|
17
|
+
* <WuButton><<WuTranslate __i18nKey="Tag" />></WuButton>
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { getComponentTree } from "./debug.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Splits the raw source around HTML entities (capturing group keeps entities
|
|
24
|
+
* in the resulting array).
|
|
25
|
+
*
|
|
26
|
+
* "Hello & World" → ["Hello ", "&", " World"]
|
|
27
|
+
* "<Tag>" → ["", "<", "Tag", ">", ""]
|
|
28
|
+
*/
|
|
29
|
+
const HTML_ENTITY_SPLIT_RE =
|
|
30
|
+
/(&(?:[a-zA-Z][a-zA-Z0-9]*|#[0-9]+|#x[0-9a-fA-F]+);)/g;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Tests whether a single segment (from the split above) is itself an entity.
|
|
34
|
+
*/
|
|
35
|
+
const HTML_ENTITY_SEGMENT_RE =
|
|
36
|
+
/^&(?:[a-zA-Z][a-zA-Z0-9]*|#[0-9]+|#x[0-9a-fA-F]+);$/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Given a text segment, extract leading whitespace, the translatable core,
|
|
40
|
+
* and trailing whitespace so spacing around entities is preserved.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} raw
|
|
43
|
+
* @returns {{ leading: string, text: string, trailing: string }}
|
|
44
|
+
*/
|
|
45
|
+
function splitSegment(raw) {
|
|
46
|
+
const leading = raw.match(/^\s*/)[0];
|
|
47
|
+
const trailing = raw.match(/\s*$/)[0];
|
|
48
|
+
// Normalise internal whitespace to match handleCapture behaviour:
|
|
49
|
+
// newlines → single space, consecutive spaces → single space.
|
|
50
|
+
const text = raw
|
|
51
|
+
.slice(leading.length, raw.length - trailing.length)
|
|
52
|
+
.replace(/\n/g, " ")
|
|
53
|
+
.replace(/\s{2,}/g, " ");
|
|
54
|
+
return { leading, text, trailing };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Transform a JSXText node whose raw source contains at least one HTML entity.
|
|
59
|
+
*
|
|
60
|
+
* Each non-entity text segment is independently trimmed and wrapped in a
|
|
61
|
+
* `<WuTranslate>` element. Entities are emitted verbatim. Leading / trailing
|
|
62
|
+
* whitespace within each segment is preserved as JSX text so words don't run
|
|
63
|
+
* together after the replacement.
|
|
64
|
+
*
|
|
65
|
+
* If every segment is either empty or a bare entity (no translatable text),
|
|
66
|
+
* the node is left untouched and the function returns `false`.
|
|
67
|
+
*
|
|
68
|
+
* @param {import('@babel/traverse').NodePath} path - JSXText path.
|
|
69
|
+
* @param {string} rawSource - Raw source slice for this node
|
|
70
|
+
* (code.slice(node.start, node.end)).
|
|
71
|
+
* @param {import('magic-string').default} ms
|
|
72
|
+
* @param {import('./processor.js').TranslationProcessor} processor
|
|
73
|
+
* @param {string} id - Source file path (for collision warnings).
|
|
74
|
+
* @returns {boolean} `true` when a replacement was written.
|
|
75
|
+
*/
|
|
76
|
+
export function transformJSXTextWithEntities(
|
|
77
|
+
path,
|
|
78
|
+
rawSource,
|
|
79
|
+
ms,
|
|
80
|
+
processor,
|
|
81
|
+
id,
|
|
82
|
+
) {
|
|
83
|
+
if (!processor.shouldTranslate(path)) return false;
|
|
84
|
+
|
|
85
|
+
// data-i18n-key cannot span multiple split segments — warn and ignore it.
|
|
86
|
+
const explicitKey = processor.getExplicitKey(path);
|
|
87
|
+
if (explicitKey) {
|
|
88
|
+
console.warn(
|
|
89
|
+
`[wick-i18n] data-i18n-key="${explicitKey}" is set on a node whose text ` +
|
|
90
|
+
`contains HTML entities. The key cannot apply across multiple segments ` +
|
|
91
|
+
`— splitting without it.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const segments = rawSource.split(HTML_ENTITY_SPLIT_RE);
|
|
96
|
+
const componentTree = getComponentTree(path);
|
|
97
|
+
|
|
98
|
+
const parts = [];
|
|
99
|
+
let hasTranslatable = false;
|
|
100
|
+
|
|
101
|
+
for (const seg of segments) {
|
|
102
|
+
if (!seg) continue; // empty strings produced by split at boundaries
|
|
103
|
+
|
|
104
|
+
if (HTML_ENTITY_SEGMENT_RE.test(seg)) {
|
|
105
|
+
// Raw entity — emit as-is, no translation key
|
|
106
|
+
parts.push(seg);
|
|
107
|
+
} else {
|
|
108
|
+
const { leading, text, trailing } = splitSegment(seg);
|
|
109
|
+
|
|
110
|
+
if (text) {
|
|
111
|
+
processor.record(text, text, id, componentTree);
|
|
112
|
+
parts.push(
|
|
113
|
+
`${leading}<WuTranslate __i18nKey=${JSON.stringify(text)}></WuTranslate>${trailing}`,
|
|
114
|
+
);
|
|
115
|
+
hasTranslatable = true;
|
|
116
|
+
} else {
|
|
117
|
+
// Pure whitespace between / around entities — preserve as JSX text
|
|
118
|
+
parts.push(seg);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!hasTranslatable) return false;
|
|
124
|
+
|
|
125
|
+
ms.overwrite(path.node.start, path.node.end, parts.join(""));
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview transformTemplateLiteral — handles JSXExpressionContainers
|
|
3
|
+
* whose expression is a TemplateLiteral with one or more dynamic expressions.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* // Input
|
|
7
|
+
* <WuButton>{`hello ${name} how are you`}</WuButton>
|
|
8
|
+
*
|
|
9
|
+
* // Output
|
|
10
|
+
* <WuButton><><WuTranslate __i18nKey="hello" /> {name} <WuTranslate __i18nKey="how are you" /></></WuButton>
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { getComponentTree } from "./debug.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Given a quasi's cooked string, extract leading whitespace, trimmed text,
|
|
17
|
+
* and trailing whitespace as separate pieces so spacing is preserved in the
|
|
18
|
+
* reconstructed JSX fragment.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} raw - The cooked value of a TemplateElement.
|
|
21
|
+
* @returns {{ leading: string, text: string, trailing: string }}
|
|
22
|
+
*/
|
|
23
|
+
function splitQuasi(raw) {
|
|
24
|
+
const leading = raw.match(/^\s*/)[0];
|
|
25
|
+
const trailing = raw.match(/\s*$/)[0];
|
|
26
|
+
const text = raw.slice(leading.length, raw.length - trailing.length);
|
|
27
|
+
return { leading, text, trailing };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Transform a JSXExpressionContainer whose expression is a TemplateLiteral
|
|
32
|
+
* with one or more dynamic expressions into an interleaved React fragment:
|
|
33
|
+
*
|
|
34
|
+
* {`hello ${name} how are you`}
|
|
35
|
+
* → <><WuTranslate __i18nKey="hello" /> {name} <WuTranslate __i18nKey="how are you" /></>
|
|
36
|
+
*
|
|
37
|
+
* Quasis that are empty or whitespace-only are skipped (no key emitted).
|
|
38
|
+
* Leading/trailing whitespace within a quasi is preserved as JSX text around
|
|
39
|
+
* the WuTranslate element so words don't run together after translation.
|
|
40
|
+
*
|
|
41
|
+
* @param {import('@babel/traverse').NodePath} path - JSXExpressionContainer path.
|
|
42
|
+
* @param {string} code - Original source (used to slice expression text).
|
|
43
|
+
* @param {import('magic-string').default} ms
|
|
44
|
+
* @param {import('./processor.js').TranslationProcessor} processor
|
|
45
|
+
* @param {string} id - Source file path (for collision warnings).
|
|
46
|
+
* @returns {boolean} `true` when a replacement was written.
|
|
47
|
+
*/
|
|
48
|
+
export function transformTemplateLiteralExpression(
|
|
49
|
+
path,
|
|
50
|
+
code,
|
|
51
|
+
ms,
|
|
52
|
+
processor,
|
|
53
|
+
id,
|
|
54
|
+
) {
|
|
55
|
+
if (!processor.shouldTranslate(path)) return false;
|
|
56
|
+
|
|
57
|
+
const container = path.node;
|
|
58
|
+
const expr = container.expression; // TemplateLiteral
|
|
59
|
+
const { quasis, expressions } = expr;
|
|
60
|
+
const componentTree = getComponentTree(path);
|
|
61
|
+
|
|
62
|
+
const parts = [];
|
|
63
|
+
let hasTranslatable = false;
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < quasis.length; i++) {
|
|
66
|
+
const cooked = quasis[i].value.cooked ?? quasis[i].value.raw;
|
|
67
|
+
const { leading, text, trailing } = splitQuasi(cooked);
|
|
68
|
+
|
|
69
|
+
if (text) {
|
|
70
|
+
processor.record(text, text, id, componentTree);
|
|
71
|
+
parts.push(
|
|
72
|
+
`${leading}<WuTranslate __i18nKey=${JSON.stringify(text)}></WuTranslate>${trailing}`,
|
|
73
|
+
);
|
|
74
|
+
hasTranslatable = true;
|
|
75
|
+
}
|
|
76
|
+
// whitespace-only or empty quasi — emit nothing (no key, no node)
|
|
77
|
+
|
|
78
|
+
// Interleave: expression follows its preceding quasi (skip after last quasi)
|
|
79
|
+
if (i < expressions.length) {
|
|
80
|
+
const exprNode = expressions[i];
|
|
81
|
+
parts.push(`{${code.slice(exprNode.start, exprNode.end)}}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!hasTranslatable) return false;
|
|
86
|
+
|
|
87
|
+
// Replace the entire {`...`} container with a React fragment
|
|
88
|
+
ms.overwrite(container.start, container.end, `<>${parts.join("")}</>`);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview transformWtCalls — detects wt("static string") call expressions
|
|
3
|
+
* and records the argument as a translation key.
|
|
4
|
+
*
|
|
5
|
+
* No code transformation is performed. wt() handles runtime lookup via the
|
|
6
|
+
* dictionary loaded by WuTranslateProvider. The plugin's only job here is to
|
|
7
|
+
* ensure static arguments appear in wick-ui-i18n.json at build time so they
|
|
8
|
+
* reach the translation API.
|
|
9
|
+
*
|
|
10
|
+
* Supported argument forms:
|
|
11
|
+
* wt("hello") — StringLiteral
|
|
12
|
+
* wt(`hello`) — TemplateLiteral with zero expressions
|
|
13
|
+
*
|
|
14
|
+
* Ignored:
|
|
15
|
+
* wt(variable) — dynamic, cannot be statically analysed
|
|
16
|
+
* wt(`hello ${name}`) — has expressions, not fully static
|
|
17
|
+
* wt() / wt(a, b) — wrong arity
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extract leading whitespace, normalised text, and trailing whitespace from a
|
|
22
|
+
* template quasi string. Mirrors the normalisation applied everywhere else in
|
|
23
|
+
* the plugin (newlines → space, consecutive spaces → one space).
|
|
24
|
+
*
|
|
25
|
+
* @param {string} raw
|
|
26
|
+
* @returns {{ leading: string, text: string, trailing: string }}
|
|
27
|
+
*/
|
|
28
|
+
function splitQuasi(raw) {
|
|
29
|
+
const leading = raw.match(/^\s*/)[0];
|
|
30
|
+
const trailing = raw.match(/\s*$/)[0];
|
|
31
|
+
const text = raw
|
|
32
|
+
.slice(leading.length, raw.length - trailing.length)
|
|
33
|
+
.replace(/\n/g, " ")
|
|
34
|
+
.replace(/\s{2,}/g, " ");
|
|
35
|
+
return {leading, text, trailing};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* If `path` is a `wt(staticString)` call expression, record the key in the
|
|
40
|
+
* processor dictionary. Returns `true` when a key was recorded.
|
|
41
|
+
*
|
|
42
|
+
* @param {import('@babel/traverse').NodePath} path - CallExpression path.
|
|
43
|
+
* @param {import('./processor.js').TranslationProcessor} processor
|
|
44
|
+
* @param {string} id - Source file path (for collision warnings).
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
export function recordWtCall(path, processor, id) {
|
|
48
|
+
const { callee, arguments: args } = path.node;
|
|
49
|
+
|
|
50
|
+
// Only handle bare `wt(...)` identifiers — not obj.wt(...) etc.
|
|
51
|
+
if (callee.type !== "Identifier" || callee.name !== "wt") return false;
|
|
52
|
+
if (args.length !== 1) return false;
|
|
53
|
+
|
|
54
|
+
const arg = args[0];
|
|
55
|
+
let text = null;
|
|
56
|
+
|
|
57
|
+
if (arg.type === "StringLiteral") {
|
|
58
|
+
text = arg.value;
|
|
59
|
+
} else if (arg.type === "TemplateLiteral" && arg.expressions.length === 0) {
|
|
60
|
+
text = arg.quasis[0].value.cooked ?? arg.quasis[0].value.raw;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (text === null) return false;
|
|
64
|
+
|
|
65
|
+
const cleanText = text
|
|
66
|
+
.trim()
|
|
67
|
+
.replace(/\n/g, " ")
|
|
68
|
+
.replace(/\s{2,}/g, " ");
|
|
69
|
+
if (!cleanText) return false;
|
|
70
|
+
|
|
71
|
+
processor.record(cleanText, cleanText, id, "(wt)");
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Transform a `wt(\`template ${expr} literal\`)` call whose template has one
|
|
77
|
+
* or more dynamic expressions.
|
|
78
|
+
*
|
|
79
|
+
* Each static quasi is independently extracted and replaced with a nested
|
|
80
|
+
* `wt("text")` call; dynamic expressions are preserved in place. The whole
|
|
81
|
+
* `wt(\`...\`)` call is replaced with a plain template literal:
|
|
82
|
+
*
|
|
83
|
+
* wt(`Hello ${name}`) → `${wt("Hello")} ${name}`
|
|
84
|
+
* wt(`${a} and ${b}`) → `${a} ${wt("and")} ${b}`
|
|
85
|
+
* wt(`Hello ${a} and ${b}`) → `${wt("Hello")} ${a} ${wt("and")} ${b}`
|
|
86
|
+
*
|
|
87
|
+
* If no quasi contains translatable text, the call is left untouched and the
|
|
88
|
+
* function returns `false`.
|
|
89
|
+
*
|
|
90
|
+
* @param {import('@babel/traverse').NodePath} path - CallExpression path.
|
|
91
|
+
* @param {string} code - Original source (for slicing expression text).
|
|
92
|
+
* @param {import('magic-string').default} ms
|
|
93
|
+
* @param {import('./processor.js').TranslationProcessor} processor
|
|
94
|
+
* @param {string} id - Source file path.
|
|
95
|
+
* @returns {boolean} `true` when the call was rewritten.
|
|
96
|
+
*/
|
|
97
|
+
export function transformWtTemplateLiteral(path, code, ms, processor, id) {
|
|
98
|
+
const {callee, arguments: args} = path.node;
|
|
99
|
+
|
|
100
|
+
if (callee.type !== "Identifier" || callee.name !== "wt") return false;
|
|
101
|
+
if (args.length !== 1) return false;
|
|
102
|
+
|
|
103
|
+
const arg = args[0];
|
|
104
|
+
if (arg.type !== "TemplateLiteral" || arg.expressions.length === 0)
|
|
105
|
+
return false;
|
|
106
|
+
|
|
107
|
+
const {quasis, expressions} = arg;
|
|
108
|
+
const parts = ["`"];
|
|
109
|
+
let hasTranslatable = false;
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < quasis.length; i++) {
|
|
112
|
+
const cooked = quasis[i].value.cooked ?? quasis[i].value.raw;
|
|
113
|
+
const {leading, text, trailing} = splitQuasi(cooked);
|
|
114
|
+
|
|
115
|
+
if (text) {
|
|
116
|
+
processor.record(text, text, id, "(wt)");
|
|
117
|
+
parts.push(`${leading}\${wt(${JSON.stringify(text)})}${trailing}`);
|
|
118
|
+
hasTranslatable = true;
|
|
119
|
+
} else {
|
|
120
|
+
// empty or whitespace-only quasi — preserve as literal template text
|
|
121
|
+
parts.push(cooked);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (i < expressions.length) {
|
|
125
|
+
const exprSrc = code.slice(expressions[i].start, expressions[i].end);
|
|
126
|
+
parts.push(`\${${exprSrc}}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
parts.push("`");
|
|
131
|
+
|
|
132
|
+
if (!hasTranslatable) return false;
|
|
133
|
+
|
|
134
|
+
ms.overwrite(path.node.start, path.node.end, parts.join(""));
|
|
135
|
+
return true;
|
|
136
|
+
}
|
package/wickuii18n.test.js
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
2
|
import wickuiI18nPlugin from "./index.js";
|
|
3
3
|
|
|
4
|
-
// Helper to simulate Vite running the transform hook
|
|
5
4
|
function transform(code, options = {}) {
|
|
6
5
|
const plugin = wickuiI18nPlugin(options);
|
|
7
6
|
const result = plugin.transform(code, "TestFile.jsx");
|
|
8
7
|
return result ? result.code : code;
|
|
9
8
|
}
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Run the full plugin lifecycle on `code` and return the emitted dictionary.
|
|
12
|
+
* Needed for wt() tests since those record keys without transforming code.
|
|
13
|
+
*/
|
|
14
|
+
function getDictionary(code, options = {}) {
|
|
15
|
+
const plugin = wickuiI18nPlugin(options);
|
|
16
|
+
plugin.buildStart();
|
|
17
|
+
plugin.transform(code, "TestFile.jsx");
|
|
18
|
+
let dict = {};
|
|
19
|
+
plugin.generateBundle.call({
|
|
20
|
+
emitFile: ({ source }) => {
|
|
21
|
+
dict = JSON.parse(source);
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
return dict;
|
|
25
|
+
}
|
|
13
26
|
|
|
27
|
+
describe("Wick UI i18n Vite Plugin", () => {
|
|
14
28
|
it("1. Translates basic Wu* components and injects import", () => {
|
|
15
29
|
const code = `<WuButton>Submit</WuButton>`;
|
|
16
30
|
const result = transform(code);
|
|
@@ -55,7 +69,9 @@ describe("Wick UI i18n Vite Plugin", () => {
|
|
|
55
69
|
it("6b. data-i18n-key propagates through nested elements", () => {
|
|
56
70
|
const code = `<WuButton data-i18n-key="btn_login"><span>Log In</span></WuButton>`;
|
|
57
71
|
const result = transform(code);
|
|
58
|
-
expect(result).toContain(
|
|
72
|
+
expect(result).toContain(
|
|
73
|
+
`<WuTranslate __i18nKey="btn_login"></WuTranslate>`,
|
|
74
|
+
);
|
|
59
75
|
expect(result).not.toContain(`__i18nKey="Log In"`);
|
|
60
76
|
});
|
|
61
77
|
|
|
@@ -100,8 +116,6 @@ describe("Wick UI i18n Vite Plugin", () => {
|
|
|
100
116
|
expect(miss).toBeNull();
|
|
101
117
|
});
|
|
102
118
|
|
|
103
|
-
// ─── Newline stripping ─────────────────────────────────────────────────────
|
|
104
|
-
|
|
105
119
|
it("11. Strips newline from JSX text node", () => {
|
|
106
120
|
const code = `<WuButton>Hello\nWorld</WuButton>`;
|
|
107
121
|
const result = transform(code);
|
|
@@ -118,8 +132,6 @@ describe("Wick UI i18n Vite Plugin", () => {
|
|
|
118
132
|
);
|
|
119
133
|
});
|
|
120
134
|
|
|
121
|
-
// ─── Ternary expressions ───────────────────────────────────────────────────
|
|
122
|
-
|
|
123
135
|
it("12. Translates both static branches of a ternary", () => {
|
|
124
136
|
const code = `<WuButton>{isActive ? "Deactivate" : "Activate"}</WuButton>`;
|
|
125
137
|
const result = transform(code);
|
|
@@ -184,4 +196,400 @@ describe("Wick UI i18n Vite Plugin", () => {
|
|
|
184
196
|
const result = transform(code);
|
|
185
197
|
expect(result).not.toContain(`WuTranslate`);
|
|
186
198
|
});
|
|
199
|
+
|
|
200
|
+
it("13. Translates dynamic string literals", () => {
|
|
201
|
+
const code = `<WuButton>Submit {name}</WuButton>`;
|
|
202
|
+
const result = transform(code);
|
|
203
|
+
expect(result).toContain(`import { WuTranslate }`);
|
|
204
|
+
expect(result).toContain(`<WuTranslate __i18nKey="Submit"></WuTranslate>`);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ─── HTML entities ─────────────────────────────────────────────────────────
|
|
209
|
+
//
|
|
210
|
+
// Any text segment containing an HTML entity must be left completely untouched.
|
|
211
|
+
// Entities are presentational / structural characters ( , &, ©…)
|
|
212
|
+
// that are not translatable and would confuse translation APIs.
|
|
213
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
describe("HTML entities", () => {
|
|
216
|
+
it("E1. standalone named entity is not wrapped", () => {
|
|
217
|
+
const code = `<WuButton>&</WuButton>`;
|
|
218
|
+
const result = transform(code);
|
|
219
|
+
expect(result).not.toContain("WuTranslate");
|
|
220
|
+
expect(result).toContain("&");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("E2. text mixed with entity — non-entity parts wrapped, entity preserved", () => {
|
|
224
|
+
const code = `<WuButton>Hello & World</WuButton>`;
|
|
225
|
+
const result = transform(code);
|
|
226
|
+
expect(result).toContain(`<WuTranslate __i18nKey="Hello"></WuTranslate>`);
|
|
227
|
+
expect(result).toContain(`<WuTranslate __i18nKey="World"></WuTranslate>`);
|
|
228
|
+
expect(result).toContain("&");
|
|
229
|
+
// must not treat the whole string (decoded or raw) as one key
|
|
230
|
+
expect(result).not.toContain(`__i18nKey="Hello & World"`);
|
|
231
|
+
expect(result).not.toContain(`__i18nKey="Hello & World"`);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("E3. non-breaking space entity is not wrapped", () => {
|
|
235
|
+
const code = `<WuButton> </WuButton>`;
|
|
236
|
+
const result = transform(code);
|
|
237
|
+
expect(result).not.toContain("WuTranslate");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("E4. numeric decimal entity is not wrapped", () => {
|
|
241
|
+
// © = ©
|
|
242
|
+
const code = `<WuButton>©</WuButton>`;
|
|
243
|
+
const result = transform(code);
|
|
244
|
+
expect(result).not.toContain("WuTranslate");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("E5. numeric hex entity is not wrapped", () => {
|
|
248
|
+
// © = ©
|
|
249
|
+
const code = `<WuButton>©</WuButton>`;
|
|
250
|
+
const result = transform(code);
|
|
251
|
+
expect(result).not.toContain("WuTranslate");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("E6. entity inside static string expression is not wrapped", () => {
|
|
255
|
+
const code = `<WuButton>{"Hello & World"}</WuButton>`;
|
|
256
|
+
const result = transform(code);
|
|
257
|
+
expect(result).not.toContain("WuTranslate");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("E7. entity branch in ternary not wrapped, clean branch still wrapped", () => {
|
|
261
|
+
const code = `<WuButton>{flag ? "Yes &" : "No"}</WuButton>`;
|
|
262
|
+
const result = transform(code);
|
|
263
|
+
expect(result).not.toContain(`__i18nKey="Yes &"`);
|
|
264
|
+
expect(result).toContain(`<WuTranslate __i18nKey="No"></WuTranslate>`);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("E8. entity JSXText sibling not wrapped, clean siblings still wrapped", () => {
|
|
268
|
+
// <WuButton>Hello {x}&{y} World</WuButton>
|
|
269
|
+
// JSXText nodes: "Hello ", "&", " World"
|
|
270
|
+
// Babel decodes & → "&" in .value, so check both forms are absent
|
|
271
|
+
const code = `<WuButton>Hello {x}&{y} World</WuButton>`;
|
|
272
|
+
const result = transform(code);
|
|
273
|
+
expect(result).toContain(`<WuTranslate __i18nKey="Hello"></WuTranslate>`);
|
|
274
|
+
expect(result).not.toContain(`__i18nKey="&"`);
|
|
275
|
+
expect(result).not.toContain(`__i18nKey="&"`);
|
|
276
|
+
expect(result).toContain(`<WuTranslate __i18nKey="World"></WuTranslate>`);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("E9. multiple entities — text between them wrapped, both entities kept", () => {
|
|
280
|
+
// <Tag> → <<WuTranslate key="Tag" />>
|
|
281
|
+
const code = `<WuButton><Tag></WuButton>`;
|
|
282
|
+
const result = transform(code);
|
|
283
|
+
expect(result).toContain(`<WuTranslate __i18nKey="Tag"></WuTranslate>`);
|
|
284
|
+
expect(result).toContain("<");
|
|
285
|
+
expect(result).toContain(">");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("E10. entity at boundaries — text in middle still wrapped", () => {
|
|
289
|
+
// Hello → <WuTranslate key="Hello" />
|
|
290
|
+
const code = `<WuButton> Hello </WuButton>`;
|
|
291
|
+
const result = transform(code);
|
|
292
|
+
expect(result).toContain(`<WuTranslate __i18nKey="Hello"></WuTranslate>`);
|
|
293
|
+
expect(result).toContain(" ");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("E11. only entities, no text — nothing wrapped", () => {
|
|
297
|
+
const code = `<WuButton><></WuButton>`;
|
|
298
|
+
const result = transform(code);
|
|
299
|
+
expect(result).not.toContain("WuTranslate");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// fix 1: whitespace normalisation inside entity-split segments
|
|
303
|
+
it("E12. internal newlines in entity-split segment are normalised", () => {
|
|
304
|
+
const code = "<WuButton>Hello\nWorld & Goodbye\nFriend</WuButton>";
|
|
305
|
+
const result = transform(code);
|
|
306
|
+
expect(result).toContain(`__i18nKey="Hello World"`);
|
|
307
|
+
expect(result).toContain(`__i18nKey="Goodbye Friend"`);
|
|
308
|
+
expect(result).not.toContain("Hello\\nWorld");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("E13. multiple spaces in entity-split segment are collapsed", () => {
|
|
312
|
+
const code = "<WuButton>Hello World & Foo</WuButton>";
|
|
313
|
+
const result = transform(code);
|
|
314
|
+
expect(result).toContain(`__i18nKey="Hello World"`);
|
|
315
|
+
expect(result).toContain(`__i18nKey="Foo"`);
|
|
316
|
+
expect(result).not.toContain(`__i18nKey="Hello World"`);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// fix 2: data-i18n-key bypass
|
|
320
|
+
it("E14. data-i18n-key with entity — warns and splits using text as keys", () => {
|
|
321
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
322
|
+
const code = `<WuButton data-i18n-key="btn">Hello & World</WuButton>`;
|
|
323
|
+
const result = transform(code);
|
|
324
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
325
|
+
expect.stringContaining('data-i18n-key="btn"'),
|
|
326
|
+
);
|
|
327
|
+
expect(result).toContain(`__i18nKey="Hello"`);
|
|
328
|
+
expect(result).toContain(`__i18nKey="World"`);
|
|
329
|
+
expect(result).not.toContain(`__i18nKey="btn"`);
|
|
330
|
+
warnSpy.mockRestore();
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("Mixed static + dynamic interpolation", () => {
|
|
335
|
+
describe("A: JSX mixed children", () => {
|
|
336
|
+
it("A1. text before dynamic → wraps leading text, keeps expr", () => {
|
|
337
|
+
// <WuButton>hello {name}</WuButton>
|
|
338
|
+
// children: JSXText("hello ") + JSXExpressionContainer(name)
|
|
339
|
+
const code = `<WuButton>hello {name}</WuButton>`;
|
|
340
|
+
const result = transform(code);
|
|
341
|
+
expect(result).toContain(`<WuTranslate __i18nKey="hello"></WuTranslate>`);
|
|
342
|
+
expect(result).toContain(`{name}`);
|
|
343
|
+
expect(result).not.toContain(`__i18nKey="name"`);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("A2. dynamic before text → keeps expr, wraps trailing text", () => {
|
|
347
|
+
// <WuButton>{name} how are you</WuButton>
|
|
348
|
+
const code = `<WuButton>{name} how are you</WuButton>`;
|
|
349
|
+
const result = transform(code);
|
|
350
|
+
expect(result).toContain(
|
|
351
|
+
`<WuTranslate __i18nKey="how are you"></WuTranslate>`,
|
|
352
|
+
);
|
|
353
|
+
expect(result).toContain(`{name}`);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("A3. text + dynamic + text → wraps both text segments independently", () => {
|
|
357
|
+
// <WuButton>hello {name} how are you</WuButton>
|
|
358
|
+
const code = `<WuButton>hello {name} how are you</WuButton>`;
|
|
359
|
+
const result = transform(code);
|
|
360
|
+
expect(result).toContain(`<WuTranslate __i18nKey="hello"></WuTranslate>`);
|
|
361
|
+
expect(result).toContain(
|
|
362
|
+
`<WuTranslate __i18nKey="how are you"></WuTranslate>`,
|
|
363
|
+
);
|
|
364
|
+
expect(result).toContain(`{name}`);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("A4. multiple dynamics with text between → wraps middle text", () => {
|
|
368
|
+
// <WuButton>{a} and {b}</WuButton>
|
|
369
|
+
const code = `<WuButton>{a} and {b}</WuButton>`;
|
|
370
|
+
const result = transform(code);
|
|
371
|
+
expect(result).toContain(`<WuTranslate __i18nKey="and"></WuTranslate>`);
|
|
372
|
+
expect(result).toContain(`{a}`);
|
|
373
|
+
expect(result).toContain(`{b}`);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("A5. text + two dynamics + text → wraps outer text, keeps both exprs", () => {
|
|
377
|
+
// <WuButton>hello {a} and {b} end</WuButton>
|
|
378
|
+
const code = `<WuButton>hello {a} and {b} end</WuButton>`;
|
|
379
|
+
const result = transform(code);
|
|
380
|
+
expect(result).toContain(`<WuTranslate __i18nKey="hello"></WuTranslate>`);
|
|
381
|
+
expect(result).toContain(`<WuTranslate __i18nKey="and"></WuTranslate>`);
|
|
382
|
+
expect(result).toContain(`<WuTranslate __i18nKey="end"></WuTranslate>`);
|
|
383
|
+
expect(result).toContain(`{a}`);
|
|
384
|
+
expect(result).toContain(`{b}`);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("A6. whitespace-only text between two dynamics → not wrapped", () => {
|
|
388
|
+
// <WuButton>{a} {b}</WuButton> — JSXText between them is only spaces
|
|
389
|
+
const code = `<WuButton>{a} {b}</WuButton>`;
|
|
390
|
+
const result = transform(code);
|
|
391
|
+
// whitespace-only JSXText must not produce a key
|
|
392
|
+
expect(result).not.toContain(`WuTranslate`);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// ── B: Template literal with expressions ──────────────────────────────────
|
|
397
|
+
// Currently the plugin skips any TemplateLiteral whose .expressions.length > 0.
|
|
398
|
+
// The expected output reconstructs the container as a React fragment:
|
|
399
|
+
// {`hello ${name}`} → <><WuTranslate __i18nKey="hello" />{name}</>
|
|
400
|
+
//
|
|
401
|
+
// All tests in this group are expected to FAIL until the feature lands.
|
|
402
|
+
|
|
403
|
+
describe("B: Template literal with expressions", () => {
|
|
404
|
+
it("B1. text before dynamic → wraps leading quasi, keeps expr", () => {
|
|
405
|
+
// {`hello ${name}`} → <><WuTranslate __i18nKey="hello" />{name}</>
|
|
406
|
+
const code = "<WuButton>{`hello ${name}`}</WuButton>";
|
|
407
|
+
const result = transform(code);
|
|
408
|
+
expect(result).toContain(`<WuTranslate __i18nKey="hello"></WuTranslate>`);
|
|
409
|
+
expect(result).toContain(`{name}`);
|
|
410
|
+
expect(result).not.toContain('__i18nKey="name"');
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("B2. dynamic before text → keeps expr, wraps trailing quasi", () => {
|
|
414
|
+
// {`${name} how are you`} → <>{name}<WuTranslate __i18nKey="how are you" /></>
|
|
415
|
+
const code = "<WuButton>{`${name} how are you`}</WuButton>";
|
|
416
|
+
const result = transform(code);
|
|
417
|
+
expect(result).toContain(
|
|
418
|
+
`<WuTranslate __i18nKey="how are you"></WuTranslate>`,
|
|
419
|
+
);
|
|
420
|
+
expect(result).toContain(`{name}`);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("B3. text + dynamic + text → wraps both quasis, keeps expr in between", () => {
|
|
424
|
+
// {`hello ${name} how are you`}
|
|
425
|
+
// → <><WuTranslate __i18nKey="hello" />{name}<WuTranslate __i18nKey="how are you" /></>
|
|
426
|
+
const code = "<WuButton>{`hello ${name} how are you`}</WuButton>";
|
|
427
|
+
const result = transform(code);
|
|
428
|
+
expect(result).toContain(`<WuTranslate __i18nKey="hello"></WuTranslate>`);
|
|
429
|
+
expect(result).toContain(
|
|
430
|
+
`<WuTranslate __i18nKey="how are you"></WuTranslate>`,
|
|
431
|
+
);
|
|
432
|
+
expect(result).toContain(`{name}`);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("B4. two dynamics with static text between → wraps middle quasi only", () => {
|
|
436
|
+
// {`${a} and ${b}`} → <>{a}<WuTranslate __i18nKey="and" />{b}</>
|
|
437
|
+
const code = "<WuButton>{`${a} and ${b}`}</WuButton>";
|
|
438
|
+
const result = transform(code);
|
|
439
|
+
expect(result).toContain(`<WuTranslate __i18nKey="and"></WuTranslate>`);
|
|
440
|
+
expect(result).toContain(`{a}`);
|
|
441
|
+
expect(result).toContain(`{b}`);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("B5. text + two dynamics + text → wraps outer quasis and middle quasi", () => {
|
|
445
|
+
// {`hello ${a} and ${b} end`}
|
|
446
|
+
// → <><WuTranslate key="hello" />{a}<WuTranslate key="and" />{b}<WuTranslate key="end" /></>
|
|
447
|
+
const code = "<WuButton>{`hello ${a} and ${b} end`}</WuButton>";
|
|
448
|
+
const result = transform(code);
|
|
449
|
+
expect(result).toContain(`<WuTranslate __i18nKey="hello"></WuTranslate>`);
|
|
450
|
+
expect(result).toContain(`<WuTranslate __i18nKey="and"></WuTranslate>`);
|
|
451
|
+
expect(result).toContain(`<WuTranslate __i18nKey="end"></WuTranslate>`);
|
|
452
|
+
expect(result).toContain(`{a}`);
|
|
453
|
+
expect(result).toContain(`{b}`);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("B6. empty quasis between two dynamics → no key emitted for empty segment", () => {
|
|
457
|
+
// {`${a}${b}`} → <>{a}{b}</>
|
|
458
|
+
// quasis: ["", "", ""] — all empty, nothing to wrap
|
|
459
|
+
const code = "<WuButton>{`${a}${b}`}</WuButton>";
|
|
460
|
+
const result = transform(code);
|
|
461
|
+
// no translation key for empty/whitespace quasis
|
|
462
|
+
expect(result).not.toContain(`__i18nKey`);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("B7. whitespace-only quasi between dynamics → not wrapped", () => {
|
|
466
|
+
// {`${a} ${b}`} → <>{a} {b}</>
|
|
467
|
+
const code = "<WuButton>{`${a} ${b}`}</WuButton>";
|
|
468
|
+
const result = transform(code);
|
|
469
|
+
expect(result).not.toContain(`WuTranslate`);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("B8. ignored component — template literal skipped entirely", () => {
|
|
473
|
+
const code = "<WuIcon>{`hello ${name}`}</WuIcon>";
|
|
474
|
+
const result = transform(code);
|
|
475
|
+
expect(result).not.toContain(`WuTranslate`);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("B9. data-skip — template literal skipped entirely", () => {
|
|
479
|
+
const code = "<WuButton data-skip>{`hello ${name}`}</WuButton>";
|
|
480
|
+
const result = transform(code);
|
|
481
|
+
expect(result).not.toContain(`WuTranslate`);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("B10. output is a React fragment wrapping the interleaved nodes", () => {
|
|
485
|
+
// the entire {`...`} container is replaced with <> ... </>
|
|
486
|
+
const code = "<WuButton>{`hello ${name}`}</WuButton>";
|
|
487
|
+
const result = transform(code);
|
|
488
|
+
expect(result).toMatch(/<>.*WuTranslate.*<\/>/s);
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// ─── wt() call expressions ───────────────────────────────────────────────────────
|
|
494
|
+
//
|
|
495
|
+
// wt("static string") is a runtime lookup helper exposed from useTranslate().
|
|
496
|
+
// The plugin’s job is to record static arguments into wick-ui-i18n.json so
|
|
497
|
+
// they reach the translation API. No code transformation is performed.
|
|
498
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
describe("wt() call expressions", () => {
|
|
501
|
+
it("W1. string literal arg is recorded in dictionary", () => {
|
|
502
|
+
const dict = getDictionary(`const text = wt("hello");`);
|
|
503
|
+
expect(dict).toHaveProperty("hello", "hello");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("W2. static template literal arg is recorded", () => {
|
|
507
|
+
const dict = getDictionary("const text = wt(`hello`);");
|
|
508
|
+
expect(dict).toHaveProperty("hello", "hello");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("W3. dynamic variable arg is ignored", () => {
|
|
512
|
+
const dict = getDictionary(`const text = wt(variable);`);
|
|
513
|
+
expect(dict).not.toHaveProperty("variable");
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("W4. template literal with expressions — static quasis extracted", () => {
|
|
517
|
+
// wt(`hello ${name}`) → `${wt("hello")} ${name}`
|
|
518
|
+
const dict = getDictionary("const text = wt(`hello ${name}`);");
|
|
519
|
+
expect(dict).toHaveProperty("hello", "hello");
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("W11. template literal — call is transformed into nested wt() calls", () => {
|
|
523
|
+
const result = transform("const text = wt(`hello ${name}`);");
|
|
524
|
+
expect(result).toContain('wt("hello")');
|
|
525
|
+
expect(result).not.toContain("wt(`hello ${name}`)");
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("W12. text before expr → leading quasi wrapped, expr kept", () => {
|
|
529
|
+
const result = transform("const t = wt(`hello ${name}`);");
|
|
530
|
+
expect(result).toContain('wt("hello")');
|
|
531
|
+
expect(result).toContain("${name}");
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("W13. text after expr → trailing quasi wrapped, expr kept", () => {
|
|
535
|
+
const result = transform("const t = wt(`${name} how are you`);");
|
|
536
|
+
expect(result).toContain('wt("how are you")');
|
|
537
|
+
expect(result).toContain("${name}");
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("W14. text + expr + text → both quasis wrapped, expr in between", () => {
|
|
541
|
+
const result = transform("const t = wt(`hello ${name} how are you`);");
|
|
542
|
+
expect(result).toContain('wt("hello")');
|
|
543
|
+
expect(result).toContain('wt("how are you")');
|
|
544
|
+
expect(result).toContain("${name}");
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("W15. only expressions, no static text → no transform", () => {
|
|
548
|
+
const result = transform("const t = wt(`${a}${b}`);");
|
|
549
|
+
expect(result).not.toContain("wt(\"");
|
|
550
|
+
// original call preserved as-is
|
|
551
|
+
expect(result).toContain("wt(`${a}${b}`)");
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("W16. whitespace-only quasi between exprs → no transform", () => {
|
|
555
|
+
const result = transform("const t = wt(`${a} ${b}`);");
|
|
556
|
+
expect(result).not.toContain("wt(\"");
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("W5. multiple wt() calls in same file all recorded", () => {
|
|
560
|
+
const dict = getDictionary(`const a = wt("hello"); const b = wt("world");`);
|
|
561
|
+
expect(dict).toHaveProperty("hello", "hello");
|
|
562
|
+
expect(dict).toHaveProperty("world", "world");
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("W6. whitespace in arg is normalised", () => {
|
|
566
|
+
const dict = getDictionary(`const text = wt("hello world");`);
|
|
567
|
+
expect(dict).toHaveProperty("hello world", "hello world");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("W7. wt() call inside JSX expression is recorded", () => {
|
|
571
|
+
const dict = getDictionary(`<WuButton>{wt("hello")}</WuButton>`);
|
|
572
|
+
expect(dict).toHaveProperty("hello", "hello");
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("W8. code is not transformed — wt() call left as-is", () => {
|
|
576
|
+
const result = transform(`const text = wt("hello");`);
|
|
577
|
+
expect(result).not.toContain("WuTranslate");
|
|
578
|
+
expect(result).toContain(`wt("hello")`);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("W9. wt() and JSX translations coexist in same file", () => {
|
|
582
|
+
const code = `<WuButton>{wt("save")}</WuButton>`;
|
|
583
|
+
const dict = getDictionary(code);
|
|
584
|
+
// wt() arg recorded
|
|
585
|
+
expect(dict).toHaveProperty("save", "save");
|
|
586
|
+
// code still not transformed (no WuTranslate wrapping the wt call)
|
|
587
|
+
const result = transform(code);
|
|
588
|
+
expect(result).toContain(`wt("save")`);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it("W10. empty string arg is ignored", () => {
|
|
592
|
+
const dict = getDictionary(`const text = wt("");`);
|
|
593
|
+
expect(Object.keys(dict)).toHaveLength(0);
|
|
594
|
+
});
|
|
187
595
|
});
|