i18next-cli 0.9.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.
Files changed (127) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +489 -0
  4. package/dist/cjs/cli.js +2 -0
  5. package/dist/cjs/config.js +1 -0
  6. package/dist/cjs/extractor/core/extractor.js +1 -0
  7. package/dist/cjs/extractor/core/key-finder.js +1 -0
  8. package/dist/cjs/extractor/core/translation-manager.js +1 -0
  9. package/dist/cjs/extractor/parsers/ast-visitors.js +1 -0
  10. package/dist/cjs/extractor/parsers/comment-parser.js +1 -0
  11. package/dist/cjs/extractor/parsers/jsx-parser.js +1 -0
  12. package/dist/cjs/extractor/plugin-manager.js +1 -0
  13. package/dist/cjs/heuristic-config.js +1 -0
  14. package/dist/cjs/index.js +1 -0
  15. package/dist/cjs/init.js +1 -0
  16. package/dist/cjs/linter.js +1 -0
  17. package/dist/cjs/locize.js +1 -0
  18. package/dist/cjs/migrator.js +1 -0
  19. package/dist/cjs/package.json +1 -0
  20. package/dist/cjs/status.js +1 -0
  21. package/dist/cjs/syncer.js +1 -0
  22. package/dist/cjs/types-generator.js +1 -0
  23. package/dist/cjs/utils/file-utils.js +1 -0
  24. package/dist/cjs/utils/logger.js +1 -0
  25. package/dist/cjs/utils/nested-object.js +1 -0
  26. package/dist/cjs/utils/validation.js +1 -0
  27. package/dist/esm/cli.js +2 -0
  28. package/dist/esm/config.js +1 -0
  29. package/dist/esm/extractor/core/extractor.js +1 -0
  30. package/dist/esm/extractor/core/key-finder.js +1 -0
  31. package/dist/esm/extractor/core/translation-manager.js +1 -0
  32. package/dist/esm/extractor/parsers/ast-visitors.js +1 -0
  33. package/dist/esm/extractor/parsers/comment-parser.js +1 -0
  34. package/dist/esm/extractor/parsers/jsx-parser.js +1 -0
  35. package/dist/esm/extractor/plugin-manager.js +1 -0
  36. package/dist/esm/heuristic-config.js +1 -0
  37. package/dist/esm/index.js +1 -0
  38. package/dist/esm/init.js +1 -0
  39. package/dist/esm/linter.js +1 -0
  40. package/dist/esm/locize.js +1 -0
  41. package/dist/esm/migrator.js +1 -0
  42. package/dist/esm/status.js +1 -0
  43. package/dist/esm/syncer.js +1 -0
  44. package/dist/esm/types-generator.js +1 -0
  45. package/dist/esm/utils/file-utils.js +1 -0
  46. package/dist/esm/utils/logger.js +1 -0
  47. package/dist/esm/utils/nested-object.js +1 -0
  48. package/dist/esm/utils/validation.js +1 -0
  49. package/package.json +81 -0
  50. package/src/cli.ts +166 -0
  51. package/src/config.ts +158 -0
  52. package/src/extractor/core/extractor.ts +195 -0
  53. package/src/extractor/core/key-finder.ts +70 -0
  54. package/src/extractor/core/translation-manager.ts +115 -0
  55. package/src/extractor/index.ts +7 -0
  56. package/src/extractor/parsers/ast-visitors.ts +637 -0
  57. package/src/extractor/parsers/comment-parser.ts +125 -0
  58. package/src/extractor/parsers/jsx-parser.ts +166 -0
  59. package/src/extractor/plugin-manager.ts +54 -0
  60. package/src/extractor.ts +15 -0
  61. package/src/heuristic-config.ts +64 -0
  62. package/src/index.ts +12 -0
  63. package/src/init.ts +156 -0
  64. package/src/linter.ts +191 -0
  65. package/src/locize.ts +251 -0
  66. package/src/migrator.ts +139 -0
  67. package/src/status.ts +192 -0
  68. package/src/syncer.ts +114 -0
  69. package/src/types-generator.ts +116 -0
  70. package/src/types.ts +312 -0
  71. package/src/utils/file-utils.ts +81 -0
  72. package/src/utils/logger.ts +36 -0
  73. package/src/utils/nested-object.ts +113 -0
  74. package/src/utils/validation.ts +69 -0
  75. package/tryme.js +8 -0
  76. package/tsconfig.json +71 -0
  77. package/types/cli.d.ts +3 -0
  78. package/types/cli.d.ts.map +1 -0
  79. package/types/config.d.ts +50 -0
  80. package/types/config.d.ts.map +1 -0
  81. package/types/extractor/core/extractor.d.ts +66 -0
  82. package/types/extractor/core/extractor.d.ts.map +1 -0
  83. package/types/extractor/core/key-finder.d.ts +31 -0
  84. package/types/extractor/core/key-finder.d.ts.map +1 -0
  85. package/types/extractor/core/translation-manager.d.ts +31 -0
  86. package/types/extractor/core/translation-manager.d.ts.map +1 -0
  87. package/types/extractor/index.d.ts +8 -0
  88. package/types/extractor/index.d.ts.map +1 -0
  89. package/types/extractor/parsers/ast-visitors.d.ts +235 -0
  90. package/types/extractor/parsers/ast-visitors.d.ts.map +1 -0
  91. package/types/extractor/parsers/comment-parser.d.ts +24 -0
  92. package/types/extractor/parsers/comment-parser.d.ts.map +1 -0
  93. package/types/extractor/parsers/jsx-parser.d.ts +35 -0
  94. package/types/extractor/parsers/jsx-parser.d.ts.map +1 -0
  95. package/types/extractor/plugin-manager.d.ts +37 -0
  96. package/types/extractor/plugin-manager.d.ts.map +1 -0
  97. package/types/extractor.d.ts +7 -0
  98. package/types/extractor.d.ts.map +1 -0
  99. package/types/heuristic-config.d.ts +10 -0
  100. package/types/heuristic-config.d.ts.map +1 -0
  101. package/types/index.d.ts +4 -0
  102. package/types/index.d.ts.map +1 -0
  103. package/types/init.d.ts +29 -0
  104. package/types/init.d.ts.map +1 -0
  105. package/types/linter.d.ts +33 -0
  106. package/types/linter.d.ts.map +1 -0
  107. package/types/locize.d.ts +5 -0
  108. package/types/locize.d.ts.map +1 -0
  109. package/types/migrator.d.ts +37 -0
  110. package/types/migrator.d.ts.map +1 -0
  111. package/types/status.d.ts +20 -0
  112. package/types/status.d.ts.map +1 -0
  113. package/types/syncer.d.ts +33 -0
  114. package/types/syncer.d.ts.map +1 -0
  115. package/types/types-generator.d.ts +29 -0
  116. package/types/types-generator.d.ts.map +1 -0
  117. package/types/types.d.ts +268 -0
  118. package/types/types.d.ts.map +1 -0
  119. package/types/utils/file-utils.d.ts +61 -0
  120. package/types/utils/file-utils.d.ts.map +1 -0
  121. package/types/utils/logger.d.ts +34 -0
  122. package/types/utils/logger.d.ts.map +1 -0
  123. package/types/utils/nested-object.d.ts +71 -0
  124. package/types/utils/nested-object.d.ts.map +1 -0
  125. package/types/utils/validation.d.ts +47 -0
  126. package/types/utils/validation.d.ts.map +1 -0
  127. package/vitest.config.ts +13 -0
