i18next-cli 1.34.0 → 1.34.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.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/cli.js +271 -1
  3. package/dist/cjs/config.js +211 -1
  4. package/dist/cjs/extractor/core/ast-visitors.js +364 -1
  5. package/dist/cjs/extractor/core/extractor.js +245 -1
  6. package/dist/cjs/extractor/core/key-finder.js +132 -1
  7. package/dist/cjs/extractor/core/translation-manager.js +745 -1
  8. package/dist/cjs/extractor/parsers/ast-utils.js +85 -1
  9. package/dist/cjs/extractor/parsers/call-expression-handler.js +941 -1
  10. package/dist/cjs/extractor/parsers/comment-parser.js +375 -1
  11. package/dist/cjs/extractor/parsers/expression-resolver.js +362 -1
  12. package/dist/cjs/extractor/parsers/jsx-handler.js +492 -1
  13. package/dist/cjs/extractor/parsers/jsx-parser.js +355 -1
  14. package/dist/cjs/extractor/parsers/scope-manager.js +408 -1
  15. package/dist/cjs/extractor/plugin-manager.js +106 -1
  16. package/dist/cjs/heuristic-config.js +99 -1
  17. package/dist/cjs/index.js +28 -1
  18. package/dist/cjs/init.js +174 -1
  19. package/dist/cjs/linter.js +431 -1
  20. package/dist/cjs/locize.js +269 -1
  21. package/dist/cjs/migrator.js +196 -1
  22. package/dist/cjs/rename-key.js +354 -1
  23. package/dist/cjs/status.js +336 -1
  24. package/dist/cjs/syncer.js +120 -1
  25. package/dist/cjs/types-generator.js +165 -1
  26. package/dist/cjs/utils/default-value.js +43 -1
  27. package/dist/cjs/utils/file-utils.js +136 -1
  28. package/dist/cjs/utils/funnel-msg-tracker.js +75 -1
  29. package/dist/cjs/utils/logger.js +36 -1
  30. package/dist/cjs/utils/nested-object.js +124 -1
  31. package/dist/cjs/utils/validation.js +71 -1
  32. package/dist/esm/cli.js +269 -1
  33. package/dist/esm/config.js +206 -1
  34. package/dist/esm/extractor/core/ast-visitors.js +362 -1
  35. package/dist/esm/extractor/core/extractor.js +241 -1
  36. package/dist/esm/extractor/core/key-finder.js +130 -1
  37. package/dist/esm/extractor/core/translation-manager.js +743 -1
  38. package/dist/esm/extractor/parsers/ast-utils.js +80 -1
  39. package/dist/esm/extractor/parsers/call-expression-handler.js +939 -1
  40. package/dist/esm/extractor/parsers/comment-parser.js +373 -1
  41. package/dist/esm/extractor/parsers/expression-resolver.js +360 -1
  42. package/dist/esm/extractor/parsers/jsx-handler.js +490 -1
  43. package/dist/esm/extractor/parsers/jsx-parser.js +334 -1
  44. package/dist/esm/extractor/parsers/scope-manager.js +406 -1
  45. package/dist/esm/extractor/plugin-manager.js +103 -1
  46. package/dist/esm/heuristic-config.js +97 -1
  47. package/dist/esm/index.js +11 -1
  48. package/dist/esm/init.js +172 -1
  49. package/dist/esm/linter.js +425 -1
  50. package/dist/esm/locize.js +265 -1
  51. package/dist/esm/migrator.js +194 -1
  52. package/dist/esm/rename-key.js +352 -1
  53. package/dist/esm/status.js +334 -1
  54. package/dist/esm/syncer.js +118 -1
  55. package/dist/esm/types-generator.js +163 -1
  56. package/dist/esm/utils/default-value.js +41 -1
  57. package/dist/esm/utils/file-utils.js +131 -1
  58. package/dist/esm/utils/funnel-msg-tracker.js +72 -1
  59. package/dist/esm/utils/logger.js +34 -1
  60. package/dist/esm/utils/nested-object.js +120 -1
  61. package/dist/esm/utils/validation.js +68 -1
  62. package/package.json +2 -2
  63. package/types/locize.d.ts.map +1 -1
package/dist/cjs/init.js CHANGED
@@ -1 +1,174 @@
1
- "use strict";var t=require("inquirer"),e=require("node:fs/promises"),n=require("node:path"),i=require("./heuristic-config.js");exports.runInit=async function(){console.log("Welcome to the i18next-cli setup wizard!"),console.log("Scanning your project for a recommended configuration...");const o=await i.detectConfig();o?console.log("✅ Found a potential project structure. Using it for suggestions."):console.log("Could not detect a project structure. Using standard defaults."),"string"==typeof o?.extract?.input&&(o.extract.input=[o?.extract?.input]),o&&"function"==typeof o.extract?.output&&delete o.extract.output;const r=await t.prompt([{type:"select",name:"fileType",message:"What kind of configuration file do you want?",choices:["TypeScript (i18next.config.ts)","JavaScript (i18next.config.js)"]},{type:"input",name:"locales",message:"What locales does your project support? (comma-separated)",default:o?.locales?.join(",")||"en,de,fr",filter:t=>t.split(",").map(t=>t.trim())},{type:"input",name:"input",message:"What is the glob pattern for your source files?",default:o?.extract?.input?(o.extract.input||[])[0]:"src/**/*.{js,jsx,ts,tsx}"},{type:"input",name:"output",message:"What is the path for your output resource files?",default:"string"==typeof o?.extract?.output?o.extract.output:"public/locales/{{language}}/{{namespace}}.json"}]),s=r.fileType.includes("TypeScript"),a=await async function(){try{const t=n.resolve(process.cwd(),"package.json"),i=await e.readFile(t,"utf-8");return"module"===JSON.parse(i).type}catch{return!0}}(),c=s?"i18next.config.ts":"i18next.config.js",u={locales:r.locales,extract:{input:r.input,output:r.output}};function p(t,e=2,n=0){const i=t=>" ".repeat(t*e),o=i(n),r=i(n+1);if(null===t||"number"==typeof t||"boolean"==typeof t)return JSON.stringify(t);if("string"==typeof t)return JSON.stringify(t);if(Array.isArray(t)){if(0===t.length)return"[]";return`[\n${t.map(t=>`${r}${p(t,e,n+1)}`).join(",\n")}\n${o}]`}if("object"==typeof t){const i=Object.keys(t);if(0===i.length)return"{}";return`{\n${i.map(i=>{const o=/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(i)?i:JSON.stringify(i);return`${r}${o}: ${p(t[i],e,n+1)}`}).join(",\n")}\n${o}}`}return JSON.stringify(t)}let f="";f=s?`import { defineConfig } from 'i18next-cli';\n\nexport default defineConfig(${p(u)});`:a?`import { defineConfig } from 'i18next-cli';\n\n/** @type {import('i18next-cli').I18nextToolkitConfig} */\nexport default defineConfig(${p(u)});`:`const { defineConfig } = require('i18next-cli');\n\n/** @type {import('i18next-cli').I18nextToolkitConfig} */\nmodule.exports = defineConfig(${p(u)});`;const l=n.resolve(process.cwd(),c);await e.writeFile(l,f.trim()),console.log(`✅ Configuration file created at: ${l}`)};
1
+ 'use strict';
2
+
3
+ var inquirer = require('inquirer');
4
+ var promises = require('node:fs/promises');
5
+ var node_path = require('node:path');
6
+ var heuristicConfig = require('./heuristic-config.js');
7
+
8
+ /**
9
+ * Determines if the current project is configured as an ESM project.
10
+ * Checks the package.json file for `"type": "module"`.
11
+ *
12
+ * @returns Promise resolving to true if ESM, false if CommonJS
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const isESM = await isEsmProject()
17
+ * if (isESM) {
18
+ * // Generate ESM syntax
19
+ * } else {
20
+ * // Generate CommonJS syntax
21
+ * }
22
+ * ```
23
+ */
24
+ async function isEsmProject() {
25
+ try {
26
+ const packageJsonPath = node_path.resolve(process.cwd(), 'package.json');
27
+ const content = await promises.readFile(packageJsonPath, 'utf-8');
28
+ const packageJson = JSON.parse(content);
29
+ return packageJson.type === 'module';
30
+ }
31
+ catch {
32
+ return true; // Default to ESM if package.json is not found or readable
33
+ }
34
+ }
35
+ /**
36
+ * Interactive setup wizard for creating a new i18next-cli configuration file.
37
+ *
38
+ * This function provides a guided setup experience that:
39
+ * 1. Asks the user for their preferred configuration file type (TypeScript or JavaScript)
40
+ * 2. Collects basic project settings (locales, input patterns, output paths)
41
+ * 3. Detects the project module system (ESM vs CommonJS) for JavaScript files
42
+ * 4. Generates an appropriate configuration file with proper syntax
43
+ * 5. Provides helpful defaults for common use cases
44
+ *
45
+ * The generated configuration includes:
46
+ * - Locale specification
47
+ * - Input file patterns for source scanning
48
+ * - Output path templates with placeholders
49
+ * - Proper imports and exports for the detected module system
50
+ * - JSDoc type annotations for JavaScript files
51
+ *
52
+ * @example
53
+ * ```typescript
54
+ * // Run the interactive setup
55
+ * await runInit()
56
+ *
57
+ * // This will create either:
58
+ * // - i18next.config.ts (TypeScript)
59
+ * // - i18next.config.js (JavaScript ESM/CommonJS)
60
+ * ```
61
+ */
62
+ async function runInit() {
63
+ console.log('Welcome to the i18next-cli setup wizard!');
64
+ console.log('Scanning your project for a recommended configuration...');
65
+ const detectedConfig = await heuristicConfig.detectConfig();
66
+ if (detectedConfig) {
67
+ console.log('✅ Found a potential project structure. Using it for suggestions.');
68
+ }
69
+ else {
70
+ console.log('Could not detect a project structure. Using standard defaults.');
71
+ }
72
+ if (typeof detectedConfig?.extract?.input === 'string')
73
+ detectedConfig.extract.input = [detectedConfig?.extract?.input];
74
+ // If heuristic detection returned a function for extract.output, don't use it as a prompt default.
75
+ // Prompt defaults must be strings; leave undefined so the prompt falls back to a sensible default.
76
+ if (detectedConfig && typeof detectedConfig.extract?.output === 'function') {
77
+ delete detectedConfig.extract.output;
78
+ }
79
+ const answers = await inquirer.prompt([
80
+ {
81
+ type: 'select',
82
+ name: 'fileType',
83
+ message: 'What kind of configuration file do you want?',
84
+ choices: ['TypeScript (i18next.config.ts)', 'JavaScript (i18next.config.js)'],
85
+ },
86
+ {
87
+ type: 'input',
88
+ name: 'locales',
89
+ message: 'What locales does your project support? (comma-separated)',
90
+ default: detectedConfig?.locales?.join(',') || 'en,de,fr',
91
+ filter: (input) => input.split(',').map(s => s.trim()),
92
+ },
93
+ {
94
+ type: 'input',
95
+ name: 'input',
96
+ message: 'What is the glob pattern for your source files?',
97
+ default: detectedConfig?.extract?.input ? (detectedConfig.extract.input || [])[0] : 'src/**/*.{js,jsx,ts,tsx}',
98
+ },
99
+ {
100
+ type: 'input',
101
+ name: 'output',
102
+ message: 'What is the path for your output resource files?',
103
+ // ensure the default is a string (detectedConfig.extract.output may be a function)
104
+ default: typeof detectedConfig?.extract?.output === 'string'
105
+ ? detectedConfig.extract.output
106
+ : 'public/locales/{{language}}/{{namespace}}.json',
107
+ },
108
+ ]);
109
+ const isTypeScript = answers.fileType.includes('TypeScript');
110
+ const isEsm = await isEsmProject();
111
+ const fileName = isTypeScript ? 'i18next.config.ts' : 'i18next.config.js';
112
+ const configObject = {
113
+ locales: answers.locales,
114
+ extract: {
115
+ input: answers.input,
116
+ output: answers.output,
117
+ },
118
+ };
119
+ // Helper to serialize a JS value as a JS literal:
120
+ function toJs(value, indent = 2, level = 0) {
121
+ const pad = (n) => ' '.repeat(n * indent);
122
+ const currentPad = pad(level);
123
+ const nextPad = pad(level + 1);
124
+ if (value === null || typeof value === 'number' || typeof value === 'boolean') {
125
+ return JSON.stringify(value);
126
+ }
127
+ if (typeof value === 'string') {
128
+ return JSON.stringify(value); // keeps double quotes and proper escaping
129
+ }
130
+ if (Array.isArray(value)) {
131
+ if (value.length === 0)
132
+ return '[]';
133
+ const items = value.map(v => `${nextPad}${toJs(v, indent, level + 1)}`).join(',\n');
134
+ return `[\n${items}\n${currentPad}]`;
135
+ }
136
+ if (typeof value === 'object') {
137
+ const keys = Object.keys(value);
138
+ if (keys.length === 0)
139
+ return '{}';
140
+ const entries = keys.map(key => {
141
+ // Use unquoted key if it's a valid identifier otherwise JSON.stringify(key)
142
+ const validId = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key);
143
+ const printedKey = validId ? key : JSON.stringify(key);
144
+ return `${nextPad}${printedKey}: ${toJs(value[key], indent, level + 1)}`;
145
+ }).join(',\n');
146
+ return `{\n${entries}\n${currentPad}}`;
147
+ }
148
+ // Fallback
149
+ return JSON.stringify(value);
150
+ }
151
+ let fileContent = '';
152
+ if (isTypeScript) {
153
+ fileContent = `import { defineConfig } from 'i18next-cli';
154
+
155
+ export default defineConfig(${toJs(configObject)});`;
156
+ }
157
+ else if (isEsm) {
158
+ fileContent = `import { defineConfig } from 'i18next-cli';
159
+
160
+ /** @type {import('i18next-cli').I18nextToolkitConfig} */
161
+ export default defineConfig(${toJs(configObject)});`;
162
+ }
163
+ else { // CJS
164
+ fileContent = `const { defineConfig } = require('i18next-cli');
165
+
166
+ /** @type {import('i18next-cli').I18nextToolkitConfig} */
167
+ module.exports = defineConfig(${toJs(configObject)});`;
168
+ }
169
+ const outputPath = node_path.resolve(process.cwd(), fileName);
170
+ await promises.writeFile(outputPath, fileContent.trim());
171
+ console.log(`✅ Configuration file created at: ${outputPath}`);
172
+ }
173
+
174
+ exports.runInit = runInit;
@@ -1 +1,431 @@
1
- "use strict";var e=require("glob"),t=require("node:fs/promises"),r=require("@swc/core"),n=require("node:path"),s=require("node:events"),a=require("chalk"),i=require("ora");const o=["a","abbr","address","article","aside","bdi","bdo","blockquote","button","caption","cite","code","data","dd","del","details","dfn","dialog","div","dt","em","figcaption","footer","h1","h2","h3","h4","h5","h6","header","img","ins","kbd","label","legend","li","main","mark","nav","option","output","p","pre","q","s","samp","section","small","span","strong","sub","summary","sup","td","textarea","th","time","title","var"].map(e=>e.toLowerCase()),c=["abbr","accesskey","alt","aria-description","aria-label","aria-placeholder","aria-roledescription","aria-valuetext","content","label","placeholder","summary","title"].map(e=>e.toLowerCase()),l=["className","key","id","style","href","i18nKey","defaults","type","target"].map(e=>e.toLowerCase()),u=["script","style","code"];class p extends s.EventEmitter{config;constructor(e){super({captureRejections:!0}),this.config=e}wrapError(e){const t="Linter failed to run: ";if(e instanceof Error){if(e.message.startsWith(t))return e;const r=new Error(`${t}${e.message}`);return r.stack=e.stack,r}return new Error(`${t}${String(e)}`)}async run(){const{config:s}=this;try{this.emit("progress",{message:"Finding source files to analyze..."});const a=["node_modules/**"],i=Array.isArray(s.extract.ignore)?s.extract.ignore:s.extract.ignore?[s.extract.ignore]:[],o=Array.isArray(s.lint?.ignore)?s.lint.ignore:s.lint?.ignore?[s.lint.ignore]:[],c=await e.glob(s.extract.input,{ignore:[...a,...i,...o]});this.emit("progress",{message:`Analyzing ${c.length} source files...`});let l=0;const u=new Map;for(const e of c){const a=await t.readFile(e,"utf-8"),i=n.extname(e).toLowerCase(),o=".ts"===i||".tsx"===i||".mts"===i||".cts"===i,c=".tsx"===i,p=".jsx"===i;let m;try{m=await r.parse(a,{syntax:o?"typescript":"ecmascript",tsx:c,jsx:p,decorators:!0})}catch(t){if(".ts"!==i||c){if(".js"!==i||p){const e=this.wrapError(t);this.emit("error",e);continue}try{m=await r.parse(a,{syntax:"ecmascript",jsx:!0,decorators:!0}),this.emit("progress",{message:`Parsed ${e} using JSX fallback`})}catch(e){const t=this.wrapError(e);this.emit("error",t);continue}}else try{m=await r.parse(a,{syntax:"typescript",tsx:!0,decorators:!0}),this.emit("progress",{message:`Parsed ${e} using TSX fallback`})}catch(e){const t=this.wrapError(e);this.emit("error",t);continue}}const g=f(m,a,s);g.length>0&&(l+=g.length,u.set(e,g))}const p={success:0===l,message:l>0?`Linter found ${l} potential issues.`:"No issues found.",files:Object.fromEntries(u.entries())};return this.emit("done",p),p}catch(e){const t=this.wrapError(e);throw this.emit("error",t),t}}}const m=e=>/^(https|http|\/\/|^\/)/.test(e);function f(e,t,r){const n=[],s=[],a=e=>t.substring(0,e).split("\n").length,i=(r.extract.transComponents||["Trans"]).map(e=>e.toLowerCase()),p=(r?.lint?.ignoredTags||r.extract.ignoredTags||[]).map(e=>e.toLowerCase()),f=new Set([...i,...u.map(e=>e.toLowerCase()),...p]),g=(r?.lint?.ignoredAttributes||r.extract.ignoredAttributes||[]).map(e=>e.toLowerCase()),d=new Set([...l,...g]),y=r?.lint?.acceptedTags?r.lint.acceptedTags:null,h=r?.extract?.acceptedTags?r.extract.acceptedTags:null,b=(y??h??o)?.map(e=>e.toLowerCase())??null,x=r?.lint?.acceptedAttributes?r.lint.acceptedAttributes:null,w=r?.extract?.acceptedAttributes?r.extract.acceptedAttributes:null,S=(x??w??c)?.map(e=>e.toLowerCase())??null,E=b&&b.length>0?new Set(b):null,v=S&&S.length>0?new Set(S):null,L=e=>{if(!e)return null;const t=e.name??e.opening?.name??e.opening?.name;if(!t)return e.opening?.name?L({name:e.opening.name}):null;const r=e=>{if(!e)return null;if("JSXIdentifier"===e.type&&(e.name||e.value))return e.name??e.value;if("Identifier"===e.type&&(e.name||e.value))return e.name??e.value;if("JSXMemberExpression"===e.type){const t=r(e.object),n=r(e.property);return t&&n?`${t}.${n}`:n??t}return e.name??e.value??e.property?.name??e.property?.value??null},n=r(t);return n?String(n):null},C=e=>{if(!e)return null;if("string"==typeof e)return e;if("JSXIdentifier"===e.type||"Identifier"===e.type){const t=e.name??e.value??e.raw??null;return t?String(t):null}if("JSXNamespacedName"===e.type)return C(e.name)??C(e.namespace);if("JSXMemberExpression"===e.type){const t=C(e.object),r=C(e.property);return t&&r?`${t}.${r}`:r??t}if(e.name||e.value||e.property)return e.name??e.value??e.property?.name??e.property?.value??null;try{const t=JSON.stringify(e),r=/"?(?:name|value)"?\s*:\s*"?([a-zA-Z0-9_\-:.$]+)"?/.exec(t);return r?r[1]:null}catch{return null}},A=e=>{for(let t=e.length-1;t>=0;t--){const r=e[t];if(r&&"object"==typeof r&&("JSXElement"===r.type||"JSXOpeningElement"===r.type||"JSXSelfClosingElement"===r.type)){const e=L(r);if(!e)continue;if(f.has(String(e).toLowerCase()))return!0}}if(E){for(let t=e.length-1;t>=0;t--){const r=e[t];if(r&&"object"==typeof r&&("JSXElement"===r.type||"JSXOpeningElement"===r.type||"JSXSelfClosingElement"===r.type)){const e=L(r);if(!e)continue;return!E.has(String(e).toLowerCase())}}return!0}return!1},J=(e,t)=>{if(!e||"object"!=typeof e)return;const r=[...t,e];if("JSXText"===e.type)if(v&&!E);else{if(!A(r)){const t=e.value.trim();t&&t.length>1&&"..."!==t&&!m(t)&&isNaN(Number(t))&&!t.startsWith("{{")&&s.push(e)}}if("StringLiteral"===e.type){const t=r[r.length-2],n=A(r);if("JSXAttribute"===t?.type&&!n){const n=C(t.name),a=n?String(n).toLowerCase():null,i=r.slice(0,-2).reverse().find(e=>e&&"object"==typeof e&&("JSXElement"===e.type||"JSXOpeningElement"===e.type||"JSXSelfClosingElement"===e.type));if(E&&i){const e=L(i);if(!e||!E.has(String(e).toLowerCase()))return}else if(E&&!i)return;if(v?null!=a&&v.has(a):null!=a&&!d.has(a)){const t=e.value.trim();t&&"..."!==t&&!m(t)&&isNaN(Number(t))&&s.push(e)}}}for(const t of Object.keys(e)){if("span"===t)continue;const n=e[t];Array.isArray(n)?n.forEach(e=>J(e,r)):n&&"object"==typeof n&&J(n,r)}};J(e,[]);let X=0;for(const e of s){const r=e.raw??e.value,s=t.indexOf(r,X);s>-1&&(n.push({text:e.value.trim(),line:a(s)}),X=s+r.length)}return n}exports.Linter=p,exports.recommendedAcceptedAttributes=c,exports.recommendedAcceptedTags=o,exports.runLinter=async function(e){return new p(e).run()},exports.runLinterCli=async function(e){const t=new p(e),r=i().start();t.on("progress",e=>{r.text=e.message});try{const{success:e,message:n,files:s}=await t.run();if(e)r.succeed(a.green.bold(n));else{r.fail(a.red.bold(n));for(const[e,t]of Object.entries(s))console.log(a.yellow(`\n${e}`)),t.forEach(({text:e,line:t})=>{console.log(` ${a.gray(`${t}:`)} ${a.red("Error:")} Found hardcoded string: "${e}"`)});process.exit(1)}}catch(e){const n=t.wrapError(e);r.fail(n.message),console.error(n),process.exit(1)}};
1
+ 'use strict';
2
+
3
+ var glob = require('glob');
4
+ var promises = require('node:fs/promises');
5
+ var core = require('@swc/core');
6
+ var node_path = require('node:path');
7
+ var node_events = require('node:events');
8
+ var chalk = require('chalk');
9
+ var ora = require('ora');
10
+
11
+ const recommendedAcceptedTags = [
12
+ 'a', 'abbr', 'address', 'article', 'aside', 'bdi', 'bdo', 'blockquote', 'button', 'caption', 'cite', 'code', 'data', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dt', 'em', 'figcaption', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'img', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'mark', 'nav', 'option', 'output', 'p', 'pre', 'q', 's', 'samp', 'section', 'small', 'span', 'strong', 'sub', 'summary', 'sup', 'td', 'textarea', 'th', 'time', 'title', 'var'
13
+ ].map(s => s.toLowerCase());
14
+ const recommendedAcceptedAttributes = ['abbr', 'accesskey', 'alt', 'aria-description', 'aria-label', 'aria-placeholder', 'aria-roledescription', 'aria-valuetext', 'content', 'label', 'placeholder', 'summary', 'title'].map(s => s.toLowerCase());
15
+ const defaultIgnoredAttributes = ['className', 'key', 'id', 'style', 'href', 'i18nKey', 'defaults', 'type', 'target'].map(s => s.toLowerCase());
16
+ const defaultIgnoredTags = ['script', 'style', 'code'];
17
+ class Linter extends node_events.EventEmitter {
18
+ config;
19
+ constructor(config) {
20
+ super({ captureRejections: true });
21
+ this.config = config;
22
+ }
23
+ wrapError(error) {
24
+ const prefix = 'Linter failed to run: ';
25
+ if (error instanceof Error) {
26
+ if (error.message.startsWith(prefix)) {
27
+ return error;
28
+ }
29
+ const wrappedError = new Error(`${prefix}${error.message}`);
30
+ wrappedError.stack = error.stack;
31
+ return wrappedError;
32
+ }
33
+ return new Error(`${prefix}${String(error)}`);
34
+ }
35
+ async run() {
36
+ const { config } = this;
37
+ try {
38
+ this.emit('progress', { message: 'Finding source files to analyze...' });
39
+ const defaultIgnore = ['node_modules/**'];
40
+ const extractIgnore = Array.isArray(config.extract.ignore)
41
+ ? config.extract.ignore
42
+ : config.extract.ignore ? [config.extract.ignore] : [];
43
+ const lintIgnore = Array.isArray(config.lint?.ignore)
44
+ ? config.lint.ignore
45
+ : config.lint?.ignore ? [config.lint.ignore] : [];
46
+ const sourceFiles = await glob.glob(config.extract.input, {
47
+ ignore: [...defaultIgnore, ...extractIgnore, ...lintIgnore]
48
+ });
49
+ this.emit('progress', { message: `Analyzing ${sourceFiles.length} source files...` });
50
+ let totalIssues = 0;
51
+ const issuesByFile = new Map();
52
+ for (const file of sourceFiles) {
53
+ const code = await promises.readFile(file, 'utf-8');
54
+ // Determine parser options from file extension so .ts is not parsed as TSX
55
+ const fileExt = node_path.extname(file).toLowerCase();
56
+ const isTypeScriptFile = fileExt === '.ts' || fileExt === '.tsx' || fileExt === '.mts' || fileExt === '.cts';
57
+ const isTSX = fileExt === '.tsx';
58
+ const isJSX = fileExt === '.jsx';
59
+ let ast;
60
+ try {
61
+ ast = await core.parse(code, {
62
+ syntax: isTypeScriptFile ? 'typescript' : 'ecmascript',
63
+ tsx: isTSX,
64
+ jsx: isJSX,
65
+ decorators: true
66
+ });
67
+ }
68
+ catch (err) {
69
+ // Fallback for .ts files with JSX
70
+ if (fileExt === '.ts' && !isTSX) {
71
+ try {
72
+ ast = await core.parse(code, {
73
+ syntax: 'typescript',
74
+ tsx: true,
75
+ decorators: true
76
+ });
77
+ this.emit('progress', { message: `Parsed ${file} using TSX fallback` });
78
+ }
79
+ catch (err2) {
80
+ const wrapped = this.wrapError(err2);
81
+ this.emit('error', wrapped);
82
+ continue;
83
+ }
84
+ // Fallback for .js files with JSX
85
+ }
86
+ else if (fileExt === '.js' && !isJSX) {
87
+ try {
88
+ ast = await core.parse(code, {
89
+ syntax: 'ecmascript',
90
+ jsx: true,
91
+ decorators: true
92
+ });
93
+ this.emit('progress', { message: `Parsed ${file} using JSX fallback` });
94
+ }
95
+ catch (err2) {
96
+ const wrapped = this.wrapError(err2);
97
+ this.emit('error', wrapped);
98
+ continue;
99
+ }
100
+ }
101
+ else {
102
+ const wrapped = this.wrapError(err);
103
+ this.emit('error', wrapped);
104
+ continue;
105
+ }
106
+ }
107
+ const hardcodedStrings = findHardcodedStrings(ast, code, config);
108
+ if (hardcodedStrings.length > 0) {
109
+ totalIssues += hardcodedStrings.length;
110
+ issuesByFile.set(file, hardcodedStrings);
111
+ }
112
+ }
113
+ const files = Object.fromEntries(issuesByFile.entries());
114
+ const data = { success: totalIssues === 0, message: totalIssues > 0 ? `Linter found ${totalIssues} potential issues.` : 'No issues found.', files };
115
+ this.emit('done', data);
116
+ return data;
117
+ }
118
+ catch (error) {
119
+ const wrappedError = this.wrapError(error);
120
+ this.emit('error', wrappedError);
121
+ throw wrappedError;
122
+ }
123
+ }
124
+ }
125
+ /**
126
+ * Runs the i18next linter to detect hardcoded strings and other potential issues.
127
+ *
128
+ * This function performs static analysis on source files to identify:
129
+ * - Hardcoded text strings in JSX elements
130
+ * - Hardcoded strings in JSX attributes (like alt text, titles, etc.)
131
+ * - Text that should be extracted for translation
132
+ *
133
+ * The linter respects configuration settings:
134
+ * - Uses the same input patterns as the extractor
135
+ * - Ignores content inside configured Trans components
136
+ * - Skips technical content like script/style tags
137
+ * - Identifies numeric values and interpolation syntax to avoid false positives
138
+ *
139
+ * @param config - The toolkit configuration with input patterns and component names
140
+ *
141
+ * @example
142
+ * ```typescript
143
+ * const config = {
144
+ * extract: {
145
+ * input: ['src/**\/*.{ts,tsx}'],
146
+ * transComponents: ['Trans', 'Translation']
147
+ * }
148
+ * }
149
+ *
150
+ * await runLinter(config)
151
+ * // Outputs issues found or success message
152
+ * ```
153
+ */
154
+ async function runLinter(config) {
155
+ return new Linter(config).run();
156
+ }
157
+ async function runLinterCli(config) {
158
+ const linter = new Linter(config);
159
+ const spinner = ora().start();
160
+ linter.on('progress', (event) => {
161
+ spinner.text = event.message;
162
+ });
163
+ try {
164
+ const { success, message, files } = await linter.run();
165
+ if (!success) {
166
+ spinner.fail(chalk.red.bold(message));
167
+ // Print detailed report after spinner fails
168
+ for (const [file, issues] of Object.entries(files)) {
169
+ console.log(chalk.yellow(`\n${file}`));
170
+ issues.forEach(({ text, line }) => {
171
+ console.log(` ${chalk.gray(`${line}:`)} ${chalk.red('Error:')} Found hardcoded string: "${text}"`);
172
+ });
173
+ }
174
+ process.exit(1);
175
+ }
176
+ else {
177
+ spinner.succeed(chalk.green.bold(message));
178
+ }
179
+ }
180
+ catch (error) {
181
+ const wrappedError = linter.wrapError(error);
182
+ spinner.fail(wrappedError.message);
183
+ console.error(wrappedError);
184
+ process.exit(1);
185
+ }
186
+ }
187
+ const isUrlOrPath = (text) => /^(https|http|\/\/|^\/)/.test(text);
188
+ /**
189
+ * Analyzes an AST to find potentially hardcoded strings that should be translated.
190
+ *
191
+ * This function traverses the syntax tree looking for:
192
+ * 1. JSX text nodes with translatable content
193
+ * 2. String literals in JSX attributes that might need translation
194
+ *
195
+ * It applies several filters to reduce false positives:
196
+ * - Ignores content inside Trans components (already handled)
197
+ * - Skips script and style tag content (technical, not user-facing)
198
+ * - Filters out numeric values (usually not translatable)
199
+ * - Ignores interpolation syntax starting with `{{`
200
+ * - Filters out ellipsis/spread operator notation `...`
201
+ * - Only reports non-empty, trimmed strings
202
+ *
203
+ * @param config - The toolkit configuration with input patterns and component names
204
+ *
205
+ * @example
206
+ * ```typescript
207
+ * const config = {
208
+ * extract: {
209
+ * input: ['src/**\/*.{ts,tsx}'],
210
+ * transComponents: ['Trans', 'Translation']
211
+ * }
212
+ * }
213
+ *
214
+ * await runLinter(config)
215
+ * // Outputs issues found or success message
216
+ * ```
217
+ */
218
+ function findHardcodedStrings(ast, code, config) {
219
+ const issues = [];
220
+ // A list of AST nodes that have been identified as potential issues.
221
+ const nodesToLint = [];
222
+ const getLineNumber = (pos) => {
223
+ return code.substring(0, pos).split('\n').length;
224
+ };
225
+ const transComponents = (config.extract.transComponents || ['Trans']).map((s) => s.toLowerCase());
226
+ const customIgnoredTags = (config?.lint?.ignoredTags || config.extract.ignoredTags || []).map((s) => s.toLowerCase());
227
+ const allIgnoredTags = new Set([...transComponents, ...defaultIgnoredTags.map(s => s.toLowerCase()), ...customIgnoredTags]);
228
+ const customIgnoredAttributes = (config?.lint?.ignoredAttributes || config.extract.ignoredAttributes || []).map((s) => s.toLowerCase());
229
+ const ignoredAttributes = new Set([...defaultIgnoredAttributes, ...customIgnoredAttributes]);
230
+ const lintAcceptedTags = config?.lint?.acceptedTags ? config.lint.acceptedTags : null;
231
+ const extractAcceptedTags = config?.extract?.acceptedTags ? config.extract.acceptedTags : null;
232
+ const acceptedTagsList = (lintAcceptedTags ?? extractAcceptedTags ?? recommendedAcceptedTags)?.map((s) => s.toLowerCase()) ?? null;
233
+ const lintAcceptedAttrs = config?.lint?.acceptedAttributes ? config.lint.acceptedAttributes : null;
234
+ const extractAcceptedAttrs = config?.extract?.acceptedAttributes ? config.extract.acceptedAttributes : null;
235
+ const acceptedAttributesList = (lintAcceptedAttrs ?? extractAcceptedAttrs ?? recommendedAcceptedAttributes)?.map((s) => s.toLowerCase()) ?? null;
236
+ const acceptedTagsSet = acceptedTagsList && acceptedTagsList.length > 0 ? new Set(acceptedTagsList) : null;
237
+ const acceptedAttributesSet = acceptedAttributesList && acceptedAttributesList.length > 0 ? new Set(acceptedAttributesList) : null;
238
+ // Helper: robustly extract a JSX element name from different node shapes
239
+ const extractJSXName = (node) => {
240
+ if (!node)
241
+ return null;
242
+ // node might be JSXOpeningElement / JSXSelfClosingElement (has .name)
243
+ const nameNode = node.name ?? node.opening?.name ?? node.opening?.name;
244
+ if (!nameNode) {
245
+ // maybe this node is a full JSXElement with opening.name
246
+ if (node.opening?.name)
247
+ return extractJSXName({ name: node.opening.name });
248
+ return null;
249
+ }
250
+ const fromIdentifier = (n) => {
251
+ if (!n)
252
+ return null;
253
+ if (n.type === 'JSXIdentifier' && (n.name || n.value))
254
+ return (n.name ?? n.value);
255
+ if (n.type === 'Identifier' && (n.name || n.value))
256
+ return (n.name ?? n.value);
257
+ if (n.type === 'JSXMemberExpression') {
258
+ const object = fromIdentifier(n.object);
259
+ const property = fromIdentifier(n.property);
260
+ return object && property ? `${object}.${property}` : (property ?? object);
261
+ }
262
+ // fallback attempts
263
+ return n.name ?? n.value ?? n.property?.name ?? n.property?.value ?? null;
264
+ };
265
+ const rawName = fromIdentifier(nameNode);
266
+ return rawName ? String(rawName) : null;
267
+ };
268
+ // Helper: extract attribute name from a JSXAttribute.name node
269
+ const extractAttrName = (nameNode) => {
270
+ if (!nameNode)
271
+ return null;
272
+ // Direct string (unlikely, but be defensive)
273
+ if (typeof nameNode === 'string')
274
+ return nameNode;
275
+ // Common SWC shapes:
276
+ // JSXIdentifier: { type: 'JSXIdentifier', value: 'alt' } or { name: 'alt' }
277
+ if (nameNode.type === 'JSXIdentifier' || nameNode.type === 'Identifier') {
278
+ const n = (nameNode.name ?? nameNode.value ?? nameNode.raw ?? null);
279
+ return n ? String(n) : null;
280
+ }
281
+ // JSXNamespacedName: { type: 'JSXNamespacedName', namespace: {...}, name: {...} }
282
+ if (nameNode.type === 'JSXNamespacedName') {
283
+ // prefer the local name (after the colon)
284
+ return extractAttrName(nameNode.name) ?? extractAttrName(nameNode.namespace);
285
+ }
286
+ // Member-like expressions (defensive)
287
+ if (nameNode.type === 'JSXMemberExpression') {
288
+ const left = extractAttrName(nameNode.object);
289
+ const right = extractAttrName(nameNode.property);
290
+ if (left && right)
291
+ return `${left}.${right}`;
292
+ return right ?? left;
293
+ }
294
+ // Some AST variants put the identifier under `.name` or `.value`
295
+ if (nameNode.name || nameNode.value || nameNode.property) {
296
+ return (nameNode.name ?? nameNode.value ?? nameNode.property?.name ?? nameNode.property?.value ?? null);
297
+ }
298
+ // Last-resort: try to stringify and extract an identifier-looking token
299
+ try {
300
+ const s = JSON.stringify(nameNode);
301
+ const m = /"?(?:name|value)"?\s*:\s*"?([a-zA-Z0-9_\-:.$]+)"?/.exec(s);
302
+ return m ? m[1] : null;
303
+ }
304
+ catch {
305
+ return null;
306
+ }
307
+ };
308
+ // Helper: return true if any JSX ancestor is in the ignored tags set
309
+ const isWithinIgnoredElement = (ancestors) => {
310
+ // First: if ANY ancestor is in the ignored set -> ignore (ignored always wins)
311
+ for (let i = ancestors.length - 1; i >= 0; i--) {
312
+ const an = ancestors[i];
313
+ if (!an || typeof an !== 'object')
314
+ continue;
315
+ if (an.type === 'JSXElement' || an.type === 'JSXOpeningElement' || an.type === 'JSXSelfClosingElement') {
316
+ const name = extractJSXName(an);
317
+ if (!name)
318
+ continue;
319
+ if (allIgnoredTags.has(String(name).toLowerCase()))
320
+ return true;
321
+ }
322
+ }
323
+ // If acceptedTags is set: use nearest enclosing JSX element to decide acceptance
324
+ if (acceptedTagsSet) {
325
+ for (let i = ancestors.length - 1; i >= 0; i--) {
326
+ const an = ancestors[i];
327
+ if (!an || typeof an !== 'object')
328
+ continue;
329
+ if (an.type === 'JSXElement' || an.type === 'JSXOpeningElement' || an.type === 'JSXSelfClosingElement') {
330
+ const name = extractJSXName(an);
331
+ if (!name)
332
+ continue;
333
+ return !acceptedTagsSet.has(String(name).toLowerCase());
334
+ }
335
+ }
336
+ // no enclosing element found -> treat as ignored
337
+ return true;
338
+ }
339
+ // Default: not inside an ignored element
340
+ return false;
341
+ };
342
+ // --- PHASE 1: Collect all potentially problematic nodes ---
343
+ const walk = (node, ancestors) => {
344
+ if (!node || typeof node !== 'object')
345
+ return;
346
+ const currentAncestors = [...ancestors, node];
347
+ if (node.type === 'JSXText') {
348
+ // If acceptedAttributesSet exists but acceptedTagsSet does not, we're in attribute-only mode:
349
+ // do not collect JSXText nodes when attribute-only mode is active.
350
+ if (acceptedAttributesSet && !acceptedTagsSet) ;
351
+ else {
352
+ const isIgnored = isWithinIgnoredElement(currentAncestors);
353
+ if (!isIgnored) {
354
+ const text = node.value.trim();
355
+ if (text && text.length > 1 && text !== '...' && !isUrlOrPath(text) && isNaN(Number(text)) && !text.startsWith('{{')) {
356
+ nodesToLint.push(node);
357
+ }
358
+ }
359
+ }
360
+ }
361
+ if (node.type === 'StringLiteral') {
362
+ const parent = currentAncestors[currentAncestors.length - 2];
363
+ // Determine whether this attribute is inside any ignored element (handles nested Trans etc.)
364
+ const insideIgnored = isWithinIgnoredElement(currentAncestors);
365
+ if (parent?.type === 'JSXAttribute' && !insideIgnored) {
366
+ const rawAttrName = extractAttrName(parent.name);
367
+ const attrNameLower = rawAttrName ? String(rawAttrName).toLowerCase() : null;
368
+ // Check tag-level acceptance if acceptedTagsSet provided: attributes should only be considered
369
+ // when the nearest enclosing element is accepted.
370
+ const parentElement = currentAncestors.slice(0, -2).reverse().find(a => a && typeof a === 'object' && (a.type === 'JSXElement' || a.type === 'JSXOpeningElement' || a.type === 'JSXSelfClosingElement'));
371
+ if (acceptedTagsSet && parentElement) {
372
+ const parentName = extractJSXName(parentElement);
373
+ if (!parentName || !acceptedTagsSet.has(String(parentName).toLowerCase())) {
374
+ // attribute is inside a non-accepted tag -> skip
375
+ return;
376
+ }
377
+ }
378
+ else if (acceptedTagsSet && !parentElement) {
379
+ // no enclosing element -> skip
380
+ return;
381
+ }
382
+ // If acceptedAttributesSet exists, only lint attributes explicitly accepted.
383
+ const shouldLintAttribute = acceptedAttributesSet
384
+ ? (attrNameLower != null && acceptedAttributesSet.has(attrNameLower))
385
+ : (attrNameLower != null ? !ignoredAttributes.has(attrNameLower) : false);
386
+ if (shouldLintAttribute) {
387
+ const text = node.value.trim();
388
+ // Filter out: empty strings, URLs, numbers, and ellipsis
389
+ if (text && text !== '...' && !isUrlOrPath(text) && isNaN(Number(text))) {
390
+ nodesToLint.push(node); // Collect the node
391
+ }
392
+ }
393
+ }
394
+ }
395
+ // Recurse into children
396
+ for (const key of Object.keys(node)) {
397
+ if (key === 'span')
398
+ continue;
399
+ const child = node[key];
400
+ if (Array.isArray(child)) {
401
+ child.forEach(item => walk(item, currentAncestors));
402
+ }
403
+ else if (child && typeof child === 'object') {
404
+ walk(child, currentAncestors);
405
+ }
406
+ }
407
+ };
408
+ walk(ast, []); // Run the walk to collect nodes
409
+ // --- PHASE 2: Find line numbers using a tracked search on the raw source code ---
410
+ let lastSearchIndex = 0;
411
+ for (const node of nodesToLint) {
412
+ // For StringLiterals, the `raw` property includes the quotes ("..."), which is
413
+ // much more unique for searching than the plain `value`.
414
+ const searchText = node.raw ?? node.value;
415
+ const position = code.indexOf(searchText, lastSearchIndex);
416
+ if (position > -1) {
417
+ issues.push({
418
+ text: node.value.trim(),
419
+ line: getLineNumber(position),
420
+ });
421
+ lastSearchIndex = position + searchText.length;
422
+ }
423
+ }
424
+ return issues;
425
+ }
426
+
427
+ exports.Linter = Linter;
428
+ exports.recommendedAcceptedAttributes = recommendedAcceptedAttributes;
429
+ exports.recommendedAcceptedTags = recommendedAcceptedTags;
430
+ exports.runLinter = runLinter;
431
+ exports.runLinterCli = runLinterCli;