@npm-questionpro/wick-ui-i18n 0.8.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 +80 -163
- package/package.json +1 -1
- package/src/debug.js +80 -0
- package/src/processor.js +153 -0
- package/src/transform.js +224 -0
- package/src/transformJSXTextWithEntities.js +127 -0
- package/src/transformTemplateLiteral.js +90 -0
- package/src/transformWtCalls.js +136 -0
- package/wickuii18n.test.js +528 -10
package/src/transform.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
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
|
+
import { transformTemplateLiteralExpression } from "./transformTemplateLiteral.js";
|
|
11
|
+
import { transformJSXTextWithEntities } from "./transformJSXTextWithEntities.js";
|
|
12
|
+
import { recordWtCall, transformWtTemplateLiteral } from "./transformWtCalls.js";
|
|
13
|
+
|
|
14
|
+
const traverse = _traverse.default || _traverse;
|
|
15
|
+
|
|
16
|
+
/** Babel parser plugins applied to every file. */
|
|
17
|
+
const BABEL_PLUGINS = ["jsx", "typescript"];
|
|
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
|
+
|
|
27
|
+
/** @param {import('@babel/types').Node} node @returns {string|null} */
|
|
28
|
+
function getStaticString(node) {
|
|
29
|
+
if (node.type === "StringLiteral") return node.value;
|
|
30
|
+
if (node.type === "TemplateLiteral" && !node.expressions.length)
|
|
31
|
+
return node.quasis[0].value.cooked;
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Replace a translatable text node with a `<WuTranslate>` element and record
|
|
37
|
+
* the key in the processor's dictionary.
|
|
38
|
+
*
|
|
39
|
+
* @param {import('@babel/traverse').NodePath} path - Path of the text node.
|
|
40
|
+
* @param {string} text - Trimmed text content.
|
|
41
|
+
* @param {number} start - Absolute start offset in source.
|
|
42
|
+
* @param {number} end - Absolute end offset in source.
|
|
43
|
+
* @param {MagicString} ms - Mutable source string.
|
|
44
|
+
* @param {import('./processor.js').TranslationProcessor} processor
|
|
45
|
+
* @param {string} id - File path (for collision warnings).
|
|
46
|
+
* @returns {boolean} `true` when replacement was made.
|
|
47
|
+
*/
|
|
48
|
+
function handleCapture(path, text, start, end, ms, processor, id, skipExplicitKey = false) {
|
|
49
|
+
const cleanText = text.trim().replace(/\n/g, " ").replace(/\s{2,}/g, " ");
|
|
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;
|
|
54
|
+
|
|
55
|
+
const key = (!skipExplicitKey && processor.getExplicitKey(path)) || cleanText;
|
|
56
|
+
processor.record(key, cleanText, id, getComponentTree(path));
|
|
57
|
+
|
|
58
|
+
ms.overwrite(
|
|
59
|
+
start,
|
|
60
|
+
end,
|
|
61
|
+
`<WuTranslate __i18nKey=${JSON.stringify(key)}></WuTranslate>`,
|
|
62
|
+
);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse `code`, replace all translatable JSX text nodes, and prepend the
|
|
68
|
+
* `WuTranslate` import when necessary.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} code - Raw source of the file.
|
|
71
|
+
* @param {string} id - File path.
|
|
72
|
+
* @param {import('./processor.js').TranslationProcessor} processor
|
|
73
|
+
* @returns {{ code: string, map: object } | null} Transformed result, or
|
|
74
|
+
* `null` when no changes were made.
|
|
75
|
+
*/
|
|
76
|
+
export function transformFile(code, id, processor) {
|
|
77
|
+
const ast = parse(code, {
|
|
78
|
+
sourceType: "module",
|
|
79
|
+
plugins: BABEL_PLUGINS,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const ms = new MagicString(code);
|
|
83
|
+
let needsImport = false;
|
|
84
|
+
let hasImport = false;
|
|
85
|
+
let hasWtTransform = false;
|
|
86
|
+
|
|
87
|
+
traverse(ast, {
|
|
88
|
+
/**
|
|
89
|
+
* Track whether `WuTranslate` is already imported from wick-ui-lib so we
|
|
90
|
+
* don't duplicate the import statement.
|
|
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
|
+
|
|
103
|
+
ImportDeclaration(path) {
|
|
104
|
+
if (path.node.source.value.includes("wick-ui-lib")) {
|
|
105
|
+
hasImport = path.node.specifiers.some(
|
|
106
|
+
(s) => s.imported?.name === "WuTranslate",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
/** Plain JSX text: `<Foo>Hello world</Foo>` */
|
|
112
|
+
JSXText(path) {
|
|
113
|
+
const text = path.node.value;
|
|
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
|
+
}
|
|
125
|
+
const start = path.node.start + text.indexOf(trimmed);
|
|
126
|
+
if (
|
|
127
|
+
handleCapture(
|
|
128
|
+
path,
|
|
129
|
+
trimmed,
|
|
130
|
+
start,
|
|
131
|
+
start + trimmed.length,
|
|
132
|
+
ms,
|
|
133
|
+
processor,
|
|
134
|
+
id,
|
|
135
|
+
)
|
|
136
|
+
) {
|
|
137
|
+
needsImport = true;
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* JSX expression containers with a static string value:
|
|
143
|
+
* `<Foo>{"Hello"}</Foo>` or `<Foo>{\`Hello\`}</Foo>`
|
|
144
|
+
*
|
|
145
|
+
* Attribute values (`variant={'secondary'}`) are skipped — their parent is
|
|
146
|
+
* a JSXAttribute, not a JSXElement child position.
|
|
147
|
+
*/
|
|
148
|
+
JSXExpressionContainer(path) {
|
|
149
|
+
if (path.parent.type === "JSXAttribute") return;
|
|
150
|
+
|
|
151
|
+
const expr = path.node.expression;
|
|
152
|
+
let text = null;
|
|
153
|
+
|
|
154
|
+
if (expr.type === "StringLiteral") {
|
|
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;
|
|
166
|
+
} else if (expr.type === "TemplateLiteral" && !expr.expressions.length) {
|
|
167
|
+
text = expr.quasis[0].value.cooked;
|
|
168
|
+
} else if (expr.type === "ConditionalExpression") {
|
|
169
|
+
const consText = getStaticString(expr.consequent);
|
|
170
|
+
const altText = getStaticString(expr.alternate);
|
|
171
|
+
let changed = false;
|
|
172
|
+
if (consText !== null)
|
|
173
|
+
changed =
|
|
174
|
+
handleCapture(
|
|
175
|
+
path,
|
|
176
|
+
consText,
|
|
177
|
+
expr.consequent.start,
|
|
178
|
+
expr.consequent.end,
|
|
179
|
+
ms,
|
|
180
|
+
processor,
|
|
181
|
+
id,
|
|
182
|
+
true,
|
|
183
|
+
) || changed;
|
|
184
|
+
if (altText !== null)
|
|
185
|
+
changed =
|
|
186
|
+
handleCapture(
|
|
187
|
+
path,
|
|
188
|
+
altText,
|
|
189
|
+
expr.alternate.start,
|
|
190
|
+
expr.alternate.end,
|
|
191
|
+
ms,
|
|
192
|
+
processor,
|
|
193
|
+
id,
|
|
194
|
+
true,
|
|
195
|
+
) || changed;
|
|
196
|
+
if (changed) needsImport = true;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (
|
|
201
|
+
text &&
|
|
202
|
+
handleCapture(
|
|
203
|
+
path,
|
|
204
|
+
text,
|
|
205
|
+
path.node.start,
|
|
206
|
+
path.node.end,
|
|
207
|
+
ms,
|
|
208
|
+
processor,
|
|
209
|
+
id,
|
|
210
|
+
)
|
|
211
|
+
) {
|
|
212
|
+
needsImport = true;
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
if (!needsImport && !hasWtTransform) return null;
|
|
218
|
+
|
|
219
|
+
if (!hasImport) {
|
|
220
|
+
ms.prepend(`import { WuTranslate } from '@npm-questionpro/wick-ui-lib';\n`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { code: ms.toString(), map: ms.generateMap({ hires: true }) };
|
|
224
|
+
}
|
|
@@ -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
|
+
}
|