@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.
@@ -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 (&amp;), decimal (&#169;), or hex (&#x00A9;).
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 &amp; → "&", &nbsp; → "\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 "&amp;" 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 (&amp; → "&", &#169; → "©").
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 &amp; World</WuButton>
8
+ *
9
+ * // Output
10
+ * <WuButton><WuTranslate __i18nKey="Hello" /> &amp; <WuTranslate __i18nKey="World" /></WuButton>
11
+ *
12
+ * @example
13
+ * // Input
14
+ * <WuButton>&lt;Tag&gt;</WuButton>
15
+ *
16
+ * // Output
17
+ * <WuButton>&lt;<WuTranslate __i18nKey="Tag" />&gt;</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 &amp; World" → ["Hello ", "&amp;", " World"]
27
+ * "&lt;Tag&gt;" → ["", "&lt;", "Tag", "&gt;", ""]
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
+ }