@@ -0,0 +1,125 @@
1
+ import type { PluginContext, I18nextToolkitConfig } from '../../types'
2
+
3
+ /**
4
+ * Extracts translation keys from comments in source code using regex patterns.
5
+ * Supports extraction from single-line (//) and multi-line comments.
6
+ *
7
+ * @param code - The source code to analyze
8
+ * @param functionNames - Array of function names to look for (e.g., ['t', 'i18n.t'])
9
+ * @param pluginContext - Context object with helper methods to add found keys
10
+ * @param config - Configuration object containing extraction settings
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * const code = `
15
+ * // t('user.name', 'User Name')
16
+ * /* t('app.title', { defaultValue: 'My App', ns: 'common' }) *\/
17
+ * `
18
+ *
19
+ * const context = createPluginContext(allKeys)
20
+ * extractKeysFromComments(code, ['t'], context, config)
21
+ * // Extracts: user.name and app.title with their respective settings
22
+ * ```
23
+ */
24
+ export function extractKeysFromComments (
25
+ code: string,
26
+ functionNames: string[],
27
+ pluginContext: PluginContext,
28
+ config: I18nextToolkitConfig
29
+ ): void {
30
+ const functionPattern = functionNames
31
+ .map(n => n.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
32
+ .join('|')
33
+ const keyRegex = new RegExp(`(?:${functionPattern})\\s*\\(\\s*(['"])([^'"]+)\\1`, 'g')
34
+
35
+ const commentTexts = collectCommentTexts(code)
36
+
37
+ for (const text of commentTexts) {
38
+ let match: RegExpExecArray | null
39
+ while ((match = keyRegex.exec(text)) !== null) {
40
+ let key = match[2]
41
+ let ns: string | undefined
42
+ const remainder = text.slice(match.index + match[0].length)
43
+
44
+ const defaultValue = parseDefaultValueFromComment(remainder)
45
+ // 1. Check for namespace in options object first (e.g., { ns: 'common' })
46
+ ns = parseNsFromComment(remainder)
47
+
48
+ // 2. If not in options, check for separator in key (e.g., 'common:button.save')
49
+ const nsSeparator = config.extract.nsSeparator ?? ':'
50
+ if (!ns && nsSeparator && key.includes(nsSeparator)) {
51
+ const parts = key.split(nsSeparator)
52
+ ns = parts.shift()
53
+ key = parts.join(nsSeparator)
54
+ }
55
+ if (!ns) ns = config.extract.defaultNS
56
+
57
+ pluginContext.addKey({ key, ns, defaultValue: defaultValue ?? key })
58
+ }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Parses default value from the remainder of a comment after a translation function call.
64
+ * Supports both string literals and object syntax with defaultValue property.
65
+ *
66
+ * @param remainder - The remaining text after the translation key
67
+ * @returns The parsed default value or undefined if none found
68
+ *
69
+ * @internal
70
+ */
71
+ function parseDefaultValueFromComment (remainder: string): string | undefined {
72
+ // Simple string default: , 'VALUE' or , "VALUE"
73
+ const dvString = /^\s*,\s*(['"])(.*?)\1/.exec(remainder)
74
+ if (dvString) return dvString[2]
75
+
76
+ // Object with defaultValue: , { defaultValue: 'VALUE', ... }
77
+ const dvObj = /^\s*,\s*\{[^}]*defaultValue\s*:\s*(['"])(.*?)\1/.exec(remainder)
78
+ if (dvObj) return dvObj[2]
79
+
80
+ return undefined
81
+ }
82
+
83
+ /**
84
+ * Parses namespace from the remainder of a comment after a translation function call.
85
+ * Looks for namespace specified in options object syntax.
86
+ *
87
+ * @param remainder - The remaining text after the translation key
88
+ * @returns The parsed namespace or undefined if none found
89
+ *
90
+ * @internal
91
+ */
92
+ function parseNsFromComment (remainder: string): string | undefined {
93
+ // Look for ns in an options object, e.g., { ns: 'common' }
94
+ const nsObj = /^\s*,\s*\{[^}]*ns\s*:\s*(['"])(.*?)\1/.exec(remainder)
95
+ if (nsObj) return nsObj[2]
96
+
97
+ return undefined
98
+ }
99
+
100
+ /**
101
+ * Collects all comment texts from source code, both single-line and multi-line.
102
+ * Deduplicates comments to avoid processing the same text multiple times.
103
+ *
104
+ * @param src - The source code to extract comments from
105
+ * @returns Array of unique comment text content
106
+ *
107
+ * @internal
108
+ */
109
+ function collectCommentTexts (src: string): string[] {
110
+ const texts: string[] = []
111
+ const seen = new Set<string>()
112
+
113
+ const commentRegex = /\/\/(.*)|\/\*([\s\S]*?)\*\//g
114
+ let cmatch: RegExpExecArray | null
115
+ while ((cmatch = commentRegex.exec(src)) !== null) {
116
+ const content = cmatch[1] ?? cmatch[2]
117
+ const s = content.trim()
118
+ if (s && !seen.has(s)) {
119
+ seen.add(s)
120
+ texts.push(s)
121
+ }
122
+ }
123
+
124
+ return texts
125
+ }
@@ -0,0 +1,166 @@
1
+ import type { JSXElement } from '@swc/core'
2
+ import type { ExtractedKey, I18nextToolkitConfig } from '../../types'
3
+
4
+ /**
5
+ * Extracts translation keys from JSX Trans components.
6
+ *
7
+ * This function handles various Trans component patterns:
8
+ * - Explicit i18nKey prop: `<Trans i18nKey="my.key">content</Trans>`
9
+ * - Implicit keys from children: `<Trans>Hello World</Trans>`
10
+ * - Namespace specification: `<Trans ns="common">content</Trans>`
11
+ * - Default values: `<Trans defaults="Default text">content</Trans>`
12
+ * - Pluralization: `<Trans count={count}>content</Trans>`
13
+ * - HTML preservation: `<Trans>Hello <strong>world</strong></Trans>`
14
+ *
15
+ * @param node - The JSX element node to process
16
+ * @param config - The toolkit configuration containing extraction settings
17
+ * @returns Extracted key information or null if no valid key found
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * // Input JSX:
22
+ * // <Trans i18nKey="welcome.title" ns="home" defaults="Welcome!">
23
+ * // Welcome to our <strong>amazing</strong> app!
24
+ * // </Trans>
25
+ *
26
+ * const result = extractFromTransComponent(jsxNode, config)
27
+ * // Returns: {
28
+ * // key: 'welcome.title',
29
+ * // ns: 'home',
30
+ * // defaultValue: 'Welcome!',
31
+ * // hasCount: false
32
+ * // }
33
+ * ```
34
+ */
35
+ export function extractFromTransComponent (node: JSXElement, config: I18nextToolkitConfig): ExtractedKey | null {
36
+ const i18nKeyAttr = node.opening.attributes?.find(
37
+ (attr) =>
38
+ attr.type === 'JSXAttribute' &&
39
+ attr.name.type === 'Identifier' &&
40
+ attr.name.value === 'i18nKey'
41
+ )
42
+
43
+ const defaultsAttr = node.opening.attributes?.find(
44
+ (attr) =>
45
+ attr.type === 'JSXAttribute' &&
46
+ attr.name.type === 'Identifier' &&
47
+ attr.name.value === 'defaults'
48
+ )
49
+
50
+ const countAttr = node.opening.attributes?.find(
51
+ (attr) =>
52
+ attr.type === 'JSXAttribute' &&
53
+ attr.name.type === 'Identifier' &&
54
+ attr.name.value === 'count'
55
+ )
56
+ const hasCount = !!countAttr
57
+
58
+ let key: string
59
+ if (i18nKeyAttr?.type === 'JSXAttribute' && i18nKeyAttr.value?.type === 'StringLiteral') {
60
+ key = i18nKeyAttr.value.value
61
+ } else {
62
+ key = serializeJSXChildren(node.children, config)
63
+ }
64
+
65
+ if (!key) {
66
+ return null
67
+ }
68
+
69
+ const nsAttr = node.opening.attributes?.find(
70
+ (attr) =>
71
+ attr.type === 'JSXAttribute' && attr.name.type === 'Identifier' && attr.name.value === 'ns'
72
+ )
73
+ const ns = nsAttr?.type === 'JSXAttribute' && nsAttr.value?.type === 'StringLiteral'
74
+ ? nsAttr.value.value
75
+ : undefined
76
+
77
+ let defaultValue = config.extract.defaultValue || ''
78
+ if (defaultsAttr?.type === 'JSXAttribute' && defaultsAttr.value?.type === 'StringLiteral') {
79
+ defaultValue = defaultsAttr.value.value
80
+ } else {
81
+ defaultValue = serializeJSXChildren(node.children, config)
82
+ }
83
+
84
+ return { key, ns, defaultValue: defaultValue || key, hasCount }
85
+ }
86
+
87
+ /**
88
+ * Serializes JSX children into a string representation suitable for i18next.
89
+ *
90
+ * This function converts JSX children into the format expected by i18next:
91
+ * - Text nodes are preserved as-is
92
+ * - HTML elements are converted to indexed placeholders or preserved if allowed
93
+ * - JSX expressions become interpolation placeholders: `{{variable}}`
94
+ * - Fragments are flattened
95
+ * - Whitespace is normalized
96
+ *
97
+ * The serialization respects the `transKeepBasicHtmlNodesFor` configuration
98
+ * to determine which HTML tags should be preserved vs. converted to indexed placeholders.
99
+ *
100
+ * @param children - Array of JSX child nodes to serialize
101
+ * @param config - Configuration containing HTML preservation settings
102
+ * @returns Serialized string representation
103
+ *
104
+ * @example
105
+ * ```typescript
106
+ * // JSX: Hello <strong>{{name}}</strong>, you have <Link to="/msgs">{{count}} messages</Link>.
107
+ * // With transKeepBasicHtmlNodesFor: ['strong']
108
+ * // Returns: "Hello <strong>{{name}}</strong>, you have <1>{{count}} messages</1>."
109
+ * // (strong preserved, Link becomes indexed placeholder <1>)
110
+ *
111
+ * const serialized = serializeJSXChildren(children, config)
112
+ * ```
113
+ *
114
+ * @internal
115
+ */
116
+ function serializeJSXChildren (children: any[], config: I18nextToolkitConfig): string {
117
+ const allowedTags = new Set(config.extract.transKeepBasicHtmlNodesFor ?? ['br', 'strong', 'i', 'p'])
118
+
119
+ /**
120
+ * Recursively processes JSX children and converts them to string format.
121
+ *
122
+ * @param children - Array of child nodes to process
123
+ * @returns Serialized string content
124
+ */
125
+ function serializeChildren (children: any[]): string {
126
+ let out = ''
127
+ // Use forEach to get the direct index of each child in the array
128
+ children.forEach((child, index) => {
129
+ if (child.type === 'JSXText') {
130
+ out += child.value
131
+ } else if (child.type === 'JSXExpressionContainer') {
132
+ const expr = child.expression
133
+ if (expr.type === 'StringLiteral') {
134
+ out += expr.value
135
+ } else if (expr.type === 'Identifier') {
136
+ out += `{{${expr.value}}}`
137
+ } else if (expr.type === 'ObjectExpression') {
138
+ const prop = expr.properties[0]
139
+ if (prop && prop.type === 'Identifier') {
140
+ out += `{{${prop.value}}}`
141
+ }
142
+ }
143
+ } else if (child.type === 'JSXElement') {
144
+ let tag
145
+ if (child.opening.name.type === 'Identifier') {
146
+ tag = child.opening.name.value
147
+ }
148
+
149
+ const innerContent = serializeChildren(child.children)
150
+
151
+ if (tag && allowedTags.has(tag)) {
152
+ // If the tag is in the allowed list, preserve it
153
+ out += `<${tag}>${innerContent}</${tag}>`
154
+ } else {
155
+ // Otherwise, replace it with ITS INDEX IN THE CHILDREN ARRAY
156
+ out += `<${index}>${innerContent}</${index}>`
157
+ }
158
+ } else if (child.type === 'JSXFragment') {
159
+ out += serializeChildren(child.children)
160
+ }
161
+ })
162
+ return out
163
+ }
164
+
165
+ return serializeChildren(children).trim().replace(/\s{2,}/g, ' ')
166
+ }
@@ -0,0 +1,54 @@
1
+ import type { ExtractedKey, PluginContext } from '../types'
2
+
3
+ /**
4
+ * Initializes an array of plugins by calling their setup hooks.
5
+ * This function should be called before starting the extraction process.
6
+ *
7
+ * @param plugins - Array of plugin objects to initialize
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * const plugins = [customPlugin(), anotherPlugin()]
12
+ * await initializePlugins(plugins)
13
+ * // All plugin setup hooks have been called
14
+ * ```
15
+ */
16
+ export async function initializePlugins (plugins: any[]): Promise<void> {
17
+ for (const plugin of plugins) {
18
+ await plugin.setup?.()
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Creates a plugin context object that provides helper methods for plugins.
24
+ * The context allows plugins to add extracted keys to the main collection.
25
+ *
26
+ * @param allKeys - The main map where extracted keys are stored
27
+ * @returns A context object with helper methods for plugins
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * const allKeys = new Map()
32
+ * const context = createPluginContext(allKeys)
33
+ *
34
+ * // Plugin can now add keys
35
+ * context.addKey({
36
+ * key: 'my.custom.key',
37
+ * defaultValue: 'Default Value',
38
+ * ns: 'common'
39
+ * })
40
+ * ```
41
+ */
42
+ export function createPluginContext (allKeys: Map<string, ExtractedKey>): PluginContext {
43
+ return {
44
+ addKey: (keyInfo: ExtractedKey) => {
45
+ // Use namespace in the unique map key to avoid collisions across namespaces
46
+ const uniqueKey = `${keyInfo.ns ?? 'translation'}:${keyInfo.key}`
47
+
48
+ if (!allKeys.has(uniqueKey)) {
49
+ const defaultValue = keyInfo.defaultValue ?? keyInfo.key
50
+ allKeys.set(uniqueKey, { ...keyInfo, defaultValue })
51
+ }
52
+ },
53
+ }
54
+ }
@@ -0,0 +1,15 @@
1
+ // src/index.ts
2
+ import { runExtractor, extract } from './extractor/core/extractor'
3
+ import { findKeys } from './extractor/core/key-finder'
4
+ import { getTranslations } from './extractor/core/translation-manager'
5
+ import { ASTVisitors } from './extractor/parsers/ast-visitors'
6
+ import type { PluginContext } from './types'
7
+
8
+ export {
9
+ runExtractor,
10
+ extract,
11
+ findKeys,
12
+ getTranslations,
13
+ ASTVisitors,
14
+ PluginContext,
15
+ }
@@ -0,0 +1,64 @@
1
+ import { glob } from 'glob'
2
+ import { readdir } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+ import type { I18nextToolkitConfig } from './types'
5
+
6
+ // A list of common glob patterns for the primary language ('en') or ('dev') translation files.
7
+ const HEURISTIC_PATTERNS = [
8
+ 'public/locales/dev/*.json',
9
+ 'locales/dev/*.json',
10
+ 'src/locales/dev/*.json',
11
+ 'src/assets/locales/dev/*.json',
12
+ 'public/locales/en/*.json',
13
+ 'locales/en/*.json',
14
+ 'src/locales/en/*.json',
15
+ 'src/assets/locales/en/*.json',
16
+ ]
17
+
18
+ /**
19
+ * Attempts to automatically detect the project's i18n structure by searching for
20
+ * common translation file locations.
21
+ *
22
+ * @returns A promise that resolves to a partial I18nextToolkitConfig if detection
23
+ * is successful, otherwise null.
24
+ */
25
+ export async function detectConfig (): Promise<Partial<I18nextToolkitConfig> | null> {
26
+ for (const pattern of HEURISTIC_PATTERNS) {
27
+ const files = await glob(pattern, { ignore: 'node_modules/**' })
28
+
29
+ if (files.length > 0) {
30
+ const firstFile = files[0]
31
+ const basePath = dirname(dirname(firstFile))
32
+
33
+ try {
34
+ const allDirs = await readdir(basePath)
35
+ // CORRECTED REGEX: Now accepts 'dev' in addition to standard locale codes.
36
+ let locales = allDirs.filter(dir => /^(dev|[a-z]{2}(-[A-Z]{2})?)$/.test(dir))
37
+
38
+ if (locales.length > 0) {
39
+ // Prioritization Logic
40
+ locales.sort()
41
+ if (locales.includes('dev')) {
42
+ locales = ['dev', ...locales.filter(l => l !== 'dev')]
43
+ }
44
+ if (locales.includes('en')) {
45
+ locales = ['en', ...locales.filter(l => l !== 'en')]
46
+ }
47
+
48
+ return {
49
+ locales,
50
+ extract: {
51
+ input: ['src/**/*.{js,jsx,ts,tsx}'],
52
+ output: join(basePath, '{{language}}', '{{namespace}}.json'),
53
+ primaryLanguage: locales.includes('en') ? 'en' : locales[0],
54
+ },
55
+ }
56
+ }
57
+ } catch {
58
+ continue
59
+ }
60
+ }
61
+ }
62
+
63
+ return null
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export type {
2
+ I18nextToolkitConfig,
3
+ Plugin,
4
+ PluginContext,
5
+ ExtractedKey
6
+ } from './types'
7
+ export { defineConfig } from './config'
8
+ export {
9
+ extract,
10
+ findKeys,
11
+ getTranslations
12
+ } from './extractor'
package/src/init.ts ADDED
@@ -0,0 +1,156 @@
1
+ import inquirer from 'inquirer'
2
+ import { writeFile, readFile } from 'node:fs/promises'
3
+ import { resolve } from 'node:path'
4
+
5
+ /**
6
+ * Determines if the current project is configured as an ESM project.
7
+ * Checks the package.json file for `"type": "module"`.
8
+ *
9
+ * @returns Promise resolving to true if ESM, false if CommonJS
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * const isESM = await isEsmProject()
14
+ * if (isESM) {
15
+ * // Generate ESM syntax
16
+ * } else {
17
+ * // Generate CommonJS syntax
18
+ * }
19
+ * ```
20
+ */
21
+ async function isEsmProject (): Promise<boolean> {
22
+ try {
23
+ const packageJsonPath = resolve(process.cwd(), 'package.json')
24
+ const content = await readFile(packageJsonPath, 'utf-8')
25
+ const packageJson = JSON.parse(content)
26
+ return packageJson.type === 'module'
27
+ } catch {
28
+ return true // Default to ESM if package.json is not found or readable
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Interactive setup wizard for creating a new i18next-cli configuration file.
34
+ *
35
+ * This function provides a guided setup experience that:
36
+ * 1. Asks the user for their preferred configuration file type (TypeScript or JavaScript)
37
+ * 2. Collects basic project settings (locales, input patterns, output paths)
38
+ * 3. Detects the project module system (ESM vs CommonJS) for JavaScript files
39
+ * 4. Generates an appropriate configuration file with proper syntax
40
+ * 5. Provides helpful defaults for common use cases
41
+ *
42
+ * The generated configuration includes:
43
+ * - Locale specification
44
+ * - Input file patterns for source scanning
45
+ * - Output path templates with placeholders
46
+ * - Proper imports and exports for the detected module system
47
+ * - JSDoc type annotations for JavaScript files
48
+ *
49
+ * @example
50
+ * ```typescript
51
+ * // Run the interactive setup
52
+ * await runInit()
53
+ *
54
+ * // This will create either:
55
+ * // - i18next.config.ts (TypeScript)
56
+ * // - i18next.config.js (JavaScript ESM/CommonJS)
57
+ * ```
58
+ */
59
+ export async function runInit () {
60
+ console.log('Welcome to the i18next-cli setup wizard!')
61
+
62
+ const answers = await inquirer.prompt([
63
+ {
64
+ type: 'list',
65
+ name: 'fileType',
66
+ message: 'What kind of configuration file do you want?',
67
+ choices: ['TypeScript (i18next.config.ts)', 'JavaScript (i18next.config.js)'],
68
+ },
69
+ {
70
+ type: 'input',
71
+ name: 'locales',
72
+ message: 'What locales does your project support? (comma-separated)',
73
+ default: 'en,de,fr',
74
+ filter: (input: string) => input.split(',').map(s => s.trim()),
75
+ },
76
+ {
77
+ type: 'input',
78
+ name: 'input',
79
+ message: 'What is the glob pattern for your source files?',
80
+ default: 'src/**/*.{js,jsx,ts,tsx}',
81
+ },
82
+ {
83
+ type: 'input',
84
+ name: 'output',
85
+ message: 'What is the path for your output resource files?',
86
+ default: 'public/locales/{{language}}/{{namespace}}.json',
87
+ },
88
+ ])
89
+
90
+ const isTypeScript = answers.fileType.includes('TypeScript')
91
+ const isEsm = await isEsmProject()
92
+ const fileName = isTypeScript ? 'i18next.config.ts' : 'i18next.config.js'
93
+
94
+ const configObject = {
95
+ locales: answers.locales,
96
+ extract: {
97
+ input: answers.input,
98
+ output: answers.output,
99
+ },
100
+ }
101
+
102
+ // Helper to serialize a JS value as a JS literal:
103
+ function toJs (value: any, indent = 2, level = 0): string {
104
+ const pad = (n: number) => ' '.repeat(n * indent)
105
+ const currentPad = pad(level)
106
+ const nextPad = pad(level + 1)
107
+
108
+ if (value === null || typeof value === 'number' || typeof value === 'boolean') {
109
+ return JSON.stringify(value)
110
+ }
111
+ if (typeof value === 'string') {
112
+ return JSON.stringify(value) // keeps double quotes and proper escaping
113
+ }
114
+ if (Array.isArray(value)) {
115
+ if (value.length === 0) return '[]'
116
+ const items = value.map(v => `${nextPad}${toJs(v, indent, level + 1)}`).join(',\n')
117
+ return `[\n${items}\n${currentPad}]`
118
+ }
119
+ if (typeof value === 'object') {
120
+ const keys = Object.keys(value)
121
+ if (keys.length === 0) return '{}'
122
+ const entries = keys.map(key => {
123
+ // Use unquoted key if it's a valid identifier otherwise JSON.stringify(key)
124
+ const validId = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
125
+ const printedKey = validId ? key : JSON.stringify(key)
126
+ return `${nextPad}${printedKey}: ${toJs(value[key], indent, level + 1)}`
127
+ }).join(',\n')
128
+ return `{\n${entries}\n${currentPad}}`
129
+ }
130
+
131
+ // Fallback
132
+ return JSON.stringify(value)
133
+ }
134
+
135
+ let fileContent = ''
136
+ if (isTypeScript) {
137
+ fileContent = `import { defineConfig } from 'i18next-cli';
138
+
139
+ export default defineConfig(${toJs(configObject)});`
140
+ } else if (isEsm) {
141
+ fileContent = `import { defineConfig } from 'i18next-cli';
142
+
143
+ /** @type {import('i18next-cli').I18nextToolkitConfig} */
144
+ export default defineConfig(${toJs(configObject)});`
145
+ } else { // CJS
146
+ fileContent = `const { defineConfig } = require('i18next-cli');
147
+
148
+ /** @type {import('i18next-cli').I18nextToolkitConfig} */
149
+ module.exports = defineConfig(${toJs(configObject)});`
150
+ }
151
+
152
+ const outputPath = resolve(process.cwd(), fileName)
153
+ await writeFile(outputPath, fileContent.trim())
154
+
155
+ console.log(`✅ Configuration file created at: ${outputPath}`)
156
+ }