@npm-questionpro/wick-ui-i18n 0.10.0 → 0.14.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/README.md CHANGED
@@ -1,78 +1,104 @@
1
- # Wick UI Auto I18n
1
+ # wick-ui-i18n
2
2
 
3
- **Build-time extractor that wraps static strings in `<WuTranslate>` and generates a JSON dictionary.**
3
+ Vite plugin wraps JSX text in Wu components with `<WuTranslate>`, rewrites
4
+ translatable props to `{wt("...")}`, and emits `wick-ui-i18n.json`.
4
5
 
5
6
  ---
6
7
 
7
- ### Quick Start
8
-
9
- ```javascript
10
- import wickuiI18nPlugin from "@npm-questionpro/wick-ui-i18n";
11
- export default {
12
- plugins: [wickuiI18nPlugin({ debug: true })],
13
- };
14
- ```
15
-
16
- ---
17
-
18
- ### 🛠 Attributes
19
-
20
- - **`data-i18n-wrapper`**: Forces extraction on non-Wu components (e.g., `<div data-i18n-wrapper>Text</div>`).
21
- - **`data-i18n-key="custom.key"`**: Overrides the default "text-as-key" behavior.
22
- - **`data-i18n-skip`**: Prevents the plugin from touching the element or its children.
23
-
24
- ---
25
-
26
- ### 📖 Rules & Logic
27
-
28
- - **Auto-Detection**: Targets all `Wu*` prefixed components by default.
29
- - **Supported**: Static `JSXText`, `{"Strings"}`, and non-dynamic `{`Template Literals`}`.
30
- - **Ignored**: Any expression containing variables (e.g., `{`Hello ${name}`}`).
31
- - **Output**: Generates `dist/wick_extracted_strings.json` during bundle phase.
8
+ ## JSX text
9
+
10
+ | Input | Output |
11
+ |---|---|
12
+ | `<WuButton>Hello</WuButton>` | ✅ `<WuTranslate __i18nKey="Hello" />` |
13
+ | `<WuIcon>star</WuIcon>` | |
14
+ | `<div>Hello</div>` | ❌ |
15
+ | `<WuButton data-skip>Hello</WuButton>` | ❌ |
16
+ | `<span data-i18n-wrapper>Hello</span>` | ✅ `<WuTranslate __i18nKey="Hello" />` |
17
+ | `<WuButton data-i18n-key="k">Hello</WuButton>` | ✅ `<WuTranslate __i18nKey="k" />` |
18
+ | `<WuButton>&amp;</WuButton>` | ❌ |
19
+ | `<WuButton>Hello &amp; World</WuButton>` | ✅ `<WuTranslate __i18nKey="Hello" /> &amp; <WuTranslate __i18nKey="World" />` |
20
+
21
+ ## JSX string expressions
22
+
23
+ | Input | Output |
24
+ |---|---|
25
+ | `<WuButton>{"Hello"}</WuButton>` | ✅ `<WuTranslate __i18nKey="Hello" />` |
26
+ | `` <WuButton>{`Hello`}</WuButton> `` | ✅ `<WuTranslate __i18nKey="Hello" />` |
27
+ | `<WuButton>{variable}</WuButton>` | |
28
+
29
+ ## JSX ternaries
30
+
31
+ | Input | Output |
32
+ |---|---|
33
+ | `<WuButton>{flag ? "Yes" : "No"}</WuButton>` | ✅ `{flag ? <WuTranslate __i18nKey="Yes" /> : <WuTranslate __i18nKey="No" />}` |
34
+ | `<WuButton>{flag ? "Yes" : variable}</WuButton>` | ✅ `{flag ? <WuTranslate __i18nKey="Yes" /> : variable}` |
35
+ | `<WuButton>{flag ? variable : variable}</WuButton>` | ❌ |
36
+ | `<WuButton>{a ? "A" : b ? "B" : "C"}</WuButton>` | ✅ `{a ? <WuTranslate __i18nKey="A" /> : b ? <WuTranslate __i18nKey="B" /> : <WuTranslate __i18nKey="C" />}` |
37
+
38
+ ## JSX template literals with expressions
39
+
40
+ | Input | Output |
41
+ |---|---|
42
+ | `` <WuButton>{`Hello ${name}`}</WuButton> `` | ✅ `<><WuTranslate __i18nKey="Hello" /> {name}</>` |
43
+ | `` <WuButton>{`${name} world`}</WuButton> `` | ✅ `<>{name} <WuTranslate __i18nKey="world" /></>` |
44
+ | `` <WuButton>{`Hello ${a} and ${b}`}</WuButton> `` | ✅ `<><WuTranslate __i18nKey="Hello" /> {a} <WuTranslate __i18nKey="and" /> {b}</>` |
45
+ | `` <WuButton>{`${a}${b}`}</WuButton> `` | ❌ |
46
+
47
+ ## JSX mixed children
48
+
49
+ | Input | Output |
50
+ |---|---|
51
+ | `<WuButton>Hello {name}</WuButton>` | ✅ `<WuTranslate __i18nKey="Hello" /> {name}` |
52
+ | `<WuButton>{a} and {b}</WuButton>` | ✅ `{a} <WuTranslate __i18nKey="and" /> {b}` |
53
+ | `<WuButton>{a} {b}</WuButton>` | ❌ |
54
+
55
+ ## JSX props
56
+
57
+ Defaults: `Label`, `placeholder`, `title`, `aria-label`, `aria-placeholder`.
58
+
59
+ | Input | Output |
60
+ |---|---|
61
+ | `<WuField Label="First name" />` | ✅ `Label={wt("First name")}` |
62
+ | `<WuInput placeholder="Enter name" />` | ✅ `placeholder={wt("Enter name")}` |
63
+ | `<WuDialog title="Confirm?" />` | ✅ `title={wt("Confirm?")}` |
64
+ | `<WuField Label={variable} />` | ❌ |
65
+ | `<WuField Label="" />` | ❌ |
66
+ | `<WuIcon Label="x" />` | ❌ |
67
+ | `<input placeholder="x" />` | ❌ |
68
+ | `<WuField data-skip Label="x" />` | ❌ |
69
+
70
+ ## `wt()` calls
71
+
72
+ Plugin records static args into `wick-ui-i18n.json`. No code rewrite unless template literal with expressions.
73
+
74
+ | Input | Dictionary | Code output |
75
+ |---|---|---|
76
+ | `wt("Hello")` | ✅ `"Hello"` | `wt("Hello")` |
77
+ | `` wt(`Hello`) `` | ✅ `"Hello"` | `` wt(`Hello`) `` |
78
+ | `` wt(`Hello ${name}`) `` | ✅ `"Hello"` | `` `${wt("Hello")} ${name}` `` |
79
+ | `` wt(`Hello ${a} and ${b}`) `` | ✅ `"Hello"`, `"and"` | `` `${wt("Hello")} ${a} ${wt("and")} ${b}` `` |
80
+ | `wt(variable)` | ❌ | — |
81
+ | `` wt(`${a}${b}`) `` | ❌ | — |
82
+
83
+ ## Data files (`extractFromKeys` option)
84
+
85
+ No code rewrite — keys only recorded in `wick-ui-i18n.json`.
86
+
87
+ | Input | Dictionary |
88
+ |---|---|
89
+ | `{ label: 'Analytics' }` + `extractFromKeys: ['label']` | ✅ `"Analytics"` |
90
+ | `{ label: variable }` | ❌ |
91
+ | `{ label: '' }` | ❌ |
32
92
 
