@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 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("/wick-ui-i18n.json", (_req, res) => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@npm-questionpro/wick-ui-i18n",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "license": "ISC",
5
5
  "description": "Auto-translation AST wrapper for Wick UI",
6
6
  "type": "module",
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 &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
+ }
@@ -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
- describe("Wick UI i18n Vite Plugin", () => {
12
- // ─── Core translation ──────────────────────────────────────────────────────
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(`<WuTranslate __i18nKey="btn_login"></WuTranslate>`);
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 (&nbsp;, &amp;, &copy;…)
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>&amp;</WuButton>`;
218
+ const result = transform(code);
219
+ expect(result).not.toContain("WuTranslate");
220
+ expect(result).toContain("&amp;");
221
+ });
222
+
223
+ it("E2. text mixed with entity — non-entity parts wrapped, entity preserved", () => {
224
+ const code = `<WuButton>Hello &amp; 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("&amp;");
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 &amp; World"`);
232
+ });
233
+
234
+ it("E3. non-breaking space entity is not wrapped", () => {
235
+ const code = `<WuButton>&nbsp;</WuButton>`;
236
+ const result = transform(code);
237
+ expect(result).not.toContain("WuTranslate");
238
+ });
239
+
240
+ it("E4. numeric decimal entity is not wrapped", () => {
241
+ // &#169; = ©
242
+ const code = `<WuButton>&#169;</WuButton>`;
243
+ const result = transform(code);
244
+ expect(result).not.toContain("WuTranslate");
245
+ });
246
+
247
+ it("E5. numeric hex entity is not wrapped", () => {
248
+ // &#x00A9; = ©
249
+ const code = `<WuButton>&#x00A9;</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 &amp; 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 &amp;" : "No"}</WuButton>`;
262
+ const result = transform(code);
263
+ expect(result).not.toContain(`__i18nKey="Yes &amp;"`);
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}&amp;{y} World</WuButton>
269
+ // JSXText nodes: "Hello ", "&amp;", " World"
270
+ // Babel decodes &amp; → "&" in .value, so check both forms are absent
271
+ const code = `<WuButton>Hello {x}&amp;{y} World</WuButton>`;
272
+ const result = transform(code);
273
+ expect(result).toContain(`<WuTranslate __i18nKey="Hello"></WuTranslate>`);
274
+ expect(result).not.toContain(`__i18nKey="&amp;"`);
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
+ // &lt;Tag&gt; → &lt;<WuTranslate key="Tag" />&gt;
281
+ const code = `<WuButton>&lt;Tag&gt;</WuButton>`;
282
+ const result = transform(code);
283
+ expect(result).toContain(`<WuTranslate __i18nKey="Tag"></WuTranslate>`);
284
+ expect(result).toContain("&lt;");
285
+ expect(result).toContain("&gt;");
286
+ });
287
+
288
+ it("E10. entity at boundaries — text in middle still wrapped", () => {
289
+ // &nbsp;Hello&nbsp; → &nbsp;<WuTranslate key="Hello" />&nbsp;
290
+ const code = `<WuButton>&nbsp;Hello&nbsp;</WuButton>`;
291
+ const result = transform(code);
292
+ expect(result).toContain(`<WuTranslate __i18nKey="Hello"></WuTranslate>`);
293
+ expect(result).toContain("&nbsp;");
294
+ });
295
+
296
+ it("E11. only entities, no text — nothing wrapped", () => {
297
+ const code = `<WuButton>&lt;&gt;</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 &amp; 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 &amp; 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 &amp; 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
  });