i18nsmith 0.1.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/dist/commands/audit.d.ts +3 -0
- package/dist/commands/audit.d.ts.map +1 -0
- package/dist/commands/audit.js +180 -0
- package/dist/commands/audit.js.map +1 -0
- package/dist/commands/backup.d.ts +6 -0
- package/dist/commands/backup.d.ts.map +1 -0
- package/dist/commands/backup.js +85 -0
- package/dist/commands/backup.js.map +1 -0
- package/dist/commands/check.d.ts +3 -0
- package/dist/commands/check.d.ts.map +1 -0
- package/dist/commands/check.js +151 -0
- package/dist/commands/check.js.map +1 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +235 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/debug-patterns.d.ts +3 -0
- package/dist/commands/debug-patterns.d.ts.map +1 -0
- package/dist/commands/debug-patterns.js +192 -0
- package/dist/commands/debug-patterns.js.map +1 -0
- package/dist/commands/debug-patterns.test.d.ts +2 -0
- package/dist/commands/debug-patterns.test.d.ts.map +1 -0
- package/dist/commands/debug-patterns.test.js +109 -0
- package/dist/commands/debug-patterns.test.js.map +1 -0
- package/dist/commands/diagnose.d.ts +3 -0
- package/dist/commands/diagnose.d.ts.map +1 -0
- package/dist/commands/diagnose.js +117 -0
- package/dist/commands/diagnose.js.map +1 -0
- package/dist/commands/init.d.ts +8 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +450 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/init.test.d.ts +2 -0
- package/dist/commands/init.test.d.ts.map +1 -0
- package/dist/commands/init.test.js +74 -0
- package/dist/commands/init.test.js.map +1 -0
- package/dist/commands/install-hooks.d.ts +3 -0
- package/dist/commands/install-hooks.d.ts.map +1 -0
- package/dist/commands/install-hooks.js +52 -0
- package/dist/commands/install-hooks.js.map +1 -0
- package/dist/commands/preflight.d.ts +7 -0
- package/dist/commands/preflight.d.ts.map +1 -0
- package/dist/commands/preflight.js +417 -0
- package/dist/commands/preflight.js.map +1 -0
- package/dist/commands/preflight.test.d.ts +5 -0
- package/dist/commands/preflight.test.d.ts.map +1 -0
- package/dist/commands/preflight.test.js +108 -0
- package/dist/commands/preflight.test.js.map +1 -0
- package/dist/commands/rename.d.ts +6 -0
- package/dist/commands/rename.d.ts.map +1 -0
- package/dist/commands/rename.js +204 -0
- package/dist/commands/rename.js.map +1 -0
- package/dist/commands/scaffold-adapter.d.ts +3 -0
- package/dist/commands/scaffold-adapter.d.ts.map +1 -0
- package/dist/commands/scaffold-adapter.js +204 -0
- package/dist/commands/scaffold-adapter.js.map +1 -0
- package/dist/commands/scaffold-adapter.test.d.ts +2 -0
- package/dist/commands/scaffold-adapter.test.d.ts.map +1 -0
- package/dist/commands/scaffold-adapter.test.js +102 -0
- package/dist/commands/scaffold-adapter.test.js.map +1 -0
- package/dist/commands/scan.d.ts +3 -0
- package/dist/commands/scan.d.ts.map +1 -0
- package/dist/commands/scan.js +93 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/commands/sync-seed.test.d.ts +2 -0
- package/dist/commands/sync-seed.test.d.ts.map +1 -0
- package/dist/commands/sync-seed.test.js +86 -0
- package/dist/commands/sync-seed.test.js.map +1 -0
- package/dist/commands/sync.d.ts +3 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +590 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/transform.d.ts +3 -0
- package/dist/commands/transform.d.ts.map +1 -0
- package/dist/commands/transform.js +114 -0
- package/dist/commands/transform.js.map +1 -0
- package/dist/commands/translate/csv-handler.d.ts +21 -0
- package/dist/commands/translate/csv-handler.d.ts.map +1 -0
- package/dist/commands/translate/csv-handler.js +270 -0
- package/dist/commands/translate/csv-handler.js.map +1 -0
- package/dist/commands/translate/executor.d.ts +31 -0
- package/dist/commands/translate/executor.d.ts.map +1 -0
- package/dist/commands/translate/executor.js +117 -0
- package/dist/commands/translate/executor.js.map +1 -0
- package/dist/commands/translate/index.d.ts +10 -0
- package/dist/commands/translate/index.d.ts.map +1 -0
- package/dist/commands/translate/index.js +170 -0
- package/dist/commands/translate/index.js.map +1 -0
- package/dist/commands/translate/reporter.d.ts +29 -0
- package/dist/commands/translate/reporter.d.ts.map +1 -0
- package/dist/commands/translate/reporter.js +103 -0
- package/dist/commands/translate/reporter.js.map +1 -0
- package/dist/commands/translate/types.d.ts +50 -0
- package/dist/commands/translate/types.d.ts.map +1 -0
- package/dist/commands/translate/types.js +5 -0
- package/dist/commands/translate/types.js.map +1 -0
- package/dist/commands/translate.d.ts +7 -0
- package/dist/commands/translate.d.ts.map +1 -0
- package/dist/commands/translate.js +7 -0
- package/dist/commands/translate.js.map +1 -0
- package/dist/commands/translate.test.d.ts +2 -0
- package/dist/commands/translate.test.d.ts.map +1 -0
- package/dist/commands/translate.test.js +118 -0
- package/dist/commands/translate.test.js.map +1 -0
- package/dist/e2e.test.d.ts +6 -0
- package/dist/e2e.test.d.ts.map +1 -0
- package/dist/e2e.test.js +376 -0
- package/dist/e2e.test.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/integration.test.d.ts +6 -0
- package/dist/integration.test.d.ts.map +1 -0
- package/dist/integration.test.js +320 -0
- package/dist/integration.test.js.map +1 -0
- package/dist/utils/diagnostics-exit.d.ts +12 -0
- package/dist/utils/diagnostics-exit.d.ts.map +1 -0
- package/dist/utils/diagnostics-exit.js +49 -0
- package/dist/utils/diagnostics-exit.js.map +1 -0
- package/dist/utils/diagnostics-exit.test.d.ts +2 -0
- package/dist/utils/diagnostics-exit.test.d.ts.map +1 -0
- package/dist/utils/diagnostics-exit.test.js +40 -0
- package/dist/utils/diagnostics-exit.test.js.map +1 -0
- package/dist/utils/diff-utils.d.ts +4 -0
- package/dist/utils/diff-utils.d.ts.map +1 -0
- package/dist/utils/diff-utils.js +30 -0
- package/dist/utils/diff-utils.js.map +1 -0
- package/dist/utils/diff-utils.test.d.ts +2 -0
- package/dist/utils/diff-utils.test.d.ts.map +1 -0
- package/dist/utils/diff-utils.test.js +30 -0
- package/dist/utils/diff-utils.test.js.map +1 -0
- package/dist/utils/exit-codes.d.ts +142 -0
- package/dist/utils/exit-codes.d.ts.map +1 -0
- package/dist/utils/exit-codes.js +168 -0
- package/dist/utils/exit-codes.js.map +1 -0
- package/dist/utils/package-manager.d.ts +4 -0
- package/dist/utils/package-manager.d.ts.map +1 -0
- package/dist/utils/package-manager.js +40 -0
- package/dist/utils/package-manager.js.map +1 -0
- package/dist/utils/pkg.d.ts +3 -0
- package/dist/utils/pkg.d.ts.map +1 -0
- package/dist/utils/pkg.js +24 -0
- package/dist/utils/pkg.js.map +1 -0
- package/dist/utils/provider-injector.d.ts +36 -0
- package/dist/utils/provider-injector.d.ts.map +1 -0
- package/dist/utils/provider-injector.js +223 -0
- package/dist/utils/provider-injector.js.map +1 -0
- package/dist/utils/provider-injector.test.d.ts +2 -0
- package/dist/utils/provider-injector.test.d.ts.map +1 -0
- package/dist/utils/provider-injector.test.js +67 -0
- package/dist/utils/provider-injector.test.js.map +1 -0
- package/dist/utils/scaffold.d.ts +20 -0
- package/dist/utils/scaffold.d.ts.map +1 -0
- package/dist/utils/scaffold.js +197 -0
- package/dist/utils/scaffold.js.map +1 -0
- package/package.json +35 -0
- package/src/commands/audit.ts +234 -0
- package/src/commands/backup.ts +96 -0
- package/src/commands/check.ts +191 -0
- package/src/commands/config.ts +263 -0
- package/src/commands/debug-patterns.test.ts +134 -0
- package/src/commands/debug-patterns.ts +257 -0
- package/src/commands/diagnose.ts +136 -0
- package/src/commands/init.test.ts +82 -0
- package/src/commands/init.ts +536 -0
- package/src/commands/install-hooks.ts +66 -0
- package/src/commands/preflight.test.ts +139 -0
- package/src/commands/preflight.ts +488 -0
- package/src/commands/rename.ts +264 -0
- package/src/commands/scaffold-adapter.test.ts +110 -0
- package/src/commands/scaffold-adapter.ts +250 -0
- package/src/commands/scan.ts +125 -0
- package/src/commands/sync-seed.test.ts +116 -0
- package/src/commands/sync.ts +736 -0
- package/src/commands/transform.ts +151 -0
- package/src/commands/translate/README.md +75 -0
- package/src/commands/translate/csv-handler.ts +301 -0
- package/src/commands/translate/executor.ts +188 -0
- package/src/commands/translate/index.ts +220 -0
- package/src/commands/translate/reporter.ts +138 -0
- package/src/commands/translate/types.ts +56 -0
- package/src/commands/translate.test.ts +173 -0
- package/src/commands/translate.ts +6 -0
- package/src/e2e.test.ts +479 -0
- package/src/fixtures/README.md +61 -0
- package/src/fixtures/basic-react/i18n.config.json +15 -0
- package/src/fixtures/basic-react/locales/de.json +8 -0
- package/src/fixtures/basic-react/locales/en.json +8 -0
- package/src/fixtures/basic-react/locales/fr.json +8 -0
- package/src/fixtures/basic-react/src/App.tsx +15 -0
- package/src/fixtures/basic-react/src/Messages.tsx +12 -0
- package/src/fixtures/nested-locales/i18n.config.json +9 -0
- package/src/fixtures/nested-locales/locales/en.json +23 -0
- package/src/fixtures/nested-locales/locales/fr.json +23 -0
- package/src/fixtures/nested-locales/src/HomePage.tsx +13 -0
- package/src/fixtures/suspicious-keys/i18n.config.json +9 -0
- package/src/fixtures/suspicious-keys/locales/en.json +11 -0
- package/src/fixtures/suspicious-keys/locales/fr.json +11 -0
- package/src/fixtures/suspicious-keys/src/BadKeys.tsx +19 -0
- package/src/index.ts +43 -0
- package/src/integration.test.ts +438 -0
- package/src/utils/diagnostics-exit.test.ts +47 -0
- package/src/utils/diagnostics-exit.ts +63 -0
- package/src/utils/diff-utils.test.ts +36 -0
- package/src/utils/diff-utils.ts +42 -0
- package/src/utils/exit-codes.ts +201 -0
- package/src/utils/package-manager.ts +44 -0
- package/src/utils/pkg.ts +23 -0
- package/src/utils/provider-injector.test.ts +79 -0
- package/src/utils/provider-injector.ts +315 -0
- package/src/utils/scaffold.ts +240 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit Code Reference for i18nsmith CLI
|
|
3
|
+
*
|
|
4
|
+
* This module centralizes all exit codes used by the CLI for consistent
|
|
5
|
+
* behavior across commands and improved CI/CD integration.
|
|
6
|
+
*
|
|
7
|
+
* ## Exit Code Ranges
|
|
8
|
+
*
|
|
9
|
+
* | Range | Category | Description |
|
|
10
|
+
* |--------|-----------------------|---------------------------------------|
|
|
11
|
+
* | 0 | Success | Command completed successfully |
|
|
12
|
+
* | 1 | General Error | Unspecified error or crash |
|
|
13
|
+
* | 2-5 | Diagnostics | Workspace diagnostic conflicts |
|
|
14
|
+
* | 10-19 | Check Command | Health check warnings/conflicts |
|
|
15
|
+
* | 1-4 | Sync Command | Sync drift/validation issues |
|
|
16
|
+
*
|
|
17
|
+
* Note: Sync exit codes overlap with diagnostics for backward compatibility.
|
|
18
|
+
* Use `--prefer-diagnostics-exit` in `check` command to disambiguate.
|
|
19
|
+
*
|
|
20
|
+
* ## Usage in CI/CD
|
|
21
|
+
*
|
|
22
|
+
* ```bash
|
|
23
|
+
* # Basic CI check
|
|
24
|
+
* npx i18nsmith check --fail-on conflicts
|
|
25
|
+
* if [ $? -eq 11 ]; then
|
|
26
|
+
* echo "Blocking conflicts found"
|
|
27
|
+
* fi
|
|
28
|
+
*
|
|
29
|
+
* # Strict sync check
|
|
30
|
+
* npx i18nsmith sync --check --strict
|
|
31
|
+
* case $? in
|
|
32
|
+
* 0) echo "All clear" ;;
|
|
33
|
+
* 1) echo "Locale drift detected" ;;
|
|
34
|
+
* 2) echo "Placeholder mismatch" ;;
|
|
35
|
+
* 4) echo "Suspicious keys found" ;;
|
|
36
|
+
* esac
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Exit codes for the `sync` command.
|
|
42
|
+
*
|
|
43
|
+
* Used when running `i18nsmith sync --check` or `--strict` mode.
|
|
44
|
+
*/
|
|
45
|
+
export const SYNC_EXIT_CODES = {
|
|
46
|
+
/** Locale drift detected (missing or unused keys) */
|
|
47
|
+
DRIFT: 1,
|
|
48
|
+
/** Placeholder/interpolation mismatch between locales */
|
|
49
|
+
PLACEHOLDER_MISMATCH: 2,
|
|
50
|
+
/** Empty or placeholder values in locale files */
|
|
51
|
+
EMPTY_VALUES: 3,
|
|
52
|
+
/** Suspicious key patterns detected (e.g., key=value, raw text keys) */
|
|
53
|
+
SUSPICIOUS_KEYS: 4,
|
|
54
|
+
} as const;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Exit codes for the `check` command.
|
|
58
|
+
*
|
|
59
|
+
* Used when running `i18nsmith check --fail-on <level>`.
|
|
60
|
+
*/
|
|
61
|
+
export const CHECK_EXIT_CODES = {
|
|
62
|
+
/** Warnings detected (when --fail-on warnings) */
|
|
63
|
+
WARNINGS: 10,
|
|
64
|
+
/** Blocking conflicts detected (when --fail-on conflicts) */
|
|
65
|
+
CONFLICTS: 11,
|
|
66
|
+
} as const;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Exit codes for the `diagnose` command and diagnostic conflicts.
|
|
70
|
+
*
|
|
71
|
+
* These are also used by `check` when `--prefer-diagnostics-exit` is set.
|
|
72
|
+
*/
|
|
73
|
+
export const DIAGNOSTICS_EXIT_CODES = {
|
|
74
|
+
/** Missing source locale file */
|
|
75
|
+
MISSING_SOURCE_LOCALE: 2,
|
|
76
|
+
/** Invalid JSON in locale file */
|
|
77
|
+
INVALID_LOCALE_JSON: 3,
|
|
78
|
+
/** Provider/adapter clash detected */
|
|
79
|
+
UNSAFE_PROVIDER_CLASH: 4,
|
|
80
|
+
/** General diagnostics conflict (fallback) */
|
|
81
|
+
GENERAL_CONFLICT: 5,
|
|
82
|
+
} as const;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* General exit codes used across all commands.
|
|
86
|
+
*/
|
|
87
|
+
export const GENERAL_EXIT_CODES = {
|
|
88
|
+
/** Success - no issues found */
|
|
89
|
+
SUCCESS: 0,
|
|
90
|
+
/** General error (catch-all for exceptions) */
|
|
91
|
+
ERROR: 1,
|
|
92
|
+
} as const;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Type for all sync exit codes
|
|
96
|
+
*/
|
|
97
|
+
export type SyncExitCode = (typeof SYNC_EXIT_CODES)[keyof typeof SYNC_EXIT_CODES];
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Type for all check exit codes
|
|
101
|
+
*/
|
|
102
|
+
export type CheckExitCode = (typeof CHECK_EXIT_CODES)[keyof typeof CHECK_EXIT_CODES];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Type for all diagnostics exit codes
|
|
106
|
+
*/
|
|
107
|
+
export type DiagnosticsExitCode = (typeof DIAGNOSTICS_EXIT_CODES)[keyof typeof DIAGNOSTICS_EXIT_CODES];
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Human-readable descriptions for sync exit codes.
|
|
111
|
+
*/
|
|
112
|
+
export const SYNC_EXIT_DESCRIPTIONS: Record<number, string> = {
|
|
113
|
+
[SYNC_EXIT_CODES.DRIFT]: 'Locale drift detected (missing or unused keys)',
|
|
114
|
+
[SYNC_EXIT_CODES.PLACEHOLDER_MISMATCH]: 'Placeholder mismatch between locales',
|
|
115
|
+
[SYNC_EXIT_CODES.EMPTY_VALUES]: 'Empty or placeholder values in locale files',
|
|
116
|
+
[SYNC_EXIT_CODES.SUSPICIOUS_KEYS]: 'Suspicious key patterns detected',
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Human-readable descriptions for check exit codes.
|
|
121
|
+
*/
|
|
122
|
+
export const CHECK_EXIT_DESCRIPTIONS: Record<number, string> = {
|
|
123
|
+
[CHECK_EXIT_CODES.WARNINGS]: 'Warnings detected',
|
|
124
|
+
[CHECK_EXIT_CODES.CONFLICTS]: 'Blocking conflicts detected',
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Human-readable descriptions for diagnostics exit codes.
|
|
129
|
+
*/
|
|
130
|
+
export const DIAGNOSTICS_EXIT_DESCRIPTIONS: Record<number, string> = {
|
|
131
|
+
[DIAGNOSTICS_EXIT_CODES.MISSING_SOURCE_LOCALE]: 'Missing source locale file',
|
|
132
|
+
[DIAGNOSTICS_EXIT_CODES.INVALID_LOCALE_JSON]: 'Invalid JSON in locale file',
|
|
133
|
+
[DIAGNOSTICS_EXIT_CODES.UNSAFE_PROVIDER_CLASH]: 'Provider/adapter clash detected',
|
|
134
|
+
[DIAGNOSTICS_EXIT_CODES.GENERAL_CONFLICT]: 'General diagnostics conflict',
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* All exit code descriptions by command context.
|
|
139
|
+
*/
|
|
140
|
+
export const EXIT_CODE_DESCRIPTIONS = {
|
|
141
|
+
general: {
|
|
142
|
+
[GENERAL_EXIT_CODES.SUCCESS]: 'Success - no issues found',
|
|
143
|
+
[GENERAL_EXIT_CODES.ERROR]: 'General error',
|
|
144
|
+
},
|
|
145
|
+
sync: SYNC_EXIT_DESCRIPTIONS,
|
|
146
|
+
check: CHECK_EXIT_DESCRIPTIONS,
|
|
147
|
+
diagnostics: DIAGNOSTICS_EXIT_DESCRIPTIONS,
|
|
148
|
+
} as const;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get a human-readable description for an exit code.
|
|
152
|
+
*
|
|
153
|
+
* @param code - The exit code
|
|
154
|
+
* @param context - Optional context to disambiguate overlapping codes
|
|
155
|
+
* @returns Description string, or 'Unknown exit code' if not recognized
|
|
156
|
+
*/
|
|
157
|
+
export function getExitCodeDescription(
|
|
158
|
+
code: number,
|
|
159
|
+
context?: 'sync' | 'check' | 'diagnostics'
|
|
160
|
+
): string {
|
|
161
|
+
// Check context-specific first
|
|
162
|
+
if (context) {
|
|
163
|
+
const contextMap = EXIT_CODE_DESCRIPTIONS[context];
|
|
164
|
+
if (contextMap[code]) {
|
|
165
|
+
return contextMap[code];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Try general codes
|
|
170
|
+
if (code in EXIT_CODE_DESCRIPTIONS.general) {
|
|
171
|
+
return EXIT_CODE_DESCRIPTIONS.general[code as keyof typeof EXIT_CODE_DESCRIPTIONS.general];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Search all contexts
|
|
175
|
+
for (const ctxMap of [SYNC_EXIT_DESCRIPTIONS, CHECK_EXIT_DESCRIPTIONS, DIAGNOSTICS_EXIT_DESCRIPTIONS]) {
|
|
176
|
+
if (ctxMap[code]) {
|
|
177
|
+
return ctxMap[code];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return `Unknown exit code: ${code}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Helper to set process exit code with optional logging.
|
|
186
|
+
*
|
|
187
|
+
* @param code - The exit code to set
|
|
188
|
+
* @param options - Optional configuration
|
|
189
|
+
*/
|
|
190
|
+
export function setExitCode(
|
|
191
|
+
code: number,
|
|
192
|
+
options?: { silent?: boolean }
|
|
193
|
+
): void {
|
|
194
|
+
process.exitCode = code;
|
|
195
|
+
if (!options?.silent && code !== 0) {
|
|
196
|
+
// Only log in development/debug mode
|
|
197
|
+
if (process.env.DEBUG?.includes('i18nsmith')) {
|
|
198
|
+
console.error(`[i18nsmith] Exit code ${code}: ${getExitCodeDescription(code)}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
|
|
5
|
+
export type PackageManager = 'pnpm' | 'yarn' | 'npm';
|
|
6
|
+
|
|
7
|
+
export async function detectPackageManager(workspaceRoot = process.cwd()): Promise<PackageManager> {
|
|
8
|
+
if (await fileExists(path.join(workspaceRoot, 'pnpm-lock.yaml'))) {
|
|
9
|
+
return 'pnpm';
|
|
10
|
+
}
|
|
11
|
+
if (await fileExists(path.join(workspaceRoot, 'yarn.lock'))) {
|
|
12
|
+
return 'yarn';
|
|
13
|
+
}
|
|
14
|
+
return 'npm';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function installDependencies(manager: PackageManager, deps: string[], workspaceRoot = process.cwd()) {
|
|
18
|
+
const args = manager === 'npm' ? ['install', ...deps] : ['add', ...deps];
|
|
19
|
+
|
|
20
|
+
await new Promise<void>((resolve, reject) => {
|
|
21
|
+
const child = spawn(manager, args, {
|
|
22
|
+
cwd: workspaceRoot,
|
|
23
|
+
stdio: 'inherit',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
child.on('error', reject);
|
|
27
|
+
child.on('close', (code) => {
|
|
28
|
+
if (code === 0) {
|
|
29
|
+
resolve();
|
|
30
|
+
} else {
|
|
31
|
+
reject(new Error(`${manager} exited with code ${code}`));
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function fileExists(target: string) {
|
|
38
|
+
try {
|
|
39
|
+
await fs.access(target);
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/utils/pkg.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export async function readPackageJson(cwd?: string) {
|
|
5
|
+
const pkgPath = path.join(cwd ?? process.cwd(), 'package.json');
|
|
6
|
+
try {
|
|
7
|
+
const content = await fs.readFile(pkgPath, 'utf8');
|
|
8
|
+
return JSON.parse(content) as Record<string, unknown>;
|
|
9
|
+
} catch (error: unknown) {
|
|
10
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
console.warn('Unable to read package.json for dependency checks.');
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function hasDependency(pkg: Record<string, unknown> | undefined, dep: string) {
|
|
19
|
+
if (!pkg) return false;
|
|
20
|
+
const deps = pkg.dependencies as Record<string, unknown> | undefined;
|
|
21
|
+
const devDeps = pkg.devDependencies as Record<string, unknown> | undefined;
|
|
22
|
+
return Boolean(deps?.[dep] || devDeps?.[dep]);
|
|
23
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { maybeInjectProvider } from './provider-injector.js';
|
|
6
|
+
|
|
7
|
+
const providerComponentPath = 'src/components/i18n-provider.tsx';
|
|
8
|
+
const providerTemplate = `'use client';
|
|
9
|
+
|
|
10
|
+
export default function Providers({ children }: { children: React.ReactNode }) {
|
|
11
|
+
return <ThemeProvider>{children}</ThemeProvider>;
|
|
12
|
+
}
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
describe('provider injector', () => {
|
|
16
|
+
let workspaceRoot: string;
|
|
17
|
+
let cwdSpy: ReturnType<typeof vi.spyOn>;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'i18nsmith-inject-'));
|
|
21
|
+
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(workspaceRoot);
|
|
22
|
+
await fs.mkdir(path.join(workspaceRoot, 'app'), { recursive: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
cwdSpy.mockRestore();
|
|
27
|
+
await fs.rm(workspaceRoot, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('injects the provider when children expression exists', async () => {
|
|
31
|
+
const providerFile = path.join(workspaceRoot, 'app/providers.tsx');
|
|
32
|
+
await fs.writeFile(providerFile, providerTemplate, 'utf8');
|
|
33
|
+
|
|
34
|
+
const result = await maybeInjectProvider({ providerComponentPath });
|
|
35
|
+
expect(result.status).toBe('injected');
|
|
36
|
+
if (result.status !== 'injected') return;
|
|
37
|
+
|
|
38
|
+
const contents = await fs.readFile(providerFile, 'utf8');
|
|
39
|
+
expect(contents).toMatch(/import \{ I18nProvider \} from ['"].+i18n-provider['"];?/);
|
|
40
|
+
expect(contents).toContain('<I18nProvider>{children}</I18nProvider>');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('previews changes without touching files when dryRun is true', async () => {
|
|
44
|
+
const providerFile = path.join(workspaceRoot, 'app/providers.tsx');
|
|
45
|
+
await fs.writeFile(providerFile, providerTemplate, 'utf8');
|
|
46
|
+
|
|
47
|
+
const result = await maybeInjectProvider({ providerComponentPath, dryRun: true });
|
|
48
|
+
expect(result.status).toBe('preview');
|
|
49
|
+
if (result.status !== 'preview') return;
|
|
50
|
+
expect(result.diff).toContain('I18nProvider');
|
|
51
|
+
|
|
52
|
+
const contents = await fs.readFile(providerFile, 'utf8');
|
|
53
|
+
expect(contents).toBe(providerTemplate);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('fails gracefully when multiple children expressions exist', async () => {
|
|
57
|
+
const providerFile = path.join(workspaceRoot, 'app/providers.tsx');
|
|
58
|
+
await fs.writeFile(
|
|
59
|
+
providerFile,
|
|
60
|
+
`'use client';
|
|
61
|
+
|
|
62
|
+
export default function Providers({ children }: { children: React.ReactNode }) {
|
|
63
|
+
return (
|
|
64
|
+
<ThemeProvider>
|
|
65
|
+
{children}
|
|
66
|
+
<div>{children}</div>
|
|
67
|
+
</ThemeProvider>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
`,
|
|
71
|
+
'utf8'
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const result = await maybeInjectProvider({ providerComponentPath });
|
|
75
|
+
expect(result.status).toBe('failed');
|
|
76
|
+
if (result.status !== 'failed') return;
|
|
77
|
+
expect(result.reason).toMatch(/Multiple/);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { promises as fs } from 'fs';
|
|
3
|
+
import { createTwoFilesPatch } from 'diff';
|
|
4
|
+
import {
|
|
5
|
+
ImportDeclaration,
|
|
6
|
+
ImportSpecifier,
|
|
7
|
+
JsxExpression,
|
|
8
|
+
JsxOpeningElement,
|
|
9
|
+
JsxSelfClosingElement,
|
|
10
|
+
Node,
|
|
11
|
+
Project,
|
|
12
|
+
SourceFile,
|
|
13
|
+
SyntaxKind,
|
|
14
|
+
} from 'ts-morph';
|
|
15
|
+
|
|
16
|
+
export type ProviderInjectionResult =
|
|
17
|
+
| { status: 'injected'; file: string; diff?: string }
|
|
18
|
+
| { status: 'preview'; file: string; diff: string }
|
|
19
|
+
| { status: 'skipped'; file: string; existingProvider?: string }
|
|
20
|
+
| { status: 'failed'; file: string; reason: string }
|
|
21
|
+
| { status: 'not-found' };
|
|
22
|
+
|
|
23
|
+
export interface ProviderDetectionResult {
|
|
24
|
+
found: boolean;
|
|
25
|
+
file?: string;
|
|
26
|
+
provider?: string;
|
|
27
|
+
candidates: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ProviderInjectionOptions {
|
|
31
|
+
providerComponentPath: string;
|
|
32
|
+
candidates?: string[];
|
|
33
|
+
dryRun?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_PROVIDER_CANDIDATES = [
|
|
37
|
+
// Next.js App Router patterns
|
|
38
|
+
'app/providers.tsx',
|
|
39
|
+
'app/providers.ts',
|
|
40
|
+
'app/providers.jsx',
|
|
41
|
+
'app/providers.js',
|
|
42
|
+
'src/app/providers.tsx',
|
|
43
|
+
'src/app/providers.ts',
|
|
44
|
+
'src/app/providers.jsx',
|
|
45
|
+
'src/app/providers.js',
|
|
46
|
+
// Next.js layout files (common provider host)
|
|
47
|
+
'app/layout.tsx',
|
|
48
|
+
'app/layout.ts',
|
|
49
|
+
'app/layout.jsx',
|
|
50
|
+
'app/layout.js',
|
|
51
|
+
'src/app/layout.tsx',
|
|
52
|
+
'src/app/layout.ts',
|
|
53
|
+
'src/app/layout.jsx',
|
|
54
|
+
'src/app/layout.js',
|
|
55
|
+
// Generic provider wrappers
|
|
56
|
+
'src/providers.tsx',
|
|
57
|
+
'src/providers.ts',
|
|
58
|
+
'src/providers.jsx',
|
|
59
|
+
'src/providers.js',
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const KNOWN_I18N_PROVIDERS = [
|
|
63
|
+
'I18nProvider',
|
|
64
|
+
'IntlProvider', // react-intl
|
|
65
|
+
'NextIntlClientProvider', // next-intl
|
|
66
|
+
'NextIntlProvider', // next-intl (older)
|
|
67
|
+
'I18nextProvider', // react-i18next
|
|
68
|
+
'TranslationProvider', // common custom name
|
|
69
|
+
'LocaleProvider', // common custom name
|
|
70
|
+
'LanguageProvider', // common custom name
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Detect if any provider file exists and whether an i18n provider is already wired.
|
|
75
|
+
*/
|
|
76
|
+
export async function detectExistingProvider(
|
|
77
|
+
candidates: string[] = DEFAULT_PROVIDER_CANDIDATES
|
|
78
|
+
): Promise<ProviderDetectionResult> {
|
|
79
|
+
const workspaceRoot = process.cwd();
|
|
80
|
+
const existingFiles: string[] = [];
|
|
81
|
+
|
|
82
|
+
for (const candidate of candidates) {
|
|
83
|
+
const absoluteCandidate = path.resolve(workspaceRoot, candidate);
|
|
84
|
+
if (await fileExists(absoluteCandidate)) {
|
|
85
|
+
existingFiles.push(candidate);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (existingFiles.length === 0) {
|
|
90
|
+
return { found: false, candidates: [] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Check each existing file for an i18n provider
|
|
94
|
+
for (const file of existingFiles) {
|
|
95
|
+
const absolutePath = path.resolve(workspaceRoot, file);
|
|
96
|
+
const contents = await fs.readFile(absolutePath, 'utf8');
|
|
97
|
+
|
|
98
|
+
const project = new Project({
|
|
99
|
+
useInMemoryFileSystem: false,
|
|
100
|
+
skipAddingFilesFromTsConfig: true,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const sourceFile = project.createSourceFile(absolutePath, contents, { overwrite: true });
|
|
104
|
+
const detectedProvider = getDetectedProvider(sourceFile);
|
|
105
|
+
|
|
106
|
+
if (detectedProvider) {
|
|
107
|
+
return {
|
|
108
|
+
found: true,
|
|
109
|
+
file,
|
|
110
|
+
provider: detectedProvider,
|
|
111
|
+
candidates: existingFiles,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Files exist but no provider detected
|
|
117
|
+
return { found: false, candidates: existingFiles };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Returns the name of the detected i18n provider, or undefined if none found.
|
|
122
|
+
*/
|
|
123
|
+
function getDetectedProvider(sourceFile: SourceFile): string | undefined {
|
|
124
|
+
for (const pattern of KNOWN_I18N_PROVIDERS) {
|
|
125
|
+
const hasPattern =
|
|
126
|
+
sourceFile
|
|
127
|
+
.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)
|
|
128
|
+
.some((element: JsxOpeningElement) => getTagName(element) === pattern) ||
|
|
129
|
+
sourceFile
|
|
130
|
+
.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)
|
|
131
|
+
.some((element: JsxSelfClosingElement) => getTagName(element) === pattern);
|
|
132
|
+
|
|
133
|
+
if (hasPattern) {
|
|
134
|
+
return pattern;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function maybeInjectProvider(
|
|
142
|
+
options: ProviderInjectionOptions
|
|
143
|
+
): Promise<ProviderInjectionResult> {
|
|
144
|
+
const { providerComponentPath, candidates = DEFAULT_PROVIDER_CANDIDATES, dryRun = false } = options;
|
|
145
|
+
const workspaceRoot = process.cwd();
|
|
146
|
+
const providerAbsolute = path.resolve(providerComponentPath);
|
|
147
|
+
|
|
148
|
+
for (const candidate of candidates) {
|
|
149
|
+
const absoluteCandidate = path.resolve(workspaceRoot, candidate);
|
|
150
|
+
if (!(await fileExists(absoluteCandidate))) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const injection = await injectIntoCandidate({
|
|
155
|
+
candidateRelativePath: candidate,
|
|
156
|
+
candidateAbsolutePath: absoluteCandidate,
|
|
157
|
+
providerAbsolutePath: providerAbsolute,
|
|
158
|
+
dryRun,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (injection.status === 'not-found') {
|
|
162
|
+
// Should never happen because we only call when file exists, but keep for safety
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return injection;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { status: 'not-found' };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface CandidateInjectionInput {
|
|
173
|
+
candidateRelativePath: string;
|
|
174
|
+
candidateAbsolutePath: string;
|
|
175
|
+
providerAbsolutePath: string;
|
|
176
|
+
dryRun: boolean;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function injectIntoCandidate({
|
|
180
|
+
candidateRelativePath,
|
|
181
|
+
candidateAbsolutePath,
|
|
182
|
+
providerAbsolutePath,
|
|
183
|
+
dryRun,
|
|
184
|
+
}: CandidateInjectionInput): Promise<ProviderInjectionResult> {
|
|
185
|
+
const originalContents = await fs.readFile(candidateAbsolutePath, 'utf8');
|
|
186
|
+
|
|
187
|
+
const project = new Project({
|
|
188
|
+
useInMemoryFileSystem: false,
|
|
189
|
+
skipAddingFilesFromTsConfig: true,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const sourceFile = project.createSourceFile(candidateAbsolutePath, originalContents, { overwrite: true });
|
|
193
|
+
|
|
194
|
+
const existingProvider = getDetectedProvider(sourceFile);
|
|
195
|
+
if (existingProvider) {
|
|
196
|
+
return { status: 'skipped', file: candidateRelativePath, existingProvider };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const childrenExpressions = findChildrenExpressions(sourceFile);
|
|
200
|
+
if (childrenExpressions.length === 0) {
|
|
201
|
+
return {
|
|
202
|
+
status: 'failed',
|
|
203
|
+
file: candidateRelativePath,
|
|
204
|
+
reason: 'No `{children}` expression found to wrap with <I18nProvider>.',
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (childrenExpressions.length > 1) {
|
|
209
|
+
return {
|
|
210
|
+
status: 'failed',
|
|
211
|
+
file: candidateRelativePath,
|
|
212
|
+
reason: 'Multiple `{children}` expressions detected; unsure which one to wrap.',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const providerImportPath = toRelativeImport(path.dirname(candidateAbsolutePath), providerAbsolutePath);
|
|
217
|
+
ensureProviderImport(sourceFile, providerImportPath);
|
|
218
|
+
|
|
219
|
+
const expression = childrenExpressions[0];
|
|
220
|
+
expression.replaceWithText(`<I18nProvider>${expression.getText()}</I18nProvider>`);
|
|
221
|
+
|
|
222
|
+
const updatedContents = sourceFile.getFullText();
|
|
223
|
+
|
|
224
|
+
if (updatedContents === originalContents) {
|
|
225
|
+
return { status: 'skipped', file: candidateRelativePath };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const diff = createTwoFilesPatch(
|
|
229
|
+
candidateRelativePath,
|
|
230
|
+
candidateRelativePath,
|
|
231
|
+
originalContents,
|
|
232
|
+
updatedContents,
|
|
233
|
+
'',
|
|
234
|
+
''
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
if (dryRun) {
|
|
238
|
+
return { status: 'preview', file: candidateRelativePath, diff };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
await fs.writeFile(candidateAbsolutePath, updatedContents, 'utf8');
|
|
242
|
+
return { status: 'injected', file: candidateRelativePath, diff };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function getTagName(element: JsxOpeningElement | JsxSelfClosingElement) {
|
|
246
|
+
return element.getTagNameNode().getText();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function findChildrenExpressions(sourceFile: SourceFile) {
|
|
250
|
+
return sourceFile
|
|
251
|
+
.getDescendantsOfKind(SyntaxKind.JsxExpression)
|
|
252
|
+
.filter((expression: JsxExpression) => {
|
|
253
|
+
const inner = expression.getExpression();
|
|
254
|
+
return inner && Node.isIdentifier(inner) && inner.getText() === 'children';
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function ensureProviderImport(sourceFile: SourceFile, moduleSpecifier: string) {
|
|
259
|
+
const providerIdentifier = 'I18nProvider';
|
|
260
|
+
const existing = sourceFile.getImportDeclaration((declaration: ImportDeclaration) => {
|
|
261
|
+
const spec = declaration.getModuleSpecifierSourceFile();
|
|
262
|
+
if (spec && spec.getFilePath() === moduleSpecifier) {
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
return declaration.getModuleSpecifierValue() === moduleSpecifier;
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
if (existing) {
|
|
269
|
+
const hasNamed = existing
|
|
270
|
+
.getNamedImports()
|
|
271
|
+
.some((namedImport: ImportSpecifier) => namedImport.getName() === providerIdentifier);
|
|
272
|
+
if (!hasNamed) {
|
|
273
|
+
existing.addNamedImport(providerIdentifier);
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const statements = sourceFile.getStatements();
|
|
279
|
+
let insertIndex = 0;
|
|
280
|
+
for (const statement of statements) {
|
|
281
|
+
if (Node.isExpressionStatement(statement)) {
|
|
282
|
+
const expression = statement.getExpression();
|
|
283
|
+
if (Node.isStringLiteral(expression)) {
|
|
284
|
+
const literal = expression.getLiteralText();
|
|
285
|
+
if (literal.startsWith('use ')) {
|
|
286
|
+
insertIndex += 1;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
sourceFile.insertImportDeclaration(insertIndex, {
|
|
295
|
+
moduleSpecifier,
|
|
296
|
+
namedImports: [{ name: providerIdentifier }],
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function fileExists(target: string) {
|
|
301
|
+
try {
|
|
302
|
+
await fs.access(target);
|
|
303
|
+
return true;
|
|
304
|
+
} catch {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function toRelativeImport(fromDir: string, targetAbsolute: string) {
|
|
310
|
+
let relative = path.relative(fromDir, targetAbsolute).replace(/\\/g, '/');
|
|
311
|
+
if (!relative.startsWith('.')) {
|
|
312
|
+
relative = `./${relative}`;
|
|
313
|
+
}
|
|
314
|
+
return relative.replace(/\.(ts|tsx|js|jsx)$/i, '');
|
|
315
|
+
}
|