@npm-questionpro/wick-ui-i18n 2.0.0-next.9 → 2.0.1
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 -89
- package/index.d.ts +46 -9
- package/index.js +58 -18
- package/package.json +1 -4
- package/src/debug.js +4 -10
- package/src/extractStrings.js +58 -0
- package/src/processor.js +18 -38
- package/src/recordWtCalls.js +166 -0
- package/src/telemetry.js +106 -0
- package/src/transform.js +132 -110
- package/src/{transformJSXTextWithEntities.js → transformReactTextWithEntities.js} +7 -34
- package/src/transformTemplateLiteral.js +11 -27
- package/src/utils.js +60 -0
- package/src/transformWtCalls.js +0 -136
|
@@ -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
ADDED
|
@@ -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,36 +1,23 @@
|
|
|
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
|
-
import {
|
|
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]+);/
|
|
13
|
+
import {transformJsxTextWithEntities} from './transformReactTextWithEntities.js'
|
|
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) {
|
|
31
19
|
if (node.type === 'StringLiteral') return node.value
|
|
32
|
-
if (node.type === 'TemplateLiteral' && !node.expressions.length)
|
|
33
|
-
return node.quasis[0].value.cooked
|
|
20
|
+
if (node.type === 'TemplateLiteral' && !node.expressions.length) return node.quasis[0].value.cooked
|
|
34
21
|
return null
|
|
35
22
|
}
|
|
36
23
|
|
|
@@ -51,17 +38,7 @@ function handleConditional(expr, path, ms, processor, id) {
|
|
|
51
38
|
for (const branch of [expr.consequent, expr.alternate]) {
|
|
52
39
|
const text = getStaticString(branch)
|
|
53
40
|
if (text !== null) {
|
|
54
|
-
changed =
|
|
55
|
-
handleCapture(
|
|
56
|
-
path,
|
|
57
|
-
text,
|
|
58
|
-
branch.start,
|
|
59
|
-
branch.end,
|
|
60
|
-
ms,
|
|
61
|
-
processor,
|
|
62
|
-
id,
|
|
63
|
-
true,
|
|
64
|
-
) || changed
|
|
41
|
+
changed = handleCapture(path, text, branch.start, branch.end, ms, processor, id, true) || changed
|
|
65
42
|
} else if (branch.type === 'ConditionalExpression') {
|
|
66
43
|
changed = handleConditional(branch, path, ms, processor, id) || changed
|
|
67
44
|
}
|
|
@@ -83,20 +60,8 @@ function handleConditional(expr, path, ms, processor, id) {
|
|
|
83
60
|
* @param {string} id - File path (for collision warnings).
|
|
84
61
|
* @returns {boolean} `true` when replacement was made.
|
|
85
62
|
*/
|
|
86
|
-
function handleCapture(
|
|
87
|
-
|
|
88
|
-
text,
|
|
89
|
-
start,
|
|
90
|
-
end,
|
|
91
|
-
ms,
|
|
92
|
-
processor,
|
|
93
|
-
id,
|
|
94
|
-
skipExplicitKey = false,
|
|
95
|
-
) {
|
|
96
|
-
const cleanText = text
|
|
97
|
-
.trim()
|
|
98
|
-
.replace(/\n/g, ' ')
|
|
99
|
-
.replace(/\s{2,}/g, ' ')
|
|
63
|
+
function handleCapture(path, text, start, end, ms, processor, id, skipExplicitKey = false) {
|
|
64
|
+
const cleanText = normalise(text)
|
|
100
65
|
if (!cleanText || !processor.shouldTranslate(path)) return false
|
|
101
66
|
// For StringLiteral / TemplateLiteral quasis / ternary branches: entities are
|
|
102
67
|
// not decoded by the JS parser, so cleanText still contains "&" etc.
|
|
@@ -105,11 +70,67 @@ function handleCapture(
|
|
|
105
70
|
const key = (!skipExplicitKey && processor.getExplicitKey(path)) || cleanText
|
|
106
71
|
processor.record(key, cleanText, id, getComponentTree(path))
|
|
107
72
|
|
|
108
|
-
ms.overwrite(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
73
|
+
ms.overwrite(start, end, `<WuTranslate __i18nKey=${JSON.stringify(key)}></WuTranslate>`)
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
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('')}}`)
|
|
113
134
|
return true
|
|
114
135
|
}
|
|
115
136
|
|
|
@@ -120,10 +141,14 @@ function handleCapture(
|
|
|
120
141
|
* @param {string} code - Raw source of the file.
|
|
121
142
|
* @param {string} id - File path.
|
|
122
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.
|
|
123
148
|
* @returns {{ code: string, map: object } | null} Transformed result, or
|
|
124
149
|
* `null` when no changes were made.
|
|
125
150
|
*/
|
|
126
|
-
export function transformFile(code, id, processor) {
|
|
151
|
+
export function transformFile(code, id, processor, {telemetry} = {}) {
|
|
127
152
|
const ast = parse(code, {
|
|
128
153
|
sourceType: 'module',
|
|
129
154
|
plugins: BABEL_PLUGINS,
|
|
@@ -132,19 +157,18 @@ export function transformFile(code, id, processor) {
|
|
|
132
157
|
const ms = new MagicString(code)
|
|
133
158
|
let needsImport = false
|
|
134
159
|
let hasImport = false
|
|
135
|
-
let
|
|
136
|
-
let
|
|
137
|
-
let hasWtImport = false
|
|
160
|
+
let needsUseWtImport = false
|
|
161
|
+
let hasUseWtImport = false
|
|
138
162
|
|
|
139
163
|
traverse(ast, {
|
|
140
|
-
/**
|
|
141
|
-
*
|
|
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.
|
|
142
168
|
*/
|
|
143
169
|
CallExpression(path) {
|
|
144
170
|
if (recordWtCall(path, processor, id)) return
|
|
145
|
-
|
|
146
|
-
hasWtTransform = true
|
|
147
|
-
}
|
|
171
|
+
transformWtTemplateLiteral(path, code, ms, processor, id)
|
|
148
172
|
},
|
|
149
173
|
|
|
150
174
|
/** Track whether WuTranslate / wt are already imported so we don't duplicate them. */
|
|
@@ -152,7 +176,7 @@ export function transformFile(code, id, processor) {
|
|
|
152
176
|
if (path.node.source.value.includes('wick-ui-lib')) {
|
|
153
177
|
for (const s of path.node.specifiers) {
|
|
154
178
|
if (s.imported?.name === 'WuTranslate') hasImport = true
|
|
155
|
-
if (s.imported?.name === '
|
|
179
|
+
if (s.imported?.name === 'useWt') hasUseWtImport = true
|
|
156
180
|
}
|
|
157
181
|
}
|
|
158
182
|
},
|
|
@@ -160,7 +184,7 @@ export function transformFile(code, id, processor) {
|
|
|
160
184
|
/**
|
|
161
185
|
* Data-file key extraction: records string values of configured object
|
|
162
186
|
* property names (e.g. `label: 'Analytics'`) without rewriting code.
|
|
163
|
-
* Enabled via `
|
|
187
|
+
* Enabled via the `dataLabelKeys` plugin option.
|
|
164
188
|
*/
|
|
165
189
|
ObjectProperty(path) {
|
|
166
190
|
if (!processor.extractFromKeys.size) return
|
|
@@ -169,10 +193,7 @@ export function transformFile(code, id, processor) {
|
|
|
169
193
|
if (!keyName || !processor.extractFromKeys.has(keyName)) return
|
|
170
194
|
const value = path.node.value
|
|
171
195
|
if (value.type !== 'StringLiteral') return
|
|
172
|
-
const text = value.value
|
|
173
|
-
.trim()
|
|
174
|
-
.replace(/\n/g, ' ')
|
|
175
|
-
.replace(/\s{2,}/g, ' ')
|
|
196
|
+
const text = normalise(value.value)
|
|
176
197
|
if (!text) return
|
|
177
198
|
if (HTML_ENTITY_RE.test(text)) return
|
|
178
199
|
processor.record(text, text, id, '(data)')
|
|
@@ -180,28 +201,39 @@ export function transformFile(code, id, processor) {
|
|
|
180
201
|
|
|
181
202
|
/**
|
|
182
203
|
* Translatable props (Label, placeholder, title, aria-label, ...)
|
|
183
|
-
* 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")}`}
|
|
184
210
|
*/
|
|
185
211
|
JSXAttribute(path) {
|
|
186
212
|
const propName = path.node.name.name
|
|
187
213
|
if (typeof propName !== 'string') return
|
|
188
214
|
const value = path.node.value
|
|
189
|
-
if (!value
|
|
190
|
-
|
|
191
|
-
const rawValue = code.slice(value.start, value.end)
|
|
192
|
-
if (HTML_ENTITY_RE.test(rawValue)) return
|
|
215
|
+
if (!value) return
|
|
193
216
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
.
|
|
197
|
-
.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
+
}
|
|
201
229
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
+
}
|
|
205
237
|
},
|
|
206
238
|
|
|
207
239
|
/** Plain JSX text: `<Foo>Hello world</Foo>` */
|
|
@@ -213,23 +245,13 @@ export function transformFile(code, id, processor) {
|
|
|
213
245
|
// translatable text segments are still wrapped while entities stay put.
|
|
214
246
|
const rawSource = code.slice(path.node.start, path.node.end)
|
|
215
247
|
if (HTML_ENTITY_RE.test(rawSource)) {
|
|
216
|
-
if (
|
|
248
|
+
if (transformJsxTextWithEntities(path, rawSource, ms, processor, id)) {
|
|
217
249
|
needsImport = true
|
|
218
250
|
}
|
|
219
251
|
return
|
|
220
252
|
}
|
|
221
253
|
const start = path.node.start + text.indexOf(trimmed)
|
|
222
|
-
if (
|
|
223
|
-
handleCapture(
|
|
224
|
-
path,
|
|
225
|
-
trimmed,
|
|
226
|
-
start,
|
|
227
|
-
start + trimmed.length,
|
|
228
|
-
ms,
|
|
229
|
-
processor,
|
|
230
|
-
id,
|
|
231
|
-
)
|
|
232
|
-
) {
|
|
254
|
+
if (handleCapture(path, trimmed, start, start + trimmed.length, ms, processor, id)) {
|
|
233
255
|
needsImport = true
|
|
234
256
|
}
|
|
235
257
|
},
|
|
@@ -249,10 +271,7 @@ export function transformFile(code, id, processor) {
|
|
|
249
271
|
|
|
250
272
|
if (expr.type === 'StringLiteral') {
|
|
251
273
|
text = expr.value
|
|
252
|
-
} else if (
|
|
253
|
-
expr.type === 'TemplateLiteral' &&
|
|
254
|
-
expr.expressions.length > 0
|
|
255
|
-
) {
|
|
274
|
+
} else if (expr.type === 'TemplateLiteral' && expr.expressions.length > 0) {
|
|
256
275
|
if (transformTemplateLiteralExpression(path, code, ms, processor, id)) {
|
|
257
276
|
needsImport = true
|
|
258
277
|
}
|
|
@@ -264,27 +283,30 @@ export function transformFile(code, id, processor) {
|
|
|
264
283
|
return
|
|
265
284
|
}
|
|
266
285
|
|
|
267
|
-
if (
|
|
268
|
-
text &&
|
|
269
|
-
handleCapture(
|
|
270
|
-
path,
|
|
271
|
-
text,
|
|
272
|
-
path.node.start,
|
|
273
|
-
path.node.end,
|
|
274
|
-
ms,
|
|
275
|
-
processor,
|
|
276
|
-
id,
|
|
277
|
-
)
|
|
278
|
-
) {
|
|
286
|
+
if (text && handleCapture(path, text, path.node.start, path.node.end, ms, processor, id)) {
|
|
279
287
|
needsImport = true
|
|
280
288
|
}
|
|
281
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
|
+
},
|
|
282
304
|
})
|
|
283
305
|
|
|
284
|
-
if (!
|
|
306
|
+
if (!ms.hasChanged()) return null
|
|
285
307
|
|
|
286
|
-
if (
|
|
287
|
-
ms.prepend(`import {
|
|
308
|
+
if (needsUseWtImport && !hasUseWtImport) {
|
|
309
|
+
ms.prepend(`import { useWt } from '@npm-questionpro/wick-ui-lib';\n`)
|
|
288
310
|
}
|
|
289
311
|
|
|
290
312
|
if (needsImport && !hasImport) {
|