@npm-questionpro/wick-ui-i18n 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,181 +1,95 @@
1
- import { createFilter } from "vite";
2
- import MagicString from "magic-string";
3
- import { parse } from "@babel/parser";
4
- import _traverse from "@babel/traverse";
5
-
6
- const traverse = _traverse.default || _traverse;
7
-
8
- class TranslationProcessor {
9
- constructor(options) {
10
- this.components = new Set(options.components);
11
- this.ignoreComponents = new Set(
12
- [
13
- "WuIcon",
14
- "WuTranslateProvider",
15
- "WuHelpButton",
16
- "WuActivityLog",
17
- "WuAppHeader",
18
- "WuAPpHeadeMenu",
19
- "WuCopyToClipboard",
20
- "WuMenuIcon",
21
- "WuScrollArea",
22
- "WuDrawer",
23
- "WuLoader",
24
- "WuContentEditor",
25
- ].concat(options.ignoreComponents || []),
26
- );
27
- this.dictionary = new Map();
28
- this.debugEnabled = options.debug || false;
29
- }
30
-
31
- log(...args) {
32
- if (this.debugEnabled) console.log("[wick-i18n]", ...args);
33
- }
34
-
35
- record(key, text, file) {
36
- if (this.dictionary.has(key) && this.dictionary.get(key) !== text) {
37
- console.warn(
38
- `[wick-i18n] Collision in ${file}\nKey: "${key}"\nNew: "${text}"`,
39
- );
40
- return;
41
- }
42
- this.dictionary.set(key, text);
43
- }
44
-
45
- shouldTranslate(path) {
46
- let isIgnored = false;
47
- let targetFound = false;
48
-
49
- path.findParent((p) => {
50
- if (!p.isJSXElement()) return false;
51
-
52
- const name =
53
- p.node.openingElement.name.name ||
54
- p.node.openingElement.name.property?.name;
55
- const attrs = p.node.openingElement.attributes || [];
56
-
57
- if (
58
- attrs.some((a) =>
59
- ["data-skip", "data-i18n-skip"].includes(a.name?.name),
60
- )
61
- ) {
62
- isIgnored = true;
63
- return true;
64
- }
1
+ /**
2
+ * @fileoverview wick-ui-i18n — Vite plugin that automatically wraps static JSX
3
+ * text inside Wick UI components with `<WuTranslate>` at build/dev time and
4
+ * emits a `wick-ui-i18n.json` translation dictionary.
5
+ *
6
+ * @example
7
+ * // vite.config.js
8
+ * import wickI18n from '@npm-questionpro/wick-ui-i18n';
9
+ *
10
+ * export default {
11
+ * plugins: [
12
+ * wickI18n({
13
+ * components: ['MyWidget'],
14
+ * ignoreComponents: ['MyRawHtml'],
15
+ * debug: true,
16
+ * }),
17
+ * ],
18
+ * };
19
+ */
65
20
 
66
- if (this.ignoreComponents.has(name)) {
67
- isIgnored = true;
68
- return true;
69
- }
70
-
71
- const hasWrapper = attrs.some(
72
- (a) => a.name?.name === "data-i18n-wrapper",
73
- );
74
- const isTarget = this.components.has(name) || name?.startsWith("Wu");
75
- // const isTarget = this.components.has(name);
76
-
77
- if (hasWrapper || isTarget) {
78
- targetFound = true;
79
- return true;
80
- }
81
-
82
- return false;
83
- });
84
-
85
- return targetFound && !isIgnored;
86
- }
21
+ import { createFilter } from "vite";
22
+ import { TranslationProcessor } from "./src/processor.js";
23
+ import { transformFile } from "./src/transform.js";
24
+ import { printReport } from "./src/debug.js";
25
+
26
+ /**
27
+ * @typedef {object} WickI18nOptions
28
+ * @property {string[]} [components] - Extra component names that trigger translation.
29
+ * @property {string[]} [ignoreComponents] - Component names to exclude from translation.
30
+ * @property {string|string[]|RegExp} [excludeFiles] - Files to skip (passed as `exclude` to Vite's createFilter).
31
+ * @property {boolean} [debug] - Log transform activity to the console.
32
+ */
33
+
34
+ /**
35
+ * Vite plugin factory for wick-ui-i18n.
36
+ *
37
+ * @param {WickI18nOptions} [options]
38
+ * @returns {import('vite').Plugin}
39
+ */
40
+ export default function wickuiI18nPlugin(options = {}) {
41
+ const processor = new TranslationProcessor({
42
+ components: options.components || [],
43
+ ignoreComponents: options.ignoreComponents,
44
+ debug: options.debug,
45
+ });
87
46
 
88
- getExplicitKey(path) {
89
- const parent = path.findParent((p) => p.isJSXElement());
90
- const attr = parent?.node.openingElement.attributes.find(
91
- (a) => a.name?.name === "data-i18n-key",
92
- );
93
- if (!attr) return null;
94
- return attr.value.type === "StringLiteral"
95
- ? attr.value.value
96
- : attr.value.expression?.value;
97
- }
98
- }
47
+ const filter = createFilter([/\.(jsx|tsx)$/], options.excludeFiles);
99
48
 
100
- export default function wickuiI18nPlugin(options = {}) {
101
- const processor = new TranslationProcessor(options);
102
- const filter = createFilter(options.include || [/\.(jsx|tsx)$/]);
49
+ let base = "/";
103
50
 
104
51
  return {
105
52
  name: "wick-ui-i18n",
106
53
  enforce: "pre",
107
54
 
108
- transform(code, id) {
109
- if (!filter(id) || !code.includes("Wu")) return null;
110
-
111
- const ast = parse(code, {
112
- sourceType: "module",
113
- plugins: ["jsx", "typescript"],
114
- });
115
- const ms = new MagicString(code);
116
- let [needsImport, hasImport] = [false, false];
117
-
118
- const handleCapture = (path, text, start, end) => {
119
- const cleanText = text.trim();
120
- if (!cleanText || !processor.shouldTranslate(path)) return;
121
-
122
- const key = processor.getExplicitKey(path) || cleanText;
123
- processor.record(key, cleanText, id);
124
-
125
- const original = ms.slice(start, end);
126
- ms.overwrite(
127
- start,
128
- end,
129
- `<WuTranslate __i18nKey=${JSON.stringify(key)}></WuTranslate>`,
130
- );
131
- needsImport = true;
132
- };
133
-
134
- traverse(ast, {
135
- ImportDeclaration(path) {
136
- if (path.node.source.value.includes("wick-ui-lib")) {
137
- hasImport = path.node.specifiers.some(
138
- (s) => s.imported?.name === "WuTranslate",
139
- );
140
- }
141
- },
142
- JSXText(path) {
143
- const text = path.node.value;
144
- const trimmed = text.trim();
145
- const start = path.node.start + text.indexOf(trimmed);
146
- handleCapture(path, trimmed, start, start + trimmed.length);
147
- },
148
- JSXExpressionContainer(path) {
149
- const expr = path.node.expression;
150
- let text = null;
151
- if (expr.type === "StringLiteral") text = expr.value;
152
- else if (
153
- expr.type === "TemplateLiteral" &&
154
- !expr.expressions.length
155
- ) {
156
- text = expr.quasis[0].value.cooked;
157
- }
158
- if (text) handleCapture(path, text, path.node.start, path.node.end);
159
- },
160
- });
161
-
162
- if (needsImport && !hasImport) {
163
- ms.prepend(
164
- `import { WuTranslate } from '@npm-questionpro/wick-ui-lib';\n`,
165
- );
166
- }
167
-
168
- return needsImport
169
- ? { code: ms.toString(), map: ms.generateMap({ hires: true }) }
170
- : null;
55
+ /**
56
+ * Capture the resolved base so the dev-server middleware path matches
57
+ * what `import.meta.env.BASE_URL` resolves to in the consumer app.
58
+ *
59
+ * @param {import('vite').ResolvedConfig} resolvedConfig
60
+ */
61
+ configResolved(resolvedConfig) {
62
+ base = resolvedConfig.base;
171
63
  },
172
64
 
65
+ /**
66
+ * Clear state on every build so stale keys don't accumulate across
67
+ * watch-mode rebuilds.
68
+ */
173
69
  buildStart() {
174
70
  processor.dictionary.clear();
71
+ processor.entries = [];
175
72
  },
176
73
 
74
+ /**
75
+ * Transform a single file: skip fast if it has no "Wu" tokens, then run
76
+ * the full AST rewrite.
77
+ *
78
+ * @param {string} code
79
+ * @param {string} id
80
+ */
81
+ transform(code, id) {
82
+ if (!filter(id) || (!code.includes("Wu") && !/\bwt\(/.test(code))) return null;
83
+ return transformFile(code, id, processor);
84
+ },
85
+
86
+ /**
87
+ * Expose the collected dictionary at `GET /wick-ui-i18n.json` during dev.
88
+ *
89
+ * @param {import('vite').ViteDevServer} server
90
+ */
177
91
  configureServer(server) {
178
- server.middlewares.use("/wick-ui-i18n.json", (req, res) => {
92
+ server.middlewares.use(`${base}wick-ui-i18n.json`, (_req, res) => {
179
93
  res.setHeader("Content-Type", "application/json");
180
94
  res.end(
181
95
  JSON.stringify(Object.fromEntries(processor.dictionary), null, 2),
@@ -183,6 +97,7 @@ export default function wickuiI18nPlugin(options = {}) {
183
97
  });
184
98
  },
185
99
 
100
+ /** Emit the translation dictionary as a build asset and print debug table. */
186
101
  generateBundle() {
187
102
  this.emitFile({
188
103
  type: "asset",
@@ -193,6 +108,8 @@ export default function wickuiI18nPlugin(options = {}) {
193
108
  2,
194
109
  ),
195
110
  });
111
+
112
+ printReport(processor.entries);
196
113
  },
197
114
  };
198
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@npm-questionpro/wick-ui-i18n",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "license": "ISC",
5
5
  "description": "Auto-translation AST wrapper for Wick UI",
6
6
  "type": "module",
package/src/debug.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @fileoverview Build-time debug reporter — prints a formatted table of every
3
+ * captured translation entry (text, source file, JSX component tree) to stdout
4
+ * after the bundle is generated.
5
+ */
6
+
7
+ import { basename } from "node:path";
8
+
9
+ /**
10
+ * Walk up the Babel path and collect JSX element names from outermost inward.
11
+ *
12
+ * @param {import('@babel/traverse').NodePath} path
13
+ * @returns {string} e.g. `"WuProvider > div > WuButton"`
14
+ */
15
+ export function getComponentTree(path) {
16
+ const parts = [];
17
+ path.findParent((p) => {
18
+ if (p.isJSXElement()) {
19
+ const name =
20
+ p.node.openingElement.name.name ||
21
+ p.node.openingElement.name.property?.name;
22
+ if (name) parts.unshift(name);
23
+ }
24
+ return false;
25
+ });
26
+ return parts.join(" > ") || "(root)";
27
+ }
28
+
29
+ /**
30
+ * @typedef {object} DebugEntry
31
+ * @property {string} key - i18n key used in WuTranslate.
32
+ * @property {string} text - Original source text.
33
+ * @property {string} file - Absolute path of the source file.
34
+ * @property {string} componentTree - Ancestor JSX chain, outermost first.
35
+ */
36
+
37
+ /**
38
+ * Print a box-drawing ASCII table of all captured translation entries.
39
+ * Columns are auto-sized to content.
40
+ *
41
+ * @param {DebugEntry[]} entries
42
+ */
43
+ export function printReport(entries) {
44
+ if (!entries.length) {
45
+ console.log("\n[wick-i18n] No translations captured.\n");
46
+ return;
47
+ }
48
+
49
+ const headers = ["Text", "File", "Component Tree"];
50
+
51
+ const rows = entries.map((e) => [e.text, basename(e.file), e.componentTree]);
52
+
53
+ const cols = headers.length;
54
+ const widths = headers.map((h, i) =>
55
+ Math.max(h.length, ...rows.map((r) => r[i].length)),
56
+ );
57
+
58
+ const pad = (str, w) => str.padEnd(w);
59
+ const sep = (l, m, r, fill) =>
60
+ l + widths.map((w) => fill.repeat(w + 2)).join(m) + r;
61
+
62
+ const top = sep("┌", "┬", "┐", "─");
63
+ const mid = sep("├", "┼", "┤", "─");
64
+ const bottom = sep("└", "┴", "┘", "─");
65
+ const row = (cells) =>
66
+ "│" + cells.map((c, i) => ` ${pad(c, widths[i])} `).join("│") + "│";
67
+
68
+ const lines = [
69
+ "",
70
+ `[wick-i18n] Build report — ${entries.length} translation(s) captured`,
71
+ top,
72
+ row(headers),
73
+ mid,
74
+ ...rows.map(row),
75
+ bottom,
76
+ "",
77
+ ];
78
+
79
+ console.log(lines.join("\n"));
80
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * @fileoverview TranslationProcessor — maintains the translation dictionary and
3
+ * decides which JSX nodes should be wrapped with WuTranslate.
4
+ */
5
+
6
+ /** Components always excluded from translation regardless of user config. */
7
+ const DEFAULT_IGNORE = [
8
+ "WuIcon",
9
+ "WuTranslateProvider",
10
+ "WuHelpButton",
11
+ "WuActivityLog",
12
+ "WuAppHeader",
13
+ "WuAPpHeadeMenu",
14
+ "WuCopyToClipboard",
15
+ "WuMenuIcon",
16
+ "WuScrollArea",
17
+ "WuDrawer",
18
+ "WuLoader",
19
+ "WuContentEditor",
20
+ ];
21
+
22
+ export class TranslationProcessor {
23
+ /**
24
+ * @param {object} options
25
+ * @param {string[]} options.components - Component names that trigger translation.
26
+ * @param {string[]} [options.ignoreComponents] - Extra components to exclude.
27
+ * @param {boolean} [options.debug] - Enable verbose logging.
28
+ */
29
+ constructor(options) {
30
+ this.components = new Set(options.components);
31
+ this.ignoreComponents = new Set(
32
+ DEFAULT_IGNORE.concat(options.ignoreComponents || []),
33
+ );
34
+ /** @type {Map<string, string>} key → original text */
35
+ this.dictionary = new Map();
36
+ /** @type {import('./debug.js').DebugEntry[]} */
37
+ this.entries = [];
38
+ this.debugEnabled = options.debug || false;
39
+ }
40
+
41
+ /**
42
+ * Conditional logger — only emits when `debug: true`.
43
+ * @param {...unknown} args
44
+ */
45
+ log(...args) {
46
+ if (this.debugEnabled) console.log("[wick-i18n]", ...args);
47
+ }
48
+
49
+ /**
50
+ * Store a key→text pair and append a debug entry.
51
+ * Warns on collision (same key, different text).
52
+ *
53
+ * @param {string} key
54
+ * @param {string} text
55
+ * @param {string} file - Source file path.
56
+ * @param {string} componentTree - JSX ancestor chain for the debug report.
57
+ */
58
+ record(key, text, file, componentTree) {
59
+ if (this.dictionary.has(key) && this.dictionary.get(key) !== text) {
60
+ console.warn(
61
+ `[wick-i18n] Collision in ${file}\nKey: "${key}"\nNew: "${text}"`,
62
+ );
63
+ return;
64
+ }
65
+ this.dictionary.set(key, text);
66
+ this.entries.push({ key, text, file, componentTree });
67
+ }
68
+
69
+ /**
70
+ * Walk up the JSX tree from `path` to determine if the node is inside a
71
+ * component that should be translated.
72
+ *
73
+ * Rules (first match wins, walking outward):
74
+ * - `data-skip` / `data-i18n-skip` attr → ignored
75
+ * - component in `ignoreComponents` → ignored
76
+ * - `data-i18n-wrapper` attr OR component starts with "Wu" / is in `components` → translate
77
+ *
78
+ * @param {import('@babel/traverse').NodePath} path
79
+ * @returns {boolean}
80
+ */
81
+ shouldTranslate(path) {
82
+ let isIgnored = false;
83
+ let targetFound = false;
84
+
85
+ path.findParent((p) => {
86
+ if (!p.isJSXElement()) return false;
87
+
88
+ const name =
89
+ p.node.openingElement.name.name ||
90
+ p.node.openingElement.name.property?.name;
91
+ const attrs = p.node.openingElement.attributes || [];
92
+
93
+ if (
94
+ attrs.some((a) =>
95
+ ["data-skip", "data-i18n-skip"].includes(a.name?.name),
96
+ )
97
+ ) {
98
+ isIgnored = true;
99
+ return true;
100
+ }
101
+
102
+ if (this.ignoreComponents.has(name)) {
103
+ isIgnored = true;
104
+ return true;
105
+ }
106
+
107
+ const hasWrapper = attrs.some(
108
+ (a) => a.name?.name === "data-i18n-wrapper",
109
+ );
110
+ const isTarget = this.components.has(name) || name?.startsWith("Wu");
111
+
112
+ if (hasWrapper || isTarget) {
113
+ targetFound = true;
114
+ return true;
115
+ }
116
+
117
+ return false;
118
+ });
119
+
120
+ return targetFound && !isIgnored;
121
+ }
122
+
123
+ /**
124
+ * Return the explicit i18n key from the nearest ancestor JSX element's
125
+ * `data-i18n-key` attribute, or `null` if absent.
126
+ *
127
+ * @param {import('@babel/traverse').NodePath} path
128
+ * @returns {string|null}
129
+ */
130
+ getExplicitKey(path) {
131
+ let result = null;
132
+ path.findParent((p) => {
133
+ if (!p.isJSXElement()) return false;
134
+ const attr = p.node.openingElement.attributes.find(
135
+ (a) => a.name?.name === "data-i18n-key",
136
+ );
137
+ if (!attr) return false;
138
+ const val =
139
+ attr.value.type === "StringLiteral"
140
+ ? attr.value.value
141
+ : attr.value.expression?.value;
142
+ if (!val) {
143
+ console.warn(
144
+ `[wick-i18n] data-i18n-key on <${p.node.openingElement.name.name}> is dynamic or empty — falling back to text content.`,
145
+ );
146
+ return true;
147
+ }
148
+ result = val;
149
+ return true;
150
+ });
151
+ return result;
152
+ }
153
+ }