@npm-questionpro/wick-ui-i18n 2.0.0-next.29 → 2.0.0-next.31

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,104 +1,52 @@
1
1
  # wick-ui-i18n
2
2
 
3
- Vite plugin — wraps JSX text in Wu components with `<WuTranslate>`, rewrites translatable props to `{wt("...")}`, and
4
- emits `wick-ui-i18n.json`.
3
+ Vite plugin — wraps JSX text in Wu\* components with `<WuTranslate>`, rewrites translatable props to `{useWt()("...")}`,
4
+ records `wt()` / `useWt()()` calls, and emits `wick-ui-i18n.json`.
5
5
 
6
- ---
7
-
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
6
+ > Full docs and examples: Storybook → **i18n/Overview**
39
7
 
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>` | ❌ |
8
+ ---
54
9
 
55
- ## JSX props
10
+ ## Quick start
56
11
 
57
- Defaults: `Label`, `placeholder`, `title`, `aria-label`, `aria-placeholder`.
12
+ ```ts
13
+ // vite.config.ts
14
+ import wickI18n from '@npm-questionpro/wick-ui-i18n'
15
+ export default defineConfig({plugins: [react(), wickI18n()]})
16
+ ```
58
17
 
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" />` | ❌ |
18
+ ---
69
19
 
70
- ## `wt()` calls
20
+ ## What it transforms
71
21
 