33
93
  ---
34
94
 
35
- ### 🔄 Logic Map (Pseudo-code)
36
-
37
- ```text
38
- FOR each File in the Project:
39
- IF File matches IncludeFilter AND contains "Wu":
40
- PARSE File into an AST (Abstract Syntax Tree)
41
- INITIALIZE MagicString (for non-destructive editing)
42
-
43
- TRAVERSE the AST:
44
-
45
- // 1. IMPORT CHECK
46
- IF Node is an Import from '@wick-ui-lib':
47
- MARK 'WuTranslate' as already imported if found
48
-
49
- // 2. STRING DISCOVERY
50
- IF Node is JSXText OR (JSXExpressionContainer WITH StaticString):
51
- SET CandidateText = Node.Value
52
-
53
- // 3. HIERARCHY EVALUATION (The "shouldTranslate" logic)
54
- WALK UP from Node to Parents:
55
- IF Parent has [data-skip] attribute:
56
- ABORT (Don't translate this node)
57
-
58
- IF Parent has [data-i18n-wrapper] attribute:
59
- MARK as "Valid Target" and STOP walking up
60
-
61
- IF Parent.Name starts with "Wu" OR is in CustomList:
62
- IF Parent.Name is NOT in IgnoreList:
63
- MARK as "Valid Target" and STOP walking up
64
-
65
- // 4. TRANSFORMATION
66
- IF "Valid Target" was found:
67
- GET ExplicitKey from [data-i18n-key] OR USE CandidateText
68
- STORE { Key, CandidateText } in GlobalDictionary
69
- OVERWRITE original code with:
70
- `<WuTranslate __i18nKey="Key">OriginalText</WuTranslate>`
71
- SET NeedsImport = True
72
-
73
- // 5. FINAL ASSEMBLY
74
- IF NeedsImport AND NOT 'WuTranslate' Imported:
75
- PREPEND import statement to top of file
95
+ ## Options
76
96
 
77
- RETURN Modified Code + Source Maps
78
- ```
97
+ | Option | Default | Description |
98
+ |---|---|---|
99
+ | `components` | `[]` | Extra components treated like Wu* |
100
+ | `ignoreComponents` | `[]` | Extra components never translated |
101
+ | `translatableProps` | `['Label','placeholder','title','aria-label','aria-placeholder']` | Props rewritten to `wt()` |
102
+ | `extractFromKeys` | `[]` | Object keys extracted into dictionary |
103
+ | `excludeFiles` | — | Files skipped entirely |
104
+ | `debug` | `false` | Log transforms to console |
package/index.d.ts CHANGED
@@ -1,28 +1,37 @@
1
- export interface AutoTranslateOptions {
1
+ import type {Plugin} from 'vite'
2
+
3
+ export interface WickI18nOptions {
4
+ /** Extra component names to translate (in addition to Wu* components). */
5
+ components?: string[]
6
+
7
+ /** Component names to exclude from translation. */
8
+ ignoreComponents?: string[]
9
+
2
10
  /**
3
- * List of components to extract text from.
4
- * If empty, matches all Wu* components automatically.
11
+ * JSX prop names rewritten to `{wt("...")}` on Wu* components.
12
+ * @default ['Label', 'placeholder', 'title', 'aria-label', 'aria-placeholder']
5
13
  */
6
- components?: string[];
14
+ translatableProps?: string[]
7
15
 
8
16
  /**
9
- * List of components to ignore text from.
17
+ * Object property names whose string values are extracted into
18
+ * `wick-ui-i18n.json` without rewriting code. Use for data files
19
+ * (nav items, option lists, etc.) paired with `wt(item.label)` at render time.
10
20
  */
11
- ignoreComponents?: string[];
21
+ extractFromKeys?: string[]
12
22
 
13
- // /**
14
- // * Files to include in the transformation.
15
- // * Can be string, RegExp, or array of string/RegExp.
16
- // */
17
- // include?: any;
23
+ /** Files to skip entirely. Passed to Vite's `createFilter` as `exclude`. */
24
+ excludeFiles?: string | RegExp | Array<string | RegExp>
18
25
 
19
- /**
20
- * Enable debug logging for extraction process.
21
- */
22
- debug?: boolean;
26
+ /** Log every transform to the console. */
27
+ debug?: boolean
23
28
  }
24
29
 
25
30
  /**
26
- * Vite plugin that extracts text from Wick UI components and wraps them in WuTranslate.
31
+ * Vite plugin that automatically translates Wick UI components.
32
+ *
33
+ * - JSX text content inside Wu* → `<WuTranslate __i18nKey="..." />`
34
+ * - Translatable props (placeholder, title, Label, …) → `{wt("...")}`
35
+ * - Emits `wick-ui-i18n.json` with all extracted keys
27
36
  */
28
- export default function autoTranslate(options?: AutoTranslateOptions): any;
37
+ export default function wickuiI18nPlugin(options?: WickI18nOptions): Plugin
package/index.js CHANGED
@@ -27,6 +27,8 @@ import { printReport } from "./src/debug.js";
27
27
  * @typedef {object} WickI18nOptions
28
28
  * @property {string[]} [components] - Extra component names that trigger translation.
29
29
  * @property {string[]} [ignoreComponents] - Component names to exclude from translation.
30
+ * @property {string[]} [translatableProps] - JSX prop names rewritten to `{wt("...")}`. Defaults to `['Label','placeholder','title','aria-label','aria-placeholder']`.
31
+ * @property {string[]} [extractFromKeys] - Object property names whose string values are extracted as translation keys (e.g. ['label']).
30
32
  * @property {string|string[]|RegExp} [excludeFiles] - Files to skip (passed as `exclude` to Vite's createFilter).
31
33
  * @property {boolean} [debug] - Log transform activity to the console.
32
34
  */
@@ -41,10 +43,12 @@ export default function wickuiI18nPlugin(options = {}) {
41
43
  const processor = new TranslationProcessor({
42
44
  components: options.components || [],
43
45
  ignoreComponents: options.ignoreComponents,
46
+ translatableProps: options.translatableProps,
47
+ extractFromKeys: options.extractFromKeys,
44
48
  debug: options.debug,
45
49
  });
46
50
 
47
- const filter = createFilter([/\.(jsx|tsx)$/], options.excludeFiles);
51
+ const filter = createFilter([/\.(jsx|tsx|ts)$/], options.excludeFiles);
48
52
 
49
53
  let base = "/";
50
54
 
@@ -79,7 +83,14 @@ export default function wickuiI18nPlugin(options = {}) {
79
83
  * @param {string} id
80
84
  */
81
85
  transform(code, id) {
82
- if (!filter(id) || (!code.includes("Wu") && !/\bwt\(/.test(code))) return null;
86
+ const hasAnyTarget =
87
+ code.includes("Wu") ||
88
+ /\bwt\(/.test(code) ||
89
+ (processor.components.size > 0 &&
90
+ [...processor.components].some(c => code.includes(c))) ||
91
+ (processor.extractFromKeys.size > 0 &&
92
+ [...processor.extractFromKeys].some(k => code.includes(k)));
93
+ if (!filter(id) || !hasAnyTarget) return null;
83
94
  return transformFile(code, id, processor);
84
95
  },
85
96
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@npm-questionpro/wick-ui-i18n",
3
- "version": "0.10.0",
3
+ "version": "0.14.0",
4
4
  "license": "ISC",
5
5
  "description": "Auto-translation AST wrapper for Wick UI",
6
6
  "type": "module",
package/src/processor.js CHANGED
@@ -3,6 +3,9 @@
3
3
  * decides which JSX nodes should be wrapped with WuTranslate.
4
4
  */
5
5
 
6
+ /** Prop names translated by default on Wu* components. Pass `translatableProps` to override. */
7
+ const DEFAULT_TRANSLATABLE_PROPS = ['Label', 'placeholder', 'title', 'aria-label', 'aria-placeholder']
8
+
6
9
  /** Components always excluded from translation regardless of user config. */
7
10
  const DEFAULT_IGNORE = [
8
11
  "WuIcon",
@@ -21,16 +24,24 @@ const DEFAULT_IGNORE = [
21
24
 
22
25
  export class TranslationProcessor {
23
26
  /**
24
- * @param {object} options
25
- * @param {string[]} options.components - Component names that trigger translation.
26
- * @param {string[]} [options.ignoreComponents] - Extra components to exclude.
27
- * @param {boolean} [options.debug] - Enable verbose logging.
27
+ * @param {object} options
28
+ * @param {string[]} options.components - Component names that trigger translation.
29
+ * @param {string[]} [options.ignoreComponents] - Extra components to exclude.
30
+ * @param {string[]} [options.translatableProps] - JSX prop names to rewrite to wt(). Overrides defaults.
31
+ * @param {string[]} [options.extractFromKeys] - Object property names whose string values are recorded.
32
+ * @param {boolean} [options.debug] - Enable verbose logging.
28
33
  */
29
34
  constructor(options) {
30
35
  this.components = new Set(options.components);
31
36
  this.ignoreComponents = new Set(
32
37
  DEFAULT_IGNORE.concat(options.ignoreComponents || []),
33
38
  );
39
+ /** @type {Set<string>} JSX prop names that should be translated. */
40
+ this.translatableProps = new Set(
41
+ options.translatableProps ?? DEFAULT_TRANSLATABLE_PROPS,
42
+ );
43
+ /** @type {Set<string>} Object property key names whose string values are extracted (e.g. 'label'). */
44
+ this.extractFromKeys = new Set(options.extractFromKeys || []);
34
45
  /** @type {Map<string, string>} key → original text */
35
46
  this.dictionary = new Map();
36
47
  /** @type {import('./debug.js').DebugEntry[]} */
@@ -120,6 +131,27 @@ export class TranslationProcessor {
120
131
  return targetFound && !isIgnored;
121
132
  }
122
133
 
134
+ /**
135
+ * Return `true` when `propName` is in the translatable-props set and the
136
+ * immediate parent JSX element is a Wu* component or in `components`, and
137
+ * is not in `ignoreComponents`. Matched props are rewritten to `{wt("...")}`. *
138
+ * @param {string} propName
139
+ * @param {import('@babel/traverse').NodePath} path - JSXAttribute path.
140
+ * @returns {boolean}
141
+ */
142
+ shouldTranslateProp(propName, path) {
143
+ if (!this.translatableProps.has(propName)) return false;
144
+ // path.parent is the JSXOpeningElement node
145
+ const openingEl = path.parent;
146
+ const name = openingEl.name?.name || openingEl.name?.property?.name;
147
+ if (!name) return false;
148
+ if (this.ignoreComponents.has(name)) return false;
149
+ // Respect data-skip / data-i18n-skip on the same element
150
+ const attrs = openingEl.attributes || [];
151
+ if (attrs.some(a => ['data-skip', 'data-i18n-skip'].includes(a.name?.name))) return false;
152
+ return name.startsWith('Wu') || this.components.has(name);
153
+ }
154
+
123
155
  /**
124
156
  * Return the explicit i18n key from the nearest ancestor JSX element's
125
157
  * `data-i18n-key` attribute, or `null` if absent.
package/src/transform.js CHANGED
@@ -1,6 +1,8 @@
1
1
  /**
2
- * @fileoverview AST transform — parses a JSX/TSX file, rewrites translatable
3
- * text nodes to `<WuTranslate>`, and prepends the import if needed.
2
+ * @fileoverview AST transform — parses a JSX/TSX/TS file and:
3
+ * - Rewrites JSX text content inside Wu* components to `<WuTranslate>`
4
+ * - Rewrites translatable props (placeholder, title, …) to `{wt("…")}`
5
+ * - Prepends the necessary imports when added.
4
6
  */
5
7
 
6
8
  import MagicString from "magic-string";
@@ -9,7 +11,10 @@ import _traverse from "@babel/traverse";
9
11
  import { getComponentTree } from "./debug.js";
10
12
  import { transformTemplateLiteralExpression } from "./transformTemplateLiteral.js";
11
13
  import { transformJSXTextWithEntities } from "./transformJSXTextWithEntities.js";
12
- import { recordWtCall, transformWtTemplateLiteral } from "./transformWtCalls.js";
14
+ import {
15
+ recordWtCall,
16
+ transformWtTemplateLiteral,
17
+ } from "./transformWtCalls.js";
13
18
 
14
19
  const traverse = _traverse.default || _traverse;
15
20
 
@@ -32,6 +37,34 @@ function getStaticString(node) {
32
37
  return null;
33
38
  }
34
39
 
40
+ /**
41
+ * Recursively handle a ConditionalExpression, wrapping every static string
42
+ * branch (at any nesting depth) with WuTranslate.
43
+ *
44
+ * @param {import('@babel/types').ConditionalExpression} expr
45
+ * @param {import('@babel/traverse').NodePath} path
46
+ * @param {MagicString} ms
47
+ * @param {import('./processor.js').TranslationProcessor} processor
48
+ * @param {string} id
49
+ * @returns {boolean} true when at least one branch was captured
50
+ */
51
+ function handleConditional(expr, path, ms, processor, id) {
52
+ let changed = false;
53
+
54
+ for (const branch of [expr.consequent, expr.alternate]) {
55
+ const text = getStaticString(branch);
56
+ if (text !== null) {
57
+ changed =
58
+ handleCapture(path, text, branch.start, branch.end, ms, processor, id, true) ||
59
+ changed;
60
+ } else if (branch.type === "ConditionalExpression") {
61
+ changed = handleConditional(branch, path, ms, processor, id) || changed;
62
+ }
63
+ }
64
+
65
+ return changed;
66
+ }
67
+
35
68
  /**
36
69
  * Replace a translatable text node with a `<WuTranslate>` element and record
37
70
  * the key in the processor's dictionary.
@@ -45,8 +78,20 @@ function getStaticString(node) {
45
78
  * @param {string} id - File path (for collision warnings).
46
79
  * @returns {boolean} `true` when replacement was made.
47
80
  */
48
- function handleCapture(path, text, start, end, ms, processor, id, skipExplicitKey = false) {
49
- const cleanText = text.trim().replace(/\n/g, " ").replace(/\s{2,}/g, " ");
81
+ function handleCapture(
82
+ path,
83
+ text,
84
+ start,
85
+ end,
86
+ ms,
87
+ processor,
88
+ id,
89
+ skipExplicitKey = false,
90
+ ) {
91
+ const cleanText = text
92
+ .trim()
93
+ .replace(/\n/g, " ")
94
+ .replace(/\s{2,}/g, " ");
50
95
  if (!cleanText || !processor.shouldTranslate(path)) return false;
51
96
  // For StringLiteral / TemplateLiteral quasis / ternary branches: entities are
52
97
  // not decoded by the JS parser, so cleanText still contains "&amp;" etc.
@@ -55,11 +100,7 @@ function handleCapture(path, text, start, end, ms, processor, id, skipExplicitKe
55
100
  const key = (!skipExplicitKey && processor.getExplicitKey(path)) || cleanText;
56
101
  processor.record(key, cleanText, id, getComponentTree(path));
57
102
 
58
- ms.overwrite(
59
- start,
60
- end,
61
- `<WuTranslate __i18nKey=${JSON.stringify(key)}></WuTranslate>`,
62
- );
103
+ ms.overwrite(start, end, `<WuTranslate __i18nKey=${JSON.stringify(key)}></WuTranslate>`);
63
104
  return true;
64
105
  }
65
106
 
@@ -83,14 +124,11 @@ export function transformFile(code, id, processor) {
83
124
  let needsImport = false;
84
125
  let hasImport = false;
85
126
  let hasWtTransform = false;
127
+ let needsWtImport = false;
128
+ let hasWtImport = false;
86
129
 
87
130
  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.
131
+ /** wt("static string") call — record the key in the dictionary.
94
132
  * No code transformation; wt() handles runtime lookup.
95
133
  */
96
134
  CallExpression(path) {
@@ -100,14 +138,63 @@ export function transformFile(code, id, processor) {
100
138
  }
101
139
  },
102
140
 
141
+ /** Track whether WuTranslate / wt are already imported so we don't duplicate them. */
103
142
  ImportDeclaration(path) {
104
143
  if (path.node.source.value.includes("wick-ui-lib")) {
105
- hasImport = path.node.specifiers.some(
106
- (s) => s.imported?.name === "WuTranslate",
107
- );
144
+ for (const s of path.node.specifiers) {
145
+ if (s.imported?.name === "WuTranslate") hasImport = true;
146
+ if (s.imported?.name === "wt") hasWtImport = true;
147
+ }
108
148
  }
109
149
  },
110
150
 
151
+ /**
152
+ * Data-file key extraction: records string values of configured object
153
+ * property names (e.g. `label: 'Analytics'`) without rewriting code.
154
+ * Enabled via `extractFromKeys` option.
155
+ */
156
+ ObjectProperty(path) {
157
+ if (!processor.extractFromKeys.size) return;
158
+ const keyNode = path.node.key;
159
+ const keyName = keyNode.name ?? keyNode.value;
160
+ if (!keyName || !processor.extractFromKeys.has(keyName)) return;
161
+ const value = path.node.value;
162
+ if (value.type !== "StringLiteral") return;
163
+ const text = value.value
164
+ .trim()
165
+ .replace(/\n/g, " ")
166
+ .replace(/\s{2,}/g, " ");
167
+ if (!text) return;
168
+ if (HTML_ENTITY_RE.test(text)) return;
169
+ processor.record(text, text, id, "(data)");
170
+ },
171
+
172
+ /**
173
+ * Translatable props (Label, placeholder, title, aria-label, ...)
174
+ * rewritten to `{wt("foo")}` on Wu* components.
175
+ */
176
+ JSXAttribute(path) {
177
+ const propName = path.node.name.name;
178
+ if (typeof propName !== "string") return;
179
+ const value = path.node.value;
180
+ if (!value || value.type !== "StringLiteral") return;
181
+
182
+ const rawValue = code.slice(value.start, value.end);
183
+ if (HTML_ENTITY_RE.test(rawValue)) return;
184
+
185
+ const text = value.value
186
+ .trim()
187
+ .replace(/\n/g, " ")
188
+ .replace(/\s{2,}/g, " ");
189
+ if (!text) return;
190
+
191
+ if (!processor.shouldTranslateProp(propName, path)) return;
192
+
193
+ processor.record(text, text, id, getComponentTree(path));
194
+ ms.overwrite(value.start, value.end, `{wt(${JSON.stringify(text)})}`);
195
+ needsWtImport = true;
196
+ },
197
+
111
198
  /** Plain JSX text: `<Foo>Hello world</Foo>` */
112
199
  JSXText(path) {
113
200
  const text = path.node.value;
@@ -157,43 +244,14 @@ export function transformFile(code, id, processor) {
157
244
  expr.type === "TemplateLiteral" &&
158
245
  expr.expressions.length > 0
159
246
  ) {
160
- if (
161
- transformTemplateLiteralExpression(path, code, ms, processor, id)
162
- ) {
247
+ if (transformTemplateLiteralExpression(path, code, ms, processor, id)) {
163
248
  needsImport = true;
164
249
  }
165
250
  return;
166
251
  } else if (expr.type === "TemplateLiteral" && !expr.expressions.length) {
167
252
  text = expr.quasis[0].value.cooked;
168
253
  } 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;
254
+ if (handleConditional(expr, path, ms, processor, id)) needsImport = true;
197
255
  return;
198
256
  }
199
257
 
@@ -214,9 +272,13 @@ export function transformFile(code, id, processor) {
214
272
  },
215
273
  });
216
274
 
217
- if (!needsImport && !hasWtTransform) return null;
275
+ if (!needsImport && !hasWtTransform && !needsWtImport) return null;
276
+
277
+ if (needsWtImport && !hasWtImport) {
278
+ ms.prepend(`import { wt } from '@npm-questionpro/wick-ui-lib';\n`);
279
+ }
218
280
 
219
- if (!hasImport) {
281
+ if (needsImport && !hasImport) {
220
282
  ms.prepend(`import { WuTranslate } from '@npm-questionpro/wick-ui-lib';\n`);
221
283
  }
222
284
 
@@ -7,6 +7,12 @@ function transform(code, options = {}) {
7
7
  return result ? result.code : code;
8
8
  }
9
9
 
10
+ function transformTs(code, options = {}) {
11
+ const plugin = wickuiI18nPlugin(options);
12
+ const result = plugin.transform(code, "TestFile.ts");
13
+ return result ? result.code : code;
14
+ }
15
+
10
16
  /**
11
17
  * Run the full plugin lifecycle on `code` and return the emitted dictionary.
12
18
  * Needed for wt() tests since those record keys without transforming code.
@@ -182,13 +188,30 @@ describe("Wick UI i18n Vite Plugin", () => {
182
188
  expect(result).not.toContain(`WuTranslate`);
183
189
  });
184
190
 
185
- it("12g. Nested ternary: translates first branch, skips inner ternary", () => {
186
- // {a ? "A" : b ? "B" : "C"} — alternate is ConditionalExpression, not a static string
191
+ it("12g. Nested ternary: all static branches translated recursively", () => {
187
192
  const code = `<WuButton>{a ? "A" : b ? "B" : "C"}</WuButton>`;
188
193
  const result = transform(code);
189
194
  expect(result).toContain(`<WuTranslate __i18nKey="A"></WuTranslate>`);
190
- // inner ternary branches are not translated (known v1 limitation)
191
- expect(result).toContain(`b ? "B" : "C"`);
195
+ expect(result).toContain(`<WuTranslate __i18nKey="B"></WuTranslate>`);
196
+ expect(result).toContain(`<WuTranslate __i18nKey="C"></WuTranslate>`);
197
+ });
198
+
199
+ it("12g2. Deeply nested ternary: all levels translated", () => {
200
+ const code = `<WuButton>{a ? "A" : b ? "B" : c ? "C" : "D"}</WuButton>`;
201
+ const result = transform(code);
202
+ expect(result).toContain(`<WuTranslate __i18nKey="A"></WuTranslate>`);
203
+ expect(result).toContain(`<WuTranslate __i18nKey="B"></WuTranslate>`);
204
+ expect(result).toContain(`<WuTranslate __i18nKey="C"></WuTranslate>`);
205
+ expect(result).toContain(`<WuTranslate __i18nKey="D"></WuTranslate>`);
206
+ });
207
+
208
+ it("12g3. Nested ternary with dynamic branch: dynamic skipped, statics translated", () => {
209
+ const code = `<WuButton>{a ? "A" : b ? variable : "C"}</WuButton>`;
210
+ const result = transform(code);
211
+ expect(result).toContain(`<WuTranslate __i18nKey="A"></WuTranslate>`);
212
+ expect(result).toContain(`variable`);
213
+ expect(result).not.toContain(`__i18nKey="variable"`);
214
+ expect(result).toContain(`<WuTranslate __i18nKey="C"></WuTranslate>`);
192
215
  });
193
216
 
194
217
  it("12h. Ternary outside any Wu/wrapper component is not translated", () => {
@@ -593,3 +616,258 @@ describe("wt() call expressions", () => {
593
616
  expect(Object.keys(dict)).toHaveLength(0);
594
617
  });
595
618
  });
619
+
620
+ // ─── translatableProps ────────────────────────────────────────────────────────
621
+ //
622
+ // The `Label` prop on Wu* components is rewritten to
623
+ // `{<WuTranslate __i18nKey="..." />}` and recorded in the dictionary.
624
+ // Custom props can be passed via `translatableProps` option to override the
625
+ // default. Dynamic props, ignored components, and data-skip are untouched.
626
+ // ─────────────────────────────────────────────────────────────────────────────
627
+
628
+
629
+ describe("translatableProps", () => {
630
+ it("P1. prop on Wu* component rewritten to wt() and import injected", () => {
631
+ const code = `<WuField Label="First name" />`;
632
+ const result = transform(code);
633
+ expect(result).toContain(`import { wt }`);
634
+ expect(result).toContain(`Label={wt("First name")}`);
635
+ });
636
+
637
+ it("P2. placeholder and title translated by default", () => {
638
+ const r1 = transform(`<WuInput placeholder="Enter name" />`);
639
+ expect(r1).toContain(`placeholder={wt("Enter name")}`);
640
+ const r2 = transform(`<WuDialog title="Confirm?" />`);
641
+ expect(r2).toContain(`title={wt("Confirm?")}`);
642
+ });
643
+
644
+ it("P3. prop not in translatableProps is left untouched", () => {
645
+ const code = `<WuInput variant="secondary" Label="First name" />`;
646
+ const result = transform(code);
647
+ expect(result).toContain(`variant="secondary"`);
648
+ expect(result).not.toContain(`wt("secondary")`);
649
+ });
650
+
651
+ it("P4. prop on non-Wu component is not transformed", () => {
652
+ const code = `<input placeholder="Enter name" />`;
653
+ const result = transform(code);
654
+ expect(result).toBe(code);
655
+ });
656
+
657
+ it("P5. dynamic (expression) prop value is not transformed", () => {
658
+ const code = `<WuField Label={someProp} />`;
659
+ const result = transform(code);
660
+ expect(result).toBe(code);
661
+ });
662
+
663
+ it("P6. empty string prop is skipped", () => {
664
+ const code = `<WuField Label="" />`;
665
+ const result = transform(code);
666
+ expect(result).toBe(code);
667
+ });
668
+
669
+ it("P7. whitespace-only prop is skipped", () => {
670
+ const code = `<WuField Label=" " />`;
671
+ const result = transform(code);
672
+ expect(result).toBe(code);
673
+ });
674
+
675
+ it("P8. prop on ignored component (WuIcon) is not transformed", () => {
676
+ const code = `<WuIcon title="star icon" />`;
677
+ const result = transform(code);
678
+ expect(result).toBe(code);
679
+ });
680
+
681
+ it("P9. data-skip on element suppresses prop translation", () => {
682
+ const code = `<WuField data-skip Label="First name" />`;
683
+ const result = transform(code);
684
+ expect(result).not.toContain('wt(');
685
+ });
686
+
687
+ it("P10. data-i18n-skip on element suppresses prop translation", () => {
688
+ const code = `<WuField data-i18n-skip Label="First name" />`;
689
+ const result = transform(code);
690
+ expect(result).not.toContain('wt(');
691
+ });
692
+
693
+ it("P11. prop wt() and JSX text WuTranslate coexist — both imports injected", () => {
694
+ const code = `<WuField Label="First name">Submit</WuField>`;
695
+ const result = transform(code);
696
+ expect(result).toContain(`import { wt }`);
697
+ expect(result).toContain(`import { WuTranslate }`);
698
+ expect(result).toContain(`Label={wt("First name")}`);
699
+ expect(result).toContain(`<WuTranslate __i18nKey="Submit">`);
700
+ });
701
+
702
+ it("P12. wt already imported — no duplicate import injected", () => {
703
+ const code = [
704
+ `import { wt } from '@npm-questionpro/wick-ui-lib';`,
705
+ `<WuField Label="First name" />`,
706
+ ].join('\n');
707
+ const result = transform(code);
708
+ const matches = (result.match(/import \{ wt \}/g) || []).length;
709
+ expect(matches).toBe(1);
710
+ });
711
+
712
+ it("P13. multiple translatable props on same element are all transformed", () => {
713
+ const code = `<WuInput placeholder="Enter name" title="Name field" />`;
714
+ const result = transform(code);
715
+ expect(result).toContain(`placeholder={wt("Enter name")}`);
716
+ expect(result).toContain(`title={wt("Name field")}`);
717
+ });
718
+
719
+ it("P14. passing translatableProps overrides defaults entirely", () => {
720
+ const code = `<WuField Label="First name" title="Name" />`;
721
+ const result = transform(code, {translatableProps: ['title']});
722
+ expect(result).toContain(`title={wt("Name")}`);
723
+ expect(result).toContain(`Label="First name"`);
724
+ });
725
+
726
+ it("P15. HTML entity in prop value is skipped", () => {
727
+ const code = `<WuField Label="Hello &amp; World" />`;
728
+ const result = transform(code);
729
+ expect(result).not.toContain('wt(');
730
+ expect(result).toContain('&amp;');
731
+ });
732
+
733
+ it("P16. multi-word prop value preserved as-is in the key", () => {
734
+ const code = `<WuField Label="Enter your full name" />`;
735
+ const result = transform(code);
736
+ expect(result).toContain(`wt("Enter your full name")`);
737
+ });
738
+
739
+ it("P17. newlines and extra spaces in prop value are normalised", () => {
740
+ const code = `<WuField Label="Enter\nyour name" />`;
741
+ const result = transform(code);
742
+ expect(result).toContain(`wt("Enter your name")`);
743
+ });
744
+
745
+ it("P18. prop on user-configured custom component is transformed", () => {
746
+ const code = `<MyWidget Label="Enter name" />`;
747
+ const result = transform(code, {components: ['MyWidget']});
748
+ expect(result).toContain(`Label={wt("Enter name")}`);
749
+ });
750
+
751
+ it("P19. prop values are recorded in the translation dictionary", () => {
752
+ const code = `<WuField Label="First name" />`;
753
+ const dict = getDictionary(code);
754
+ expect(dict).toHaveProperty('First name', 'First name');
755
+ });
756
+ });
757
+
758
+ // ─── .ts data-file key extraction ───────────────────────────────────────────────────
759
+ //
760
+ // Standalone wt() in .ts data files (e.g. nav config arrays) lets the plugin
761
+ // extract string literals into wick-ui-i18n.json without any JSX transform.
762
+ // The code is returned unchanged (null); wt() handles the lookup at render time.
763
+ // ─────────────────────────────────────────────────────────────────────────────
764
+
765
+ // ─── extractFromKeys ────────────────────────────────────────────────────────
766
+ //
767
+ // Object property names configured via `extractFromKeys` have their string
768
+ // values recorded automatically — no wt() wrapping required in data files.
769
+ // Code is never rewritten; wt() handles the lookup at render time.
770
+ // ─────────────────────────────────────────────────────────────────────────────
771
+
772
+ describe('extractFromKeys', () => {
773
+ it('EK1. string values of a configured key are recorded without code rewrite', () => {
774
+ const code = [
775
+ `export const ITEMS = [`,
776
+ ` { label: 'Analytics', icon: 'wm-analytics' },`,
777
+ ` { label: 'Engagement', icon: 'wm-trending-up' },`,
778
+ `]`,
779
+ ].join('\n');
780
+ const dict = getDictionary(code, {extractFromKeys: ['label']});
781
+ expect(dict).toHaveProperty('Analytics', 'Analytics');
782
+ expect(dict).toHaveProperty('Engagement', 'Engagement');
783
+ // non-listed keys are ignored
784
+ expect(dict).not.toHaveProperty('wm-analytics');
785
+ });
786
+
787
+ it('EK2. transform returns null — data file code is never rewritten', () => {
788
+ const code = `export const ITEMS = [{ label: 'Analytics' }]`;
789
+ const plugin = wickuiI18nPlugin({extractFromKeys: ['label']});
790
+ const result = plugin.transform(code, 'navItems.ts');
791
+ expect(result).toBeNull();
792
+ });
793
+
794
+ it('EK3. works on .ts, .tsx, and .jsx files', () => {
795
+ const code = `export const x = { label: 'Hello' }`;
796
+ const opts = {extractFromKeys: ['label']};
797
+ for (const ext of ['ts', 'tsx', 'jsx']) {
798
+ const plugin = wickuiI18nPlugin(opts);
799
+ plugin.buildStart();
800
+ plugin.transform(code, `file.${ext}`);
801
+ let dict = {};
802
+ plugin.generateBundle.call({emitFile: ({source}) => { dict = JSON.parse(source); }});
803
+ expect(dict).toHaveProperty('Hello', 'Hello');
804
+ }
805
+ });
806
+
807
+ it('EK4. multiple configured keys all extracted', () => {
808
+ const code = `const x = { label: 'Save', title: 'Confirm', key: 'btn-save' }`;
809
+ const dict = getDictionary(code, {extractFromKeys: ['label', 'title']});
810
+ expect(dict).toHaveProperty('Save', 'Save');
811
+ expect(dict).toHaveProperty('Confirm', 'Confirm');
812
+ expect(dict).not.toHaveProperty('btn-save');
813
+ });
814
+
815
+ it('EK5. dynamic (non-literal) values are ignored', () => {
816
+ const code = `const x = { label: someVar }`;
817
+ const dict = getDictionary(code, {extractFromKeys: ['label']});
818
+ expect(Object.keys(dict)).toHaveLength(0);
819
+ });
820
+
821
+ it('EK6. empty string values are ignored', () => {
822
+ const code = `const x = { label: '' }`;
823
+ const dict = getDictionary(code, {extractFromKeys: ['label']});
824
+ expect(Object.keys(dict)).toHaveLength(0);
825
+ });
826
+
827
+ it('EK7. HTML entity in value is ignored', () => {
828
+ const code = `const x = { label: 'Hello &amp; World' }`;
829
+ const dict = getDictionary(code, {extractFromKeys: ['label']});
830
+ expect(Object.keys(dict)).toHaveLength(0);
831
+ });
832
+
833
+ it('EK8. no extractFromKeys configured — object properties are not touched', () => {
834
+ const code = `const x = { label: 'Analytics' }`;
835
+ const dict = getDictionary(code, {});
836
+ expect(Object.keys(dict)).toHaveLength(0);
837
+ });
838
+
839
+ it('EK9. coexists with JSX text-child translation in the same build', () => {
840
+ const plugin = wickuiI18nPlugin({extractFromKeys: ['label']});
841
+ plugin.buildStart();
842
+ plugin.transform(`export const ITEMS = [{ label: 'Analytics' }]`, 'navItems.ts');
843
+ plugin.transform(`<WuButton>Submit</WuButton>`, 'Form.tsx');
844
+ let dict = {};
845
+ plugin.generateBundle.call({emitFile: ({source}) => { dict = JSON.parse(source); }});
846
+ expect(dict).toHaveProperty('Analytics', 'Analytics');
847
+ expect(dict).toHaveProperty('Submit', 'Submit');
848
+ });
849
+
850
+ it('EK10. full SECONDARY_NAVBAR_ITEMS shape — all 8 labels extracted', () => {
851
+ const code = `
852
+ export const SECONDARY_NAVBAR_ITEMS = [
853
+ { key: 'account-operations', label: 'Analytics', icon: 'wm-analytics' },
854
+ { key: 'engagement', label: 'Engagement', icon: 'wm-trending-up' },
855
+ { key: 'system-notifications', label: 'System Notifications', icon: 'wm-notifications' },
856
+ { key: 'search', label: 'Search', icon: 'wm-search' },
857
+ { key: 'templates', label: 'Templates', icon: 'wc-static-content' },
858
+ { key: 'translations', label: 'Translations', icon: 'wm-translate' },
859
+ { key: 'ai', label: 'AI', icon: 'wc-ai' },
860
+ { key: 'cpq-pricing', label: 'CPQ Pricing', icon: 'wc-pricing-analysis' },
861
+ ]`;
862
+ const dict = getDictionary(code, {extractFromKeys: ['label']});
863
+ expect(Object.keys(dict)).toHaveLength(8);
864
+ expect(dict).toHaveProperty('Analytics');
865
+ expect(dict).toHaveProperty('System Notifications');
866
+ expect(dict).toHaveProperty('CPQ Pricing');
867
+ // non-label keys not extracted
868
+ expect(dict).not.toHaveProperty('account-operations');
869
+ expect(dict).not.toHaveProperty('wm-analytics');
870
+ });
871
+ });
872
+
873
+