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.
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +489 -0
- package/dist/cjs/cli.js +2 -0
- package/dist/cjs/config.js +1 -0
- package/dist/cjs/extractor/core/extractor.js +1 -0
- package/dist/cjs/extractor/core/key-finder.js +1 -0
- package/dist/cjs/extractor/core/translation-manager.js +1 -0
- package/dist/cjs/extractor/parsers/ast-visitors.js +1 -0
- package/dist/cjs/extractor/parsers/comment-parser.js +1 -0
- package/dist/cjs/extractor/parsers/jsx-parser.js +1 -0
- package/dist/cjs/extractor/plugin-manager.js +1 -0
- package/dist/cjs/heuristic-config.js +1 -0
- package/dist/cjs/index.js +1 -0
- package/dist/cjs/init.js +1 -0
- package/dist/cjs/linter.js +1 -0
- package/dist/cjs/locize.js +1 -0
- package/dist/cjs/migrator.js +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/status.js +1 -0
- package/dist/cjs/syncer.js +1 -0
- package/dist/cjs/types-generator.js +1 -0
- package/dist/cjs/utils/file-utils.js +1 -0
- package/dist/cjs/utils/logger.js +1 -0
- package/dist/cjs/utils/nested-object.js +1 -0
- package/dist/cjs/utils/validation.js +1 -0
- package/dist/esm/cli.js +2 -0
- package/dist/esm/config.js +1 -0
- package/dist/esm/extractor/core/extractor.js +1 -0
- package/dist/esm/extractor/core/key-finder.js +1 -0
- package/dist/esm/extractor/core/translation-manager.js +1 -0
- package/dist/esm/extractor/parsers/ast-visitors.js +1 -0
- package/dist/esm/extractor/parsers/comment-parser.js +1 -0
- package/dist/esm/extractor/parsers/jsx-parser.js +1 -0
- package/dist/esm/extractor/plugin-manager.js +1 -0
- package/dist/esm/heuristic-config.js +1 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/init.js +1 -0
- package/dist/esm/linter.js +1 -0
- package/dist/esm/locize.js +1 -0
- package/dist/esm/migrator.js +1 -0
- package/dist/esm/status.js +1 -0
- package/dist/esm/syncer.js +1 -0
- package/dist/esm/types-generator.js +1 -0
- package/dist/esm/utils/file-utils.js +1 -0
- package/dist/esm/utils/logger.js +1 -0
- package/dist/esm/utils/nested-object.js +1 -0
- package/dist/esm/utils/validation.js +1 -0
- package/package.json +81 -0
- package/src/cli.ts +166 -0
- package/src/config.ts +158 -0
- package/src/extractor/core/extractor.ts +195 -0
- package/src/extractor/core/key-finder.ts +70 -0
- package/src/extractor/core/translation-manager.ts +115 -0
- package/src/extractor/index.ts +7 -0
- package/src/extractor/parsers/ast-visitors.ts +637 -0
- package/src/extractor/parsers/comment-parser.ts +125 -0
- package/src/extractor/parsers/jsx-parser.ts +166 -0
- package/src/extractor/plugin-manager.ts +54 -0
- package/src/extractor.ts +15 -0
- package/src/heuristic-config.ts +64 -0
- package/src/index.ts +12 -0
- package/src/init.ts +156 -0
- package/src/linter.ts +191 -0
- package/src/locize.ts +251 -0
- package/src/migrator.ts +139 -0
- package/src/status.ts +192 -0
- package/src/syncer.ts +114 -0
- package/src/types-generator.ts +116 -0
- package/src/types.ts +312 -0
- package/src/utils/file-utils.ts +81 -0
- package/src/utils/logger.ts +36 -0
- package/src/utils/nested-object.ts +113 -0
- package/src/utils/validation.ts +69 -0
- package/tryme.js +8 -0
- package/tsconfig.json +71 -0
- package/types/cli.d.ts +3 -0
- package/types/cli.d.ts.map +1 -0
- package/types/config.d.ts +50 -0
- package/types/config.d.ts.map +1 -0
- package/types/extractor/core/extractor.d.ts +66 -0
- package/types/extractor/core/extractor.d.ts.map +1 -0
- package/types/extractor/core/key-finder.d.ts +31 -0
- package/types/extractor/core/key-finder.d.ts.map +1 -0
- package/types/extractor/core/translation-manager.d.ts +31 -0
- package/types/extractor/core/translation-manager.d.ts.map +1 -0
- package/types/extractor/index.d.ts +8 -0
- package/types/extractor/index.d.ts.map +1 -0
- package/types/extractor/parsers/ast-visitors.d.ts +235 -0
- package/types/extractor/parsers/ast-visitors.d.ts.map +1 -0
- package/types/extractor/parsers/comment-parser.d.ts +24 -0
- package/types/extractor/parsers/comment-parser.d.ts.map +1 -0
- package/types/extractor/parsers/jsx-parser.d.ts +35 -0
- package/types/extractor/parsers/jsx-parser.d.ts.map +1 -0
- package/types/extractor/plugin-manager.d.ts +37 -0
- package/types/extractor/plugin-manager.d.ts.map +1 -0
- package/types/extractor.d.ts +7 -0
- package/types/extractor.d.ts.map +1 -0
- package/types/heuristic-config.d.ts +10 -0
- package/types/heuristic-config.d.ts.map +1 -0
- package/types/index.d.ts +4 -0
- package/types/index.d.ts.map +1 -0
- package/types/init.d.ts +29 -0
- package/types/init.d.ts.map +1 -0
- package/types/linter.d.ts +33 -0
- package/types/linter.d.ts.map +1 -0
- package/types/locize.d.ts +5 -0
- package/types/locize.d.ts.map +1 -0
- package/types/migrator.d.ts +37 -0
- package/types/migrator.d.ts.map +1 -0
- package/types/status.d.ts +20 -0
- package/types/status.d.ts.map +1 -0
- package/types/syncer.d.ts +33 -0
- package/types/syncer.d.ts.map +1 -0
- package/types/types-generator.d.ts +29 -0
- package/types/types-generator.d.ts.map +1 -0
- package/types/types.d.ts +268 -0
- package/types/types.d.ts.map +1 -0
- package/types/utils/file-utils.d.ts +61 -0
- package/types/utils/file-utils.d.ts.map +1 -0
- package/types/utils/logger.d.ts +34 -0
- package/types/utils/logger.d.ts.map +1 -0
- package/types/utils/nested-object.d.ts +71 -0
- package/types/utils/nested-object.d.ts.map +1 -0
- package/types/utils/validation.d.ts +47 -0
- package/types/utils/validation.d.ts.map +1 -0
- 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
|
+
}
|
package/src/extractor.ts
ADDED
|
@@ -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
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
|
+
}
|