72
- Plugin records static args into `wick-ui-i18n.json`. No code rewrite unless template literal with expressions.
22
+ | Source | Output |
23
+ | ----------------------------------------- | --------------------------------------------- |
24
+ | `<WuButton>Save</WuButton>` | `<WuTranslate __i18nKey="Save" />` |
25
+ | `<WuInput Label="First name" />` | `Label={useWt()("First name")}` |
26
+ | ``<WuButton>{`${n} results`}</WuButton>`` | `<><WuTranslate __i18nKey="results" />{n}</>` |
27
+ | `wt("Hello")` | recorded in dictionary |
28
+ | ``wt(`Hello ${name}`)`` | `` `${wt("Hello")} ${name}` `` |
29
+ | `useWt()("Hello")` | recorded in dictionary |
30
+ | ``useWt()(`Hello ${name}`)`` | `` `${useWt()("Hello")} ${name}` `` |
73
31
 
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}`)`` | ❌ | — |
32
+ ---
82
33
 
83
- ## Data files (`extractFromKeys` option)
34
+ ## Options
84
35
 
85
- No code rewrite — keys only recorded in `wick-ui-i18n.json`.
36
+ | Option | Default | Description |
37
+ | ------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------- |
38
+ | `components` | `[]` | Extra components treated like Wu\* |
39
+ | `ignoreComponents` | `[]` | Extra components never translated |
40
+ | `translatableProps` | `['Label','placeholder','title','aria-label','aria-placeholder']` | Props rewritten to `useWt()()` |
41
+ | `dataLabelKeys` | `[]` | Property names whose values are labels in mixed data files |
42
+ | `dictionaryFiles` | — | Files where every string is a display label (enums, label constants) |
43
+ | `excludeFiles` | — | Files skipped entirely |
44
+ | `debug` | `false` | Print extraction table at build |
86
45
 
87
- | Input | Dictionary |
88
- | ------------------------------------------------------- | ---------------- |
89
- | `{ label: 'Analytics' }` + `extractFromKeys: ['label']` | ✅ `"Analytics"` |
90
- | `{ label: variable }` | ❌ |
91
- | `{ label: '' }` | ❌ |
46
+ > **Deprecated:** `extractFromKeys` → `dataLabelKeys` · `resolveFiles` → `dictionaryFiles`
92
47
 
93
48
  ---
94
49
 
95
- ## Options
50
+ ## Breaking changes
96
51
 
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 |
52
+ See [`BREAKING_CHANGES.md`](./BREAKING_CHANGES.md).
package/index.d.ts CHANGED
@@ -5,34 +5,72 @@ export interface WickI18nOptions {
5
5
  /** Extra component names to translate (in addition to Wu* components). */
6
6
  components?: string[]
7
7
 
8
- /** Component names to exclude from translation. */
8
+ /** Component names to exclude from translation (extends the built-in ignore list). */
9
9
  ignoreComponents?: string[]
10
10
 
11
11
  /**
12
- * JSX prop names rewritten to `{wt("...")}` on Wu* components.
13
- * @default ['Label', 'placeholder', 'title', 'aria-label', 'aria-placeholder']
12
+ * Extra JSX prop names rewritten to `{useWt()("...")}` on Wu* components.
13
+ * Appended to the defaults defaults are always kept:
14
+ * `['Label', 'placeholder', 'title', 'aria-label', 'aria-placeholder']`.
14
15
  */
15
16
  translatableProps?: string[]
16
17
 
17
18
  /**
18
19
  * Object property names whose string values are extracted into
19
- * `wick-ui-i18n.json` without rewriting code. Use for data files
20
- * (nav items, option lists, etc.) paired with `wt(item.label)` at render time.
20
+ * `wick-ui-i18n.json` without rewriting code.
21
+ *
22
+ * Use for mixed data files (nav items, option lists, table configs) where
23
+ * only specific property names hold display labels — icon names, routes, and
24
+ * IDs with other key names are left alone.
25
+ *
26
+ * At render time, look up values with `wt(item.label)`.
27
+ *
28
+ * @example
29
+ * dataLabelKeys: ['label', 'title']
30
+ * // { key: 'analytics', label: 'Analytics', icon: 'wm-analytics' }
31
+ * // ^^^^^^^^^^^ extracted, icon name ignored
21
32
  */
22
- extractFromKeys?: string[]
33
+ dataLabelKeys?: string[]
34
+
35
+ /**
36
+ * Files where **every** string value is a display label — object properties
37
+ * and TypeScript enum members are all extracted into `wick-ui-i18n.json`.
38
+ *
39
+ * Use **only** for dedicated enum/constant files. Do NOT point at mixed data
40
+ * files — use `dataLabelKeys` for those.
41
+ *
42
+ * Passed to Vite's `createFilter` as `include`, so globs, regexes, and
43
+ * package specifiers all work.
44
+ *
45
+ * @example
46
+ * dictionaryFiles: ['src/enums/*.ts', 'src/constants/labels.ts']
47
+ */
48
+ dictionaryFiles?: string | RegExp | Array<string | RegExp>
23
49
 
24
50
  /** Files to skip entirely. Passed to Vite's `createFilter` as `exclude`. */
25
51
  excludeFiles?: string | RegExp | Array<string | RegExp>
26
52
 
27
- /** Log every transform to the console. */
53
+ /** Print a build-time extraction report table to stdout. */
28
54
  debug?: boolean
55
+
56
+ /**
57
+ * @deprecated Use `dataLabelKeys` instead.
58
+ */
59
+ extractFromKeys?: string[]
60
+
61
+ /**
62
+ * @deprecated Use `dictionaryFiles` instead.
63
+ */
64
+ resolveFiles?: string | RegExp | Array<string | RegExp>
29
65
  }
30
66
 
31
67
  /**
32
68
  * Vite plugin that automatically translates Wick UI components.
33
69
  *
34
70
  * - JSX text content inside Wu* → `<WuTranslate __i18nKey="..." />`
35
- * - Translatable props (placeholder, title, Label, …) → `{wt("...")}`
36
- * - Emits `wick-ui-i18n.json` with all extracted keys
71
+ * - Translatable props (`Label`, `placeholder`, `title`, …) → `{useWt()("...")}`
72
+ * - `wt("literal")` and `useWt()("literal")` calls keys recorded in dictionary
73
+ * - `wt(\`Hello ${name}\`)` and `useWt()(\`Hello ${name}\`)` → each static quasi extracted and rewritten
74
+ * - Emits `wick-ui-i18n.json` at build time / serves it at `GET /wick-ui-i18n.json` in dev
37
75
  */
38
76
  export default function wickuiI18nPlugin(options?: WickI18nOptions): Plugin
package/index.js CHANGED
@@ -22,14 +22,23 @@ import {createFilter} from 'vite'
22
22
  import {TranslationProcessor} from './src/processor.js'
23
23
  import {transformFile} from './src/transform.js'
24
24
  import {printReport} from './src/debug.js'
25
+ import {TelemetryCollector} from './src/telemetry.js'
26
+ import {extractStringsFromFile} from './src/extractStrings.js'
25
27
 
26
28
  /**
27
29
  * @typedef {object} WickI18nOptions
28
30
  * @property {string[]} [components] - Extra component names that trigger translation.
29
31
  * @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']).
32
- * @property {string|string[]|RegExp} [excludeFiles] - Files to skip (passed as `exclude` to Vite's createFilter).
32
+ * @property {string[]} [translatableProps] - JSX prop names rewritten to `{useWt()("...")}`. Defaults to `['Label','placeholder','title','aria-label','aria-placeholder']`.
33
+ * @property {string[]} [dataLabelKeys] - Object property names whose string values are labels in mixed data
34
+ * files (e.g. `['label', 'title']` in nav config arrays). No code rewrite;
35
+ * keys are recorded into `wick-ui-i18n.json` for the translation API.
36
+ * @property {string[]} [extractFromKeys] - @deprecated Use `dataLabelKeys` instead.
37
+ * @property {string|string[]|RegExp} [dictionaryFiles] - Files where every string is a display label (enum files,
38
+ * message constants). Pattern: `'**\/enums\/*.ts'`.
39
+ * Do NOT point at mixed data files — use `dataLabelKeys` for those.
40
+ * @property {string|string[]|RegExp} [resolveFiles] - @deprecated Use `dictionaryFiles` instead.
41
+ * @property {string|string[]|RegExp} [excludeFiles] - Files to skip (passed as `exclude` to Vite's createFilter).
33
42
  * @property {boolean} [debug] - Log transform activity to the console.
34
43
  */
35
44
 
@@ -40,15 +49,22 @@ import {printReport} from './src/debug.js'
40
49
  * @returns {import('vite').Plugin}
41
50
  */
42
51
  export default function wickuiI18nPlugin(options = {}) {
52
+ const telemetry = new TelemetryCollector()
53
+
43
54
  const processor = new TranslationProcessor({
44
55
  components: options.components || [],
45
56
  ignoreComponents: options.ignoreComponents,
46
57
  translatableProps: options.translatableProps,
47
- extractFromKeys: options.extractFromKeys,
58
+ dataLabelKeys: options.dataLabelKeys,
59
+ extractFromKeys: options.extractFromKeys, // deprecated alias — processor handles both
48
60
  debug: options.debug,
49
61
  })
50
62
 
51
63
  const filter = createFilter([/\.(jsx|tsx|ts)$/], options.excludeFiles)
64
+ const enumFilter =
65
+ (options.dictionaryFiles ?? options.resolveFiles)
66
+ ? createFilter(options.dictionaryFiles ?? options.resolveFiles)
67
+ : null
52
68
 
53
69
  let base = '/'
54
70
 
@@ -64,6 +80,7 @@ export default function wickuiI18nPlugin(options = {}) {
64
80
  */
65
81
  configResolved(resolvedConfig) {
66
82
  base = resolvedConfig.base
83
+ telemetry.scanVersions(resolvedConfig.root)
67
84
  },
68
85
 
69
86
  /**
@@ -73,6 +90,7 @@ export default function wickuiI18nPlugin(options = {}) {
73
90
  buildStart() {
74
91
  processor.dictionary.clear()
75
92
  processor.entries = []
93
+ telemetry.reset()
76
94
  },
77
95
 
78
96
  /**
@@ -83,13 +101,16 @@ export default function wickuiI18nPlugin(options = {}) {
83
101
  * @param {string} id
84
102
  */
85
103
  transform(code, id) {
104
+ if (enumFilter?.(id)) extractStringsFromFile(code, id, processor)
105
+
86
106
  const hasAnyTarget =
87
107
  code.includes('Wu') ||
88
- /\bwt\(/.test(code) ||
108
+ /\bwt\(|useWt\(/.test(code) ||
89
109
  (processor.components.size > 0 && [...processor.components].some(c => code.includes(c))) ||
90
110
  (processor.extractFromKeys.size > 0 && [...processor.extractFromKeys].some(k => code.includes(k)))
91
111
  if (!filter(id) || !hasAnyTarget) return null
92
- return transformFile(code, id, processor)
112
+ // Pass telemetry into the same traversal — avoids a second parse of the file.
113
+ return transformFile(code, id, processor, {telemetry: code.includes('Wu') ? telemetry : null})
93
114
  },
94
115
 
95
116
  /**
@@ -102,10 +123,20 @@ export default function wickuiI18nPlugin(options = {}) {
102
123
  res.setHeader('Content-Type', 'application/json')
103
124
  res.end(JSON.stringify(Object.fromEntries(processor.dictionary), null, 2))
104
125
  })
126
+ server.middlewares.use(`${base}telemetry.json`, (_req, res) => {
127
+ res.setHeader('Content-Type', 'application/json')
128
+ res.end(JSON.stringify(telemetry.toJSON()))
129
+ })
105
130
  },
106
131
 
107
132
  /** Emit the translation dictionary as a build asset and print debug table. */
108
133
  generateBundle() {
134
+ this.emitFile({
135
+ type: 'asset',
136
+ fileName: 'telemetry.json',
137
+ source: JSON.stringify(telemetry.toJSON()),
138
+ })
139
+
109
140
  this.emitFile({
110
141
  type: 'asset',
111
142
  fileName: 'wick-ui-i18n.json',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@npm-questionpro/wick-ui-i18n",
3
- "version": "2.0.0-next.29",
3
+ "version": "2.0.0-next.31",
4
4
  "private": false,
5
5
  "license": "ISC",
6
6
  "description": "Auto-translation AST wrapper for Wick UI",
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @fileoverview extractStrings — collects every non-empty StringLiteral value
3
+ * from object properties and TypeScript enum members in a source file.
4
+ *
5
+ * Used for constant/enum files declared via the `dictionaryFiles` plugin option.
6
+ * No code is rewritten; keys are simply added to the translation dictionary.
7
+ *
8
+ * Handles:
9
+ * export const LABEL = { BRAND_NAME: 'QuestionPro', ... }
10
+ * enum Status { Active = 'Active', Draft = 'Draft' }
11
+ * const enum Direction { Up = 'up', Down = 'down' }
12
+ *
13
+ * Skips:
14
+ * empty strings, numeric enum members, computed keys, HTML entities
15
+ */
16
+
17
+ import {parse} from '@babel/parser'
18
+ import {traverse, BABEL_PLUGINS, HTML_ENTITY_RE, normalise} from './utils.js'
19
+
20
+ /**
21
+ * Parse `code` and record every non-empty string literal value into the
22
+ * processor dictionary. Works for plain objects and TS enum declarations.
23
+ *
24
+ * @param {string} code
25
+ * @param {string} id - File path (for collision warnings).
26
+ * @param {import('./processor.js').TranslationProcessor} processor
27
+ */
28
+ export function extractStringsFromFile(code, id, processor) {
29
+ let ast
30
+ try {
31
+ ast = parse(code, {sourceType: 'module', plugins: BABEL_PLUGINS})
32
+ } catch {
33
+ processor.log(`[dictionaryFiles] failed to parse ${id} — skipping`)
34
+ return
35
+ }
36
+
37
+ traverse(ast, {
38
+ /** Object property with a string value: { BRAND_NAME: 'QuestionPro' } */
39
+ ObjectProperty(path) {
40
+ const value = path.node.value
41
+ if (value.type !== 'StringLiteral') return
42
+ const text = normalise(value.value)
43
+ if (!text) return
44
+ if (HTML_ENTITY_RE.test(text)) return
45
+ processor.record(text, text, id, '(dictionaryFiles:object)')
46
+ },
47
+
48
+ /** TypeScript enum member with a string initialiser: Active = 'Active' */
49
+ TSEnumMember(path) {
50
+ const init = path.node.initializer
51
+ if (!init || init.type !== 'StringLiteral') return
52
+ const text = normalise(init.value)
53
+ if (!text) return
54
+ if (HTML_ENTITY_RE.test(text)) return
55
+ processor.record(text, text, id, '(dictionaryFiles:enum)')
56
+ },
57
+ })
58
+ }
package/src/processor.js CHANGED
@@ -13,7 +13,7 @@ const DEFAULT_IGNORE = [
13
13
  'WuHelpButton',
14
14
  'WuActivityLog',
15
15
  'WuAppHeader',
16
- 'WuAPpHeadeMenu',
16
+ 'WuAppHeaderMenu',
17
17
  'WuCopyToClipboard',
18
18
  'WuMenuIcon',
19
19
  'WuScrollArea',
@@ -27,17 +27,18 @@ export class TranslationProcessor {
27
27
  * @param {object} options
28
28
  * @param {string[]} options.components - Component names that trigger translation.
29
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.
30
+ * @param {string[]} [options.translatableProps] - Extra JSX prop names to rewrite to useWt()(). Appended to defaults.
31
+ * @param {string[]} [options.dataLabelKeys] - Object property names whose string values are labels in mixed
32
+ * data files (e.g. ['label', 'title'] in nav config arrays).
32
33
  * @param {boolean} [options.debug] - Enable verbose logging.
33
34
  */
34
35
  constructor(options) {
35
36
  this.components = new Set(options.components)
36
37
  this.ignoreComponents = new Set(DEFAULT_IGNORE.concat(options.ignoreComponents || []))
37
38
  /** @type {Set<string>} JSX prop names that should be translated. */
38
- this.translatableProps = new Set(options.translatableProps ?? DEFAULT_TRANSLATABLE_PROPS)
39
+ this.translatableProps = new Set([...DEFAULT_TRANSLATABLE_PROPS, ...(options.translatableProps || [])])
39
40
  /** @type {Set<string>} Object property key names whose string values are extracted (e.g. 'label'). */
40
- this.extractFromKeys = new Set(options.extractFromKeys || [])
41
+ this.extractFromKeys = new Set(options.dataLabelKeys || options.extractFromKeys || [])
41
42
  /** @type {Map<string, string>} key → original text */
42
43
  this.dictionary = new Map()
43
44
  /** @type {import('./debug.js').DebugEntry[]} */
@@ -76,7 +77,7 @@ export class TranslationProcessor {
76
77
  * component that should be translated.
77
78
  *
78
79
  * Rules (first match wins, walking outward):
79
- * - `data-skip` / `data-i18n-skip` attr → ignored
80
+ * - `data-i18n-skip` attr → ignored
80
81
  * - component in `ignoreComponents` → ignored
81
82
  * - `data-i18n-wrapper` attr OR component starts with "Wu" / is in `components` → translate
82
83
  *
@@ -93,7 +94,7 @@ export class TranslationProcessor {
93
94
  const name = p.node.openingElement.name.name || p.node.openingElement.name.property?.name
94
95
  const attrs = p.node.openingElement.attributes || []
95
96
 
96
- if (attrs.some(a => ['data-skip', 'data-i18n-skip'].includes(a.name?.name))) {
97
+ if (attrs.some(a => a.name?.name === 'data-i18n-skip')) {
97
98
  isIgnored = true
98
99
  return true
99
100
  }
@@ -120,7 +121,8 @@ export class TranslationProcessor {
120
121
  /**
121
122
  * Return `true` when `propName` is in the translatable-props set and the
122
123
  * immediate parent JSX element is a Wu* component or in `components`, and
123
- * is not in `ignoreComponents`. Matched props are rewritten to `{wt("...")}`. *
124
+ * is not in `ignoreComponents`. Matched props are rewritten to `{useWt()("...")}` by the transform.
125
+ *
124
126
  * @param {string} propName
125
127
  * @param {import('@babel/traverse').NodePath} path - JSXAttribute path.
126
128
  * @returns {boolean}
@@ -132,9 +134,9 @@ export class TranslationProcessor {
132
134
  const name = openingEl.name?.name || openingEl.name?.property?.name
133
135
  if (!name) return false
134
136
  if (this.ignoreComponents.has(name)) return false
135
- // Respect data-skip / data-i18n-skip on the same element
137
+ // Respect data-i18n-skip on the same element
136
138
  const attrs = openingEl.attributes || []
137
- if (attrs.some(a => ['data-skip', 'data-i18n-skip'].includes(a.name?.name))) return false
139
+ if (attrs.some(a => a.name?.name === 'data-i18n-skip')) return false
138
140
  return name.startsWith('Wu') || this.components.has(name)
139
141
  }
140
142
 
@@ -0,0 +1,166 @@
1
+ /**
2
+ * @fileoverview recordWtCalls — handles wt() and useWt()() call expressions:
3
+ *
4
+ * 1. recordWtCall — records static string args into the translation dictionary.
5
+ * No code rewrite; wt/useWt handle runtime lookup.
6
+ *
7
+ * 2. transformWtTemplateLiteral — rewrites template literals with expressions
8
+ * so each static quasi becomes its own wt/useWt call:
9
+ *
10
+ * wt(`Hello ${name}`)
11
+ * → `${wt("Hello")} ${name}`
12
+ *
13
+ * useWt()(`Hello ${name}`)
14
+ * → `${useWt()("Hello")} ${name}`
15
+ *
16
+ * Both functions handle the same two call shapes:
17
+ * wt(...) — bare identifier callee
18
+ * useWt()(...) — call expression callee (useWt() returns the translate fn)
19
+ *
20
+ * Supported static arg forms:
21
+ * wt("hello") — StringLiteral → recorded, not rewritten
22
+ * wt(`hello`) — TemplateLiteral, no expressions → recorded, not rewritten
23
+ * wt(`hello ${n}`) — TemplateLiteral with expressions → each quasi recorded + rewritten
24
+ *
25
+ * Ignored:
26
+ * wt(variable) — dynamic, cannot be statically analysed
27
+ * wt() / wt(a, b) — wrong arity
28
+ */
29
+
30
+ import {splitQuasi, normalise} from './utils.js'
31
+
32
+ // ─── shared helper ────────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Detect whether `node` is a `wt(...)` or `useWt()(...)` call and return
36
+ * the wrapper string used to reconstruct translated quasis in the output.
37
+ *
38
+ * Returns `null` when the node is neither shape.
39
+ *
40
+ * @param {import('@babel/types').CallExpression} node
41
+ * @returns {{ wrapper: (text: string) => string, args: import('@babel/types').Node[] } | null}
42
+ */
43
+ function getWtInfo(node) {
44
+ const {callee, arguments: args} = node
45
+
46
+ // wt(...)
47
+ if (callee.type === 'Identifier' && callee.name === 'wt') {
48
+ return {wrapper: text => `wt(${JSON.stringify(text)})`, args}
49
+ }
50
+
51
+ // useWt()(...)
52
+ if (
53
+ callee.type === 'CallExpression' &&
54
+ callee.callee.type === 'Identifier' &&
55
+ callee.callee.name === 'useWt' &&
56
+ callee.arguments.length === 0
57
+ ) {
58
+ return {wrapper: text => `useWt()(${JSON.stringify(text)})`, args}
59
+ }
60
+
61
+ return null
62
+ }
63
+
64
+ // ─── recordWtCall ─────────────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * If `path` is a `wt(staticString)` or `useWt()(staticString)` call, record
68
+ * the key in the processor dictionary. Returns `true` when a key was recorded.
69
+ *
70
+ * No code transformation is performed — wt/useWt handle runtime lookup.
71
+ *
72
+ * @param {import('@babel/traverse').NodePath} path - CallExpression path.
73
+ * @param {import('./processor.js').TranslationProcessor} processor
74
+ * @param {string} id - Source file path (for collision warnings).
75
+ * @returns {boolean}
76
+ */
77
+ export function recordWtCall(path, processor, id) {
78
+ const info = getWtInfo(path.node)
79
+ if (!info) return false
80
+
81
+ const {args} = info
82
+ if (args.length !== 1) return false
83
+
84
+ const arg = args[0]
85
+ let text = null
86
+
87
+ if (arg.type === 'StringLiteral') {
88
+ text = arg.value
89
+ } else if (arg.type === 'TemplateLiteral' && arg.expressions.length === 0) {
90
+ text = arg.quasis[0].value.cooked ?? arg.quasis[0].value.raw
91
+ }
92
+
93
+ if (text === null) return false
94
+
95
+ const cleanText = normalise(text)
96
+ if (!cleanText) return false
97
+
98
+ processor.record(cleanText, cleanText, id, '(wt)')
99
+ return true
100
+ }
101
+
102
+ // ─── transformWtTemplateLiteral ───────────────────────────────────────────────
103
+
104
+ /**
105
+ * Transform a `wt(...)` or `useWt()(...)` call whose argument is a TemplateLiteral
106
+ * with one or more dynamic expressions.
107
+ *
108
+ * Each static quasi is independently extracted and replaced with a nested
109
+ * `wt("text")` / `useWt()("text")` call; dynamic expressions are preserved in
110
+ * place. The whole call is replaced with a plain template literal:
111
+ *
112
+ * wt(`Hello ${name}`) → `${wt("Hello")} ${name}`
113
+ * wt(`${a} and ${b}`) → `${a} ${wt("and")} ${b}`
114
+ * useWt()(`Hello ${name}`) → `${useWt()("Hello")} ${name}`
115
+ * useWt()(`${a} and ${b}`) → `${a} ${useWt()("and")} ${b}`
116
+ *
117
+ * If no quasi contains translatable text the call is left untouched and the
118
+ * function returns `false`.
119
+ *
120
+ * @param {import('@babel/traverse').NodePath} path - CallExpression path.
121
+ * @param {string} code - Original source (for slicing expression text).
122
+ * @param {import('magic-string').default} ms
123
+ * @param {import('./processor.js').TranslationProcessor} processor
124
+ * @param {string} id - Source file path.
125
+ * @returns {boolean} `true` when the call was rewritten.
126
+ */
127
+ export function transformWtTemplateLiteral(path, code, ms, processor, id) {
128
+ const info = getWtInfo(path.node)
129
+ if (!info) return false
130
+
131
+ const {wrapper, args} = info
132
+ if (args.length !== 1) return false
133
+
134
+ const arg = args[0]
135
+ if (arg.type !== 'TemplateLiteral' || arg.expressions.length === 0) return false
136
+
137
+ const {quasis, expressions} = arg
138
+ const parts = ['`']
139
+ let hasTranslatable = false
140
+
141
+ for (let i = 0; i < quasis.length; i++) {
142
+ const cooked = quasis[i].value.cooked ?? quasis[i].value.raw
143
+ const {leading, text, trailing} = splitQuasi(cooked)
144
+
145
+ if (text) {
146
+ processor.record(text, text, id, '(wt)')
147
+ parts.push(`${leading}\${${wrapper(text)}}${trailing}`)
148
+ hasTranslatable = true
149
+ } else {
150
+ // empty or whitespace-only quasi — preserve as literal template text
151
+ parts.push(cooked)
152
+ }
153
+
154
+ if (i < expressions.length) {
155
+ const exprSrc = code.slice(expressions[i].start, expressions[i].end)
156
+ parts.push(`\${${exprSrc}}`)
157
+ }
158
+ }
159
+
160
+ parts.push('`')
161
+
162
+ if (!hasTranslatable) return false
163
+
164
+ ms.overwrite(path.node.start, path.node.end, parts.join(''))
165
+ return true
166
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @fileoverview Telemetry collector — scans consumer node_modules for
3
+ * @npm-questionpro/wick-ui-* versions and counts Wu* JSX component usages
4
+ * across the entire build. Emitted as `telemetry.json` at build time and
5
+ * served at `GET /telemetry.json` in dev.
6
+ *
7
+ * Intentionally isolated from i18n logic — no imports from other src/ files
8
+ * except shared utilities (no domain objects).
9
+ *
10
+ * Wu* component recording is done inside transformFile's existing traversal
11
+ * (via the `telemetry` option) so no second parse is needed per file.
12
+ */
13
+
14
+ import fs from 'node:fs'
15
+ import path from 'node:path'
16
+
17
+ export class TelemetryCollector {
18
+ constructor() {
19
+ /** @type {Record<string, string>} short name → version e.g. { lib: '2.0.0-next.29' } */
20
+ this.versions = {}
21
+ /** @type {Map<string, number>} PascalCase component name → total usage count */
22
+ this.counts = new Map()
23
+ }
24
+
25
+ /** Clear component counts — called on every buildStart so watch-mode rebuilds are clean. */
26
+ reset() {
27
+ this.counts.clear()
28
+ }
29
+
30
+ /**
31
+ * Increment the usage count for a Wu* component and record its prop names.
32
+ * @param {string} name - e.g. 'WuButton'
33
+ * @param {string[]} props - Prop names present on this usage e.g. ['variant', 'disabled']
34
+ */
35
+ record(name, props = []) {
36
+ if (!this.counts.has(name)) this.counts.set(name, {count: 0, props: new Map()})
37
+ const entry = this.counts.get(name)
38
+ entry.count++
39
+ for (const prop of props) {
40
+ entry.props.set(prop, (entry.props.get(prop) ?? 0) + 1)
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Scan the consumer's node_modules for all @npm-questionpro/wick-ui-* packages
46
+ * and record their versions. Called once in configResolved when root is known.
47
+ *
48
+ * @param {string} root - Vite project root
49
+ */
50
+ scanVersions(root) {
51
+ const scopeDir = path.join(root, 'node_modules', '@npm-questionpro')
52
+ let entries
53
+ try {
54
+ entries = fs.readdirSync(scopeDir)
55
+ } catch {
56
+ // @npm-questionpro scope not present — skip silently
57
+ return
58
+ }
59
+ for (const name of entries) {
60
+ if (!name.startsWith('wick-ui-')) continue
61
+ try {
62
+ const pkg = JSON.parse(fs.readFileSync(path.join(scopeDir, name, 'package.json'), 'utf8'))
63
+ const shortKey = name.replace('wick-ui-', '')
64
+ this.versions[shortKey] = pkg.version
65
+ } catch {
66
+ // malformed or missing package.json — skip
67
+ }
68
+ }
69
+
70
+ // Also capture react and react-dom versions from the consumer's node_modules
71
+ for (const dep of ['react', 'react-dom']) {
72
+ try {
73
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'node_modules', dep, 'package.json'), 'utf8'))
74
+ this.versions[dep] = pkg.version
75
+ } catch {
76
+ // not installed — skip
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Produce the final telemetry payload as two clearly separated sections:
83
+ * - `versions`: package version map (sorted alphabetically)
84
+ * - `components`: Wu* usage counts with per-prop tallies (sorted alphabetically)
85
+ *
86
+ * Keeping the two sections distinct prevents a component named "react" or
87
+ * "lib" from silently shadowing a version entry.
88
+ *
89
+ * @returns {{ versions: Record<string, string>, components: Record<string, object> }}
90
+ */
91
+ toJSON() {
92
+ const versions = Object.fromEntries(Object.entries(this.versions).sort(([a], [b]) => a.localeCompare(b)))
93
+ const components = Object.fromEntries(
94
+ [...this.counts.entries()]
95
+ .sort(([a], [b]) => a.localeCompare(b))
96
+ .map(([name, {count, props}]) => [
97
+ name,
98
+ {
99
+ count,
100
+ props: Object.fromEntries([...props.entries()].sort(([a], [b]) => a.localeCompare(b))),
101
+ },
102
+ ]),
103
+ )
104
+ return {versions, components}
105
+ }
106
+ }
package/src/transform.js CHANGED
@@ -1,30 +1,18 @@
1
1
  /**
2
2
  * @fileoverview AST transform — parses a JSX/TSX/TS file and:
3
3
  * - Rewrites JSX text content inside Wu* components to `<WuTranslate>`
4
- * - Rewrites translatable props (placeholder, title, …) to `{wt("…")}`
4
+ * - Rewrites translatable props (placeholder, title, …) to `{useWt()("…")}`
5
5
  * - Prepends the necessary imports when added.
6
+ * - Optionally records Wu* component usages into a TelemetryCollector.
6
7
  */
7
8
 
8
9
  import MagicString from 'magic-string'
9
10
  import {parse} from '@babel/parser'
10
- import _traverse from '@babel/traverse'
11
11
  import {getComponentTree} from './debug.js'
12
12
  import {transformTemplateLiteralExpression} from './transformTemplateLiteral.js'
13
13
  import {transformJsxTextWithEntities} from './transformReactTextWithEntities.js'
14
- import {recordWtCall, transformWtTemplateLiteral} from './transformWtCalls.js'
15
-
16
- const traverse = _traverse.default || _traverse
17
-
18
- /** Babel parser plugins applied to every file. */
19
- const BABEL_PLUGINS = ['jsx', 'typescript']
20
-
21
- /**
22
- * Matches any HTML entity: named (&amp;), decimal (&#169;), or hex (&#x00A9;).
23
- * Used to skip text segments that contain entities — they must be left as-is.
24
- * Note: for JSXText this must be tested against the RAW source, not the Babel
25
- * decoded .value (Babel turns &amp; → "&", &nbsp; → "\u00a0", etc.).
26
- */
27
- const HTML_ENTITY_RE = /&(?:[a-zA-Z][a-zA-Z0-9]*|#[0-9]+|#x[0-9a-fA-F]+);/
14
+ import {recordWtCall, transformWtTemplateLiteral} from './recordWtCalls.js'
15
+ import {traverse, BABEL_PLUGINS, HTML_ENTITY_RE, normalise, splitQuasi} from './utils.js'
28
16
 
29
17
  /** @param {import('@babel/types').Node} node @returns {string|null} */
30
18
  function getStaticString(node) {
@@ -73,10 +61,7 @@ function handleConditional(expr, path, ms, processor, id) {
73
61
  * @returns {boolean} `true` when replacement was made.
74
62
  */
75
63
  function handleCapture(path, text, start, end, ms, processor, id, skipExplicitKey = false) {
76
- const cleanText = text
77
- .trim()
78
- .replace(/\n/g, ' ')
79
- .replace(/\s{2,}/g, ' ')
64
+ const cleanText = normalise(text)
80
65
  if (!cleanText || !processor.shouldTranslate(path)) return false
81
66
  // For StringLiteral / TemplateLiteral quasis / ternary branches: entities are
82
67
  // not decoded by the JS parser, so cleanText still contains "&amp;" etc.
@@ -89,6 +74,66 @@ function handleCapture(path, text, start, end, ms, processor, id, skipExplicitKe
89
74
  return true
90
75
  }
91
76
 
77
+ /**
78
+ * Transform a JSX prop value that is a TemplateLiteral into a useWt()() call
79
+ * (or a new template literal with useWt()() for each static quasi).
80
+ *
81
+ * Static: Label={`hello`} → Label={useWt()("hello")}
82
+ * Dynamic: Label={`${name} hello`} → Label={`${name} ${useWt()("hello")}`}
83
+ * Dynamic: Label={`${a} and ${b}`} → Label={`${a} ${useWt()("and")} ${b}`}
84
+ *
85
+ * @param {import('@babel/traverse').NodePath} path - JSXAttribute path.
86
+ * @param {import('@babel/types').TemplateLiteral} expr
87
+ * @param {string} code
88
+ * @param {import('magic-string').default} ms
89
+ * @param {import('./processor.js').TranslationProcessor} processor
90
+ * @param {string} id
91
+ * @returns {boolean} `true` when a replacement was written.
92
+ */
93
+ function transformPropTemplateLiteral(path, expr, code, ms, processor, id) {
94
+ if (!processor.shouldTranslateProp(path.node.name.name, path)) return false
95
+
96
+ const {quasis, expressions} = expr
97
+ const componentTree = getComponentTree(path)
98
+
99
+ // Static template literal: no expressions
100
+ if (expressions.length === 0) {
101
+ const text = normalise(quasis[0].value.cooked ?? quasis[0].value.raw)
102
+ if (!text || HTML_ENTITY_RE.test(text)) return false
103
+ processor.record(text, text, id, componentTree)
104
+ // Overwrite the entire JSXExpressionContainer
105
+ ms.overwrite(path.node.value.start, path.node.value.end, `{useWt()(${JSON.stringify(text)})}`)
106
+ return true
107
+ }
108
+
109
+ // Dynamic template literal: walk quasis, replace static parts with useWt()()
110
+ const parts = ['`']
111
+ let hasTranslatable = false
112
+
113
+ for (let i = 0; i < quasis.length; i++) {
114
+ const cooked = quasis[i].value.cooked ?? quasis[i].value.raw
115
+ const {leading, text, trailing} = splitQuasi(cooked)
116
+
117
+ if (text && !HTML_ENTITY_RE.test(text)) {
118
+ processor.record(text, text, id, componentTree)
119
+ parts.push(`${leading}\${useWt()(${JSON.stringify(text)})}${trailing}`)
120
+ hasTranslatable = true
121
+ } else {
122
+ parts.push(cooked)
123
+ }
124
+
125
+ if (i < expressions.length) {
126
+ parts.push(`\${${code.slice(expressions[i].start, expressions[i].end)}}`)
127
+ }
128
+ }
129
+
130
+ parts.push('`')
131
+ if (!hasTranslatable) return false
132
+
133
+ ms.overwrite(path.node.value.start, path.node.value.end, `{${parts.join('')}}`)
134
+ return true
135
+ }
136
+
92
137
  /**
93
138
  * Parse `code`, replace all translatable JSX text nodes, and prepend the
94
139
  * `WuTranslate` import when necessary.
@@ -96,10 +141,14 @@ function handleCapture(path, text, start, end, ms, processor, id, skipExplicitKe
96
141
  * @param {string} code - Raw source of the file.
97
142
  * @param {string} id - File path.
98
143
  * @param {import('./processor.js').TranslationProcessor} processor
144
+ * @param {object} [options]
145
+ * @param {import('./telemetry.js').TelemetryCollector|null} [options.telemetry]
146
+ * When provided, Wu* JSX component usages are recorded into this collector
147
+ * inside the same traversal — avoiding a second parse of the file.
99
148
  * @returns {{ code: string, map: object } | null} Transformed result, or
100
149
  * `null` when no changes were made.
101
150
  */
102
- export function transformFile(code, id, processor) {
151
+ export function transformFile(code, id, processor, {telemetry} = {}) {
103
152
  const ast = parse(code, {
104
153
  sourceType: 'module',
105
154
  plugins: BABEL_PLUGINS,
@@ -108,19 +157,18 @@ export function transformFile(code, id, processor) {
108
157
  const ms = new MagicString(code)
109
158
  let needsImport = false
110
159
  let hasImport = false
111
- let hasWtTransform = false
112
- let needsWtImport = false
113
- let hasWtImport = false
160
+ let needsUseWtImport = false
161
+ let hasUseWtImport = false
114
162
 
115
163
  traverse(ast, {
116
- /** wt("static string") call — record the key in the dictionary.
117
- * No code transformation; wt() handles runtime lookup.
164
+ /**
165
+ * wt("static string") / useWt()("static string") record the key.
166
+ * wt(`quasi ${expr}`) / useWt()(`quasi ${expr}`) — rewrite each static
167
+ * quasi to its own wt/useWt call and record it.
118
168
  */
119
169
  CallExpression(path) {
120
170
  if (recordWtCall(path, processor, id)) return
121
- if (transformWtTemplateLiteral(path, code, ms, processor, id)) {
122
- hasWtTransform = true
123
- }
171
+ transformWtTemplateLiteral(path, code, ms, processor, id)
124
172
  },
125
173
 
126
174
  /** Track whether WuTranslate / wt are already imported so we don't duplicate them. */
@@ -128,7 +176,7 @@ export function transformFile(code, id, processor) {
128
176
  if (path.node.source.value.includes('wick-ui-lib')) {
129
177
  for (const s of path.node.specifiers) {
130
178
  if (s.imported?.name === 'WuTranslate') hasImport = true
131
- if (s.imported?.name === 'wt') hasWtImport = true
179
+ if (s.imported?.name === 'useWt') hasUseWtImport = true
132
180
  }
133
181
  }
134
182
  },
@@ -136,7 +184,7 @@ export function transformFile(code, id, processor) {
136
184
  /**
137
185
  * Data-file key extraction: records string values of configured object
138
186
  * property names (e.g. `label: 'Analytics'`) without rewriting code.
139
- * Enabled via `extractFromKeys` option.
187
+ * Enabled via the `dataLabelKeys` plugin option.
140
188
  */
141
189
  ObjectProperty(path) {
142
190
  if (!processor.extractFromKeys.size) return
@@ -145,10 +193,7 @@ export function transformFile(code, id, processor) {
145
193
  if (!keyName || !processor.extractFromKeys.has(keyName)) return
146
194
  const value = path.node.value
147
195
  if (value.type !== 'StringLiteral') return
148
- const text = value.value
149
- .trim()
150
- .replace(/\n/g, ' ')
151
- .replace(/\s{2,}/g, ' ')
196
+ const text = normalise(value.value)
152
197
  if (!text) return
153
198
  if (HTML_ENTITY_RE.test(text)) return
154
199
  processor.record(text, text, id, '(data)')
@@ -156,28 +201,39 @@ export function transformFile(code, id, processor) {
156
201
 
157
202
  /**
158
203
  * Translatable props (Label, placeholder, title, aria-label, ...)
159
- * rewritten to `{wt("foo")}` on Wu* components.
204
+ * rewritten to `{useWt()("foo")}` on Wu* components.
205
+ *
206
+ * Handles all three value shapes:
207
+ * Label="hello" → Label={useWt()("hello")}
208
+ * Label={`hello`} → Label={useWt()("hello")}
209
+ * Label={`${name} hello`} → Label={`${name} ${useWt()("hello")}`}
160
210
  */
161
211
  JSXAttribute(path) {
162
212
  const propName = path.node.name.name
163
213
  if (typeof propName !== 'string') return
164
214
  const value = path.node.value
165
- if (!value || value.type !== 'StringLiteral') return
166
-
167
- const rawValue = code.slice(value.start, value.end)
168
- if (HTML_ENTITY_RE.test(rawValue)) return
215
+ if (!value) return
169
216
 
170
- const text = value.value
171
- .trim()
172
- .replace(/\n/g, ' ')
173
- .replace(/\s{2,}/g, ' ')
174
- if (!text) return
175
-
176
- if (!processor.shouldTranslateProp(propName, path)) return
217
+ // Shape 1: StringLiteral — Label="hello"
218
+ if (value.type === 'StringLiteral') {
219
+ const rawValue = code.slice(value.start, value.end)
220
+ if (HTML_ENTITY_RE.test(rawValue)) return
221
+ const text = normalise(value.value)
222
+ if (!text) return
223
+ if (!processor.shouldTranslateProp(propName, path)) return
224
+ processor.record(text, text, id, getComponentTree(path))
225
+ ms.overwrite(value.start, value.end, `{useWt()(${JSON.stringify(text)})}`)
226
+ needsUseWtImport = true
227
+ return
228
+ }
177
229
 
178
- processor.record(text, text, id, getComponentTree(path))
179
- ms.overwrite(value.start, value.end, `{wt(${JSON.stringify(text)})}`)
180
- needsWtImport = true
230
+ // Shape 2 & 3: JSXExpressionContainer wrapping a TemplateLiteral
231
+ // Label={`hello`} or Label={`${name} hello`}
232
+ if (value.type === 'JSXExpressionContainer' && value.expression.type === 'TemplateLiteral') {
233
+ if (transformPropTemplateLiteral(path, value.expression, code, ms, processor, id)) {
234
+ needsUseWtImport = true
235
+ }
236
+ }
181
237
  },
182
238
 
183
239
  /** Plain JSX text: `<Foo>Hello world</Foo>` */
@@ -231,12 +287,26 @@ export function transformFile(code, id, processor) {
231
287
  needsImport = true
232
288
  }
233
289
  },
290
+
291
+ /**
292
+ * Telemetry: record Wu* component usages when a collector is provided.
293
+ * Runs in the same traversal to avoid parsing the file a second time.
294
+ */
295
+ JSXOpeningElement(path) {
296
+ if (!telemetry) return
297
+ const name = path.node.name.name
298
+ if (typeof name !== 'string' || !name.startsWith('Wu')) return
299
+ const props = path.node.attributes
300
+ .filter(a => a.type === 'JSXAttribute' && typeof a.name?.name === 'string')
301
+ .map(a => a.name.name)
302
+ telemetry.record(name, props)
303
+ },
234
304
  })
235
305
 
236
- if (!needsImport && !hasWtTransform && !needsWtImport) return null
306
+ if (!ms.hasChanged()) return null
237
307
 
238
- if (needsWtImport && !hasWtImport) {
239
- ms.prepend(`import { wt } from '@npm-questionpro/wick-ui-lib';\n`)
308
+ if (needsUseWtImport && !hasUseWtImport) {
309
+ ms.prepend(`import { useWt } from '@npm-questionpro/wick-ui-lib';\n`)
240
310
  }
241
311
 
242
312
  if (needsImport && !hasImport) {
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import {getComponentTree} from './debug.js'
21
+ import {splitQuasi} from './utils.js'
21
22
 
22
23
  /**
23
24
  * Splits the raw source around HTML entities (capturing group keeps entities
@@ -33,25 +34,6 @@ const HTML_ENTITY_SPLIT_RE = /(&(?:[a-zA-Z][a-zA-Z0-9]*|#[0-9]+|#x[0-9a-fA-F]+);
33
34
  */
34
35
  const HTML_ENTITY_SEGMENT_RE = /^&(?:[a-zA-Z][a-zA-Z0-9]*|#[0-9]+|#x[0-9a-fA-F]+);$/
35
36
 
36
- /**
37
- * Given a text segment, extract leading whitespace, the translatable core,
38
- * and trailing whitespace so spacing around entities is preserved.
39
- *
40
- * @param {string} raw
41
- * @returns {{ leading: string, text: string, trailing: string }}
42
- */
43
- function splitSegment(raw) {
44
- const leading = raw.match(/^\s*/)[0]
45
- const trailing = raw.match(/\s*$/)[0]
46
- // Normalise internal whitespace to match handleCapture behaviour:
47
- // newlines → single space, consecutive spaces → single space.
48
- const text = raw
49
- .slice(leading.length, raw.length - trailing.length)
50
- .replace(/\n/g, ' ')
51
- .replace(/\s{2,}/g, ' ')
52
- return {leading, text, trailing}
53
- }
54
-
55
37
  /**
56
38
  * Transform a JSXText node whose raw source contains at least one HTML entity.
57
39
  *
@@ -97,7 +79,8 @@ export function transformJsxTextWithEntities(path, rawSource, ms, processor, id)
97
79
  // Raw entity — emit as-is, no translation key
98
80
  parts.push(seg)
99
81
  } else {
100
- const {leading, text, trailing} = splitSegment(seg)
82
+ // splitQuasi normalises internal whitespace and separates leading/trailing padding
83
+ const {leading, text, trailing} = splitQuasi(seg)
101
84
 
102
85
  if (text) {
103
86
  processor.record(text, text, id, componentTree)
@@ -11,21 +11,7 @@
11
11
  */
12
12
 
13
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
- }
14
+ import {splitQuasi, HTML_ENTITY_RE} from './utils.js'
29
15
 
30
16
  /**
31
17
  * Transform a JSXExpressionContainer whose expression is a TemplateLiteral
@@ -35,6 +21,7 @@ function splitQuasi(raw) {
35
21
  * → <><WuTranslate __i18nKey="hello" /> {name} <WuTranslate __i18nKey="how are you" /></>
36
22
  *
37
23
  * Quasis that are empty or whitespace-only are skipped (no key emitted).
24
+ * Quasis that contain HTML entities are emitted as plain text (not wrapped).
38
25
  * Leading/trailing whitespace within a quasi is preserved as JSX text around
39
26
  * the WuTranslate element so words don't run together after translation.
40
27
  *
@@ -61,9 +48,14 @@ export function transformTemplateLiteralExpression(path, code, ms, processor, id
61
48
  const {leading, text, trailing} = splitQuasi(cooked)
62
49
 
63
50
  if (text) {
64
- processor.record(text, text, id, componentTree)
65
- parts.push(`${leading}<WuTranslate __i18nKey=${JSON.stringify(text)}></WuTranslate>${trailing}`)
66
- hasTranslatable = true
51
+ if (HTML_ENTITY_RE.test(text)) {
52
+ // Entity-like string in this quasi — preserve as literal JSX text, no key
53
+ parts.push(cooked)
54
+ } else {
55
+ processor.record(text, text, id, componentTree)
56
+ parts.push(`${leading}<WuTranslate __i18nKey=${JSON.stringify(text)}></WuTranslate>${trailing}`)
57
+ hasTranslatable = true
58
+ }
67
59
  }
68
60
  // whitespace-only or empty quasi — emit nothing (no key, no node)
69
61
 
package/src/utils.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @fileoverview Shared utilities used across all transform modules:
3
+ * Babel traversal setup, constants, and text-normalisation helpers.
4
+ *
5
+ * Having a single source of truth here ensures every module applies
6
+ * identical normalisation and uses the same regex/plugin list.
7
+ */
8
+
9
+ import _traverse from '@babel/traverse'
10
+
11
+ /** @babel/traverse ESM/CJS interop — handles both `import traverse` forms. */
12
+ export const traverse = _traverse.default || _traverse
13
+
14
+ /** Babel parser plugins applied to every file. */
15
+ export const BABEL_PLUGINS = ['jsx', 'typescript']
16
+
17
+ /**
18
+ * Matches any HTML entity: named (&amp;), decimal (&#169;), or hex (&#x00A9;).
19
+ * Used to skip text segments that contain entities — they must be left as-is.
20
+ * Note: for JSXText this must be tested against the RAW source, not the Babel
21
+ * decoded .value (Babel turns &amp; → "&", &nbsp; → "\u00a0", etc.).
22
+ */
23
+ export const HTML_ENTITY_RE = /&(?:[a-zA-Z][a-zA-Z0-9]*|#[0-9]+|#x[0-9a-fA-F]+);/
24
+
25
+ /**
26
+ * Normalise a raw string: trim edges, then collapse newlines and whitespace
27
+ * runs to a single space. Applied to every string before it is used as a
28
+ * translation key so keys are stable regardless of source formatting.
29
+ *
30
+ * @param {string} raw
31
+ * @returns {string}
32
+ */
33
+ export function normalise(raw) {
34
+ return raw
35
+ .trim()
36
+ .replace(/\n/g, ' ')
37
+ .replace(/\s{2,}/g, ' ')
38
+ }
39
+
40
+ /**
41
+ * Split a template quasi (or any padded string segment) into leading
42
+ * whitespace, normalised translatable text, and trailing whitespace.
43
+ *
44
+ * Leading/trailing whitespace is preserved separately so spacing around
45
+ * the replacement node is maintained in the reconstructed JSX output.
46
+ * The middle part is passed through `normalise` so keys are consistent.
47
+ *
48
+ * Used by both the JSX template-literal transformer and the wt() template
49
+ * transformer — a single shared implementation ensures the two paths
50
+ * produce identical keys for the same source text.
51
+ *
52
+ * @param {string} raw - Cooked value of a TemplateElement or a split text segment.
53
+ * @returns {{ leading: string, text: string, trailing: string }}
54
+ */
55
+ export function splitQuasi(raw) {
56
+ const leading = raw.match(/^\s*/)[0]
57
+ const trailing = raw.match(/\s*$/)[0]
58
+ const text = normalise(raw.slice(leading.length, raw.length - trailing.length))
59
+ return {leading, text, trailing}
60
+ }
@@ -1,135 +0,0 @@
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) return false
105
-
106
- const {quasis, expressions} = arg
107
- const parts = ['`']
108
- let hasTranslatable = false
109
-
110
- for (let i = 0; i < quasis.length; i++) {
111
- const cooked = quasis[i].value.cooked ?? quasis[i].value.raw
112
- const {leading, text, trailing} = splitQuasi(cooked)
113
-
114
- if (text) {
115
- processor.record(text, text, id, '(wt)')
116
- parts.push(`${leading}\${wt(${JSON.stringify(text)})}${trailing}`)
117
- hasTranslatable = true
118
- } else {
119
- // empty or whitespace-only quasi — preserve as literal template text
120
- parts.push(cooked)
121
- }
122
-
123
- if (i < expressions.length) {
124
- const exprSrc = code.slice(expressions[i].start, expressions[i].end)
125
- parts.push(`\${${exprSrc}}`)
126
- }
127
- }
128
-
129
- parts.push('`')
130
-
131
- if (!hasTranslatable) return false
132
-
133
- ms.overwrite(path.node.start, path.node.end, parts.join(''))
134
- return true
135
- }