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