@npm-questionpro/wick-ui-i18n 2.0.0-next.30 → 2.0.0-next.32
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 +34 -88
- package/index.d.ts +46 -9
- package/index.js +40 -8
- package/package.json +1 -1
- package/src/extractStrings.js +58 -0
- package/src/processor.js +12 -10
- package/src/recordWtCalls.js +166 -0
- package/src/telemetry.js +25 -42
- package/src/transform.js +123 -53
- package/src/transformReactTextWithEntities.js +3 -20
- package/src/transformTemplateLiteral.js +10 -18
- package/src/utils.js +60 -0
- package/src/transformWtCalls.js +0 -135
package/README.md
CHANGED
|
@@ -1,104 +1,50 @@
|
|
|
1
1
|
# wick-ui-i18n
|
|
2
2
|
|
|
3
|
-
Vite plugin — wraps JSX text in Wu components with `<WuTranslate>`, rewrites translatable props to `{
|
|
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>&</WuButton>` | ❌ |
|
|
19
|
-
| `<WuButton>Hello & World</WuButton>` | ✅ `<WuTranslate __i18nKey="Hello" /> & <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
|
-
|
|
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
|
|
8
|
+
---
|
|
56
9
|
|
|
57
|
-
|
|
10
|
+
## Quick start
|
|
58
11
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
| `<WuField Label={variable} />` | ❌ |
|
|
65
|
-
| `<WuField Label="" />` | ❌ |
|
|
66
|
-
| `<WuIcon Label="x" />` | ❌ |
|
|
67
|
-
| `<input placeholder="x" />` | ❌ |
|
|
68
|
-
| `<WuField data-skip Label="x" />` | ❌ |
|
|
12
|
+
```ts
|
|
13
|
+
// vite.config.ts
|
|
14
|
+
import wickI18n from '@npm-questionpro/wick-ui-i18n'
|
|
15
|
+
export default defineConfig({plugins: [react(), wickI18n()]})
|
|
16
|
+
```
|
|
69
17
|
|
|
70
|
-
|
|
18
|
+
---
|
|
71
19
|
|
|
72
|
-
|
|
20
|
+
## What it transforms
|
|
73
21
|
|
|
74
|
-
|
|
|
75
|
-
|
|
|
76
|
-
|
|
|
77
|
-
|
|
|
78
|
-
|
|
|
79
|
-
|
|
|
80
|
-
| `wt(
|
|
81
|
-
|
|
|
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}` `` |
|
|
82
31
|
|
|
83
|
-
|
|
32
|
+
---
|
|
84
33
|
|
|
85
|
-
|
|
34
|
+
## Options
|
|
86
35
|
|
|
87
|
-
|
|
|
88
|
-
|
|
|
89
|
-
| `
|
|
90
|
-
| `
|
|
91
|
-
| `
|
|
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` | — | Globs (project files via Vite) or exact paths (anywhere, incl. `node_modules` via `fs`) |
|
|
43
|
+
| `excludeFiles` | — | Files skipped entirely |
|
|
44
|
+
| `debug` | `false` | Print extraction table at build |
|
|
92
45
|
|
|
93
46
|
---
|
|
94
47
|
|
|
95
|
-
##
|
|
48
|
+
## Breaking changes
|
|
96
49
|
|
|
97
|
-
|
|
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 |
|
|
50
|
+
See [`BREAKING_CHANGES.md`](./BREAKING_CHANGES.md).
|
package/index.d.ts
CHANGED
|
@@ -5,26 +5,61 @@ 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 `{
|
|
13
|
-
*
|
|
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.
|
|
20
|
-
*
|
|
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
|
-
|
|
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
|
+
* Two modes depending on the value:
|
|
43
|
+
* - **Glob patterns** (`'src/enums/*.ts'`) — matched via Vite's transform pipeline.
|
|
44
|
+
* Works for files in your project; does **not** reach `node_modules`.
|
|
45
|
+
* - **Exact paths** (`'node_modules/@company/shared/enums.ts'`) — read directly
|
|
46
|
+
* with `fs` at build time, bypassing Vite's transform pipeline. Use this for
|
|
47
|
+
* `node_modules` files. Missing files produce a `console.warn`.
|
|
48
|
+
*
|
|
49
|
+
* Both modes can be mixed in the same array.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* dictionaryFiles: [
|
|
53
|
+
* 'src/enums/*.ts', // glob — project files
|
|
54
|
+
* 'node_modules/@company/shared/enums.ts', // exact — node_modules
|
|
55
|
+
* ]
|
|
56
|
+
*/
|
|
57
|
+
dictionaryFiles?: string | RegExp | Array<string | RegExp>
|
|
23
58
|
|
|
24
59
|
/** Files to skip entirely. Passed to Vite's `createFilter` as `exclude`. */
|
|
25
60
|
excludeFiles?: string | RegExp | Array<string | RegExp>
|
|
26
61
|
|
|
27
|
-
/**
|
|
62
|
+
/** Print a build-time extraction report table to stdout. */
|
|
28
63
|
debug?: boolean
|
|
29
64
|
}
|
|
30
65
|
|
|
@@ -32,7 +67,9 @@ export interface WickI18nOptions {
|
|
|
32
67
|
* Vite plugin that automatically translates Wick UI components.
|
|
33
68
|
*
|
|
34
69
|
* - JSX text content inside Wu* → `<WuTranslate __i18nKey="..." />`
|
|
35
|
-
* - Translatable props (placeholder
|
|
36
|
-
* -
|
|
70
|
+
* - Translatable props (`Label`, `placeholder`, `title`, …) → `{useWt()("...")}`
|
|
71
|
+
* - `wt("literal")` and `useWt()("literal")` calls → keys recorded in dictionary
|
|
72
|
+
* - `wt(\`Hello ${name}\`)` and `useWt()(\`Hello ${name}\`)` → each static quasi extracted and rewritten
|
|
73
|
+
* - Emits `wick-ui-i18n.json` at build time / serves it at `GET /wick-ui-i18n.json` in dev
|
|
37
74
|
*/
|
|
38
75
|
export default function wickuiI18nPlugin(options?: WickI18nOptions): Plugin
|
package/index.js
CHANGED
|
@@ -18,19 +18,27 @@
|
|
|
18
18
|
* };
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import fs from 'node:fs'
|
|
22
|
+
import path from 'node:path'
|
|
21
23
|
import {createFilter} from 'vite'
|
|
22
24
|
import {TranslationProcessor} from './src/processor.js'
|
|
23
25
|
import {transformFile} from './src/transform.js'
|
|
24
26
|
import {printReport} from './src/debug.js'
|
|
25
|
-
import {TelemetryCollector
|
|
27
|
+
import {TelemetryCollector} from './src/telemetry.js'
|
|
28
|
+
import {extractStringsFromFile} from './src/extractStrings.js'
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* @typedef {object} WickI18nOptions
|
|
29
32
|
* @property {string[]} [components] - Extra component names that trigger translation.
|
|
30
33
|
* @property {string[]} [ignoreComponents] - Component names to exclude from translation.
|
|
31
|
-
* @property {string[]} [translatableProps] - JSX prop names rewritten to `{
|
|
32
|
-
* @property {string[]} [
|
|
33
|
-
*
|
|
34
|
+
* @property {string[]} [translatableProps] - JSX prop names rewritten to `{useWt()("...")}`. Defaults to `['Label','placeholder','title','aria-label','aria-placeholder']`.
|
|
35
|
+
* @property {string[]} [dataLabelKeys] - Object property names whose string values are labels in mixed data
|
|
36
|
+
* files (e.g. `['label', 'title']` in nav config arrays). No code rewrite;
|
|
37
|
+
* keys are recorded into `wick-ui-i18n.json` for the translation API.
|
|
38
|
+
* @property {string|string[]|RegExp} [dictionaryFiles] - Files where every string is a display label (enum files,
|
|
39
|
+
* message constants). Pattern: `'**\/enums\/*.ts'`.
|
|
40
|
+
* Do NOT point at mixed data files — use `dataLabelKeys` for those.
|
|
41
|
+
* @property {string|string[]|RegExp} [excludeFiles] - Files to skip (passed as `exclude` to Vite's createFilter).
|
|
34
42
|
* @property {boolean} [debug] - Log transform activity to the console.
|
|
35
43
|
*/
|
|
36
44
|
|
|
@@ -47,13 +55,24 @@ export default function wickuiI18nPlugin(options = {}) {
|
|
|
47
55
|
components: options.components || [],
|
|
48
56
|
ignoreComponents: options.ignoreComponents,
|
|
49
57
|
translatableProps: options.translatableProps,
|
|
50
|
-
|
|
58
|
+
dataLabelKeys: options.dataLabelKeys,
|
|
51
59
|
debug: options.debug,
|
|
52
60
|
})
|
|
53
61
|
|
|
54
62
|
const filter = createFilter([/\.(jsx|tsx|ts)$/], options.excludeFiles)
|
|
63
|
+
const enumFilter = options.dictionaryFiles ? createFilter(options.dictionaryFiles) : null
|
|
64
|
+
|
|
65
|
+
// Exact-path entries from dictionaryFiles (no glob chars) are read via fs in buildStart
|
|
66
|
+
// so they work for node_modules files that Vite never processes through transforms.
|
|
67
|
+
const dictionaryPatterns = (() => {
|
|
68
|
+
const raw = options.dictionaryFiles
|
|
69
|
+
if (!raw) return []
|
|
70
|
+
const arr = Array.isArray(raw) ? raw : [raw]
|
|
71
|
+
return arr.filter(p => typeof p === 'string' && !/[*?{}[\]]/.test(p))
|
|
72
|
+
})()
|
|
55
73
|
|
|
56
74
|
let base = '/'
|
|
75
|
+
let root = '/'
|
|
57
76
|
|
|
58
77
|
return {
|
|
59
78
|
name: 'wick-ui-i18n',
|
|
@@ -67,6 +86,7 @@ export default function wickuiI18nPlugin(options = {}) {
|
|
|
67
86
|
*/
|
|
68
87
|
configResolved(resolvedConfig) {
|
|
69
88
|
base = resolvedConfig.base
|
|
89
|
+
root = resolvedConfig.root
|
|
70
90
|
telemetry.scanVersions(resolvedConfig.root)
|
|
71
91
|
},
|
|
72
92
|
|
|
@@ -78,6 +98,16 @@ export default function wickuiI18nPlugin(options = {}) {
|
|
|
78
98
|
processor.dictionary.clear()
|
|
79
99
|
processor.entries = []
|
|
80
100
|
telemetry.reset()
|
|
101
|
+
|
|
102
|
+
for (const rel of dictionaryPatterns) {
|
|
103
|
+
const abs = path.isAbsolute(rel) ? rel : path.resolve(root, rel)
|
|
104
|
+
try {
|
|
105
|
+
const code = fs.readFileSync(abs, 'utf8')
|
|
106
|
+
extractStringsFromFile(code, abs, processor)
|
|
107
|
+
} catch {
|
|
108
|
+
console.warn(`[wick-i18n] dictionaryFiles: could not read "${rel}" — file not found or unreadable`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
81
111
|
},
|
|
82
112
|
|
|
83
113
|
/**
|
|
@@ -88,14 +118,16 @@ export default function wickuiI18nPlugin(options = {}) {
|
|
|
88
118
|
* @param {string} id
|
|
89
119
|
*/
|
|
90
120
|
transform(code, id) {
|
|
121
|
+
if (enumFilter?.(id)) extractStringsFromFile(code, id, processor)
|
|
122
|
+
|
|
91
123
|
const hasAnyTarget =
|
|
92
124
|
code.includes('Wu') ||
|
|
93
|
-
/\bwt\(/.test(code) ||
|
|
125
|
+
/\bwt\(|useWt\(/.test(code) ||
|
|
94
126
|
(processor.components.size > 0 && [...processor.components].some(c => code.includes(c))) ||
|
|
95
127
|
(processor.extractFromKeys.size > 0 && [...processor.extractFromKeys].some(k => code.includes(k)))
|
|
96
|
-
if (filter(id) && code.includes('Wu')) collectComponents(code, telemetry)
|
|
97
128
|
if (!filter(id) || !hasAnyTarget) return null
|
|
98
|
-
|
|
129
|
+
// Pass telemetry into the same traversal — avoids a second parse of the file.
|
|
130
|
+
return transformFile(code, id, processor, {telemetry: code.includes('Wu') ? telemetry : null})
|
|
99
131
|
},
|
|
100
132
|
|
|
101
133
|
/**
|
package/package.json
CHANGED
|
@@ -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
|
-
'
|
|
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
|
|
31
|
-
* @param {string[]} [options.
|
|
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
|
|
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.
|
|
41
|
+
this.extractFromKeys = new Set(options.dataLabelKeys || [])
|
|
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-
|
|
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 =>
|
|
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 `{
|
|
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-
|
|
137
|
+
// Respect data-i18n-skip on the same element
|
|
136
138
|
const attrs = openingEl.attributes || []
|
|
137
|
-
if (attrs.some(a =>
|
|
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
|
+
}
|
package/src/telemetry.js
CHANGED
|
@@ -4,18 +4,15 @@
|
|
|
4
4
|
* across the entire build. Emitted as `telemetry.json` at build time and
|
|
5
5
|
* served at `GET /telemetry.json` in dev.
|
|
6
6
|
*
|
|
7
|
-
* Intentionally isolated from i18n logic — no imports from other src/ files
|
|
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.
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
14
|
import fs from 'node:fs'
|
|
11
15
|
import path from 'node:path'
|
|
12
|
-
import {parse} from '@babel/parser'
|
|
13
|
-
import _traverse from '@babel/traverse'
|
|
14
|
-
|
|
15
|
-
const traverse = _traverse.default || _traverse
|
|
16
|
-
|
|
17
|
-
/** Babel parser plugins applied to every file. */
|
|
18
|
-
const BABEL_PLUGINS = ['jsx', 'typescript']
|
|
19
16
|
|
|
20
17
|
export class TelemetryCollector {
|
|
21
18
|
constructor() {
|
|
@@ -69,17 +66,31 @@ export class TelemetryCollector {
|
|
|
69
66
|
// malformed or missing package.json — skip
|
|
70
67
|
}
|
|
71
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
|
+
}
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
/**
|
|
75
|
-
* Produce the final telemetry payload
|
|
76
|
-
*
|
|
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.
|
|
77
88
|
*
|
|
78
|
-
* @returns {Record<string, string
|
|
89
|
+
* @returns {{ versions: Record<string, string>, components: Record<string, object> }}
|
|
79
90
|
*/
|
|
80
91
|
toJSON() {
|
|
81
|
-
const
|
|
82
|
-
const
|
|
92
|
+
const versions = Object.fromEntries(Object.entries(this.versions).sort(([a], [b]) => a.localeCompare(b)))
|
|
93
|
+
const components = Object.fromEntries(
|
|
83
94
|
[...this.counts.entries()]
|
|
84
95
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
85
96
|
.map(([name, {count, props}]) => [
|
|
@@ -90,34 +101,6 @@ export class TelemetryCollector {
|
|
|
90
101
|
},
|
|
91
102
|
]),
|
|
92
103
|
)
|
|
93
|
-
return {
|
|
104
|
+
return {versions, components}
|
|
94
105
|
}
|
|
95
106
|
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Parse a single file and record all Wu* JSX component usages into the collector.
|
|
99
|
-
* Skips files that fail to parse without throwing.
|
|
100
|
-
*
|
|
101
|
-
* @param {string} code - Raw source of the file.
|
|
102
|
-
* @param {TelemetryCollector} collector
|
|
103
|
-
*/
|
|
104
|
-
export function collectComponents(code, collector) {
|
|
105
|
-
let ast
|
|
106
|
-
try {
|
|
107
|
-
ast = parse(code, {sourceType: 'module', plugins: BABEL_PLUGINS})
|
|
108
|
-
} catch {
|
|
109
|
-
return
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
traverse(ast, {
|
|
113
|
-
JSXOpeningElement(path) {
|
|
114
|
-
const name = path.node.name.name
|
|
115
|
-
if (typeof name === 'string' && name.startsWith('Wu')) {
|
|
116
|
-
const props = path.node.attributes
|
|
117
|
-
.filter(a => a.type === 'JSXAttribute' && typeof a.name?.name === 'string')
|
|
118
|
-
.map(a => a.name.name)
|
|
119
|
-
collector.record(name, props)
|
|
120
|
-
}
|
|
121
|
-
},
|
|
122
|
-
})
|
|
123
|
-
}
|
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 `{
|
|
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 './
|
|
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 (&), decimal (©), or hex (©).
|
|
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 & → "&", → "\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 "&" 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
|
|
112
|
-
let
|
|
113
|
-
let hasWtImport = false
|
|
160
|
+
let needsUseWtImport = false
|
|
161
|
+
let hasUseWtImport = false
|
|
114
162
|
|
|
115
163
|
traverse(ast, {
|
|
116
|
-
/**
|
|
117
|
-
*
|
|
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
|
-
|
|
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 === '
|
|
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 `
|
|
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 `{
|
|
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
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
.
|
|
173
|
-
.
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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 (!
|
|
306
|
+
if (!ms.hasChanged()) return null
|
|
237
307
|
|
|
238
|
-
if (
|
|
239
|
-
ms.prepend(`import {
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 (&), decimal (©), or hex (©).
|
|
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 & → "&", → "\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
|
+
}
|
package/src/transformWtCalls.js
DELETED
|
@@ -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
|
-
}
|