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.
Files changed (213) hide show
  1. package/dist/commands/audit.d.ts +3 -0
  2. package/dist/commands/audit.d.ts.map +1 -0
  3. package/dist/commands/audit.js +180 -0
  4. package/dist/commands/audit.js.map +1 -0
  5. package/dist/commands/backup.d.ts +6 -0
  6. package/dist/commands/backup.d.ts.map +1 -0
  7. package/dist/commands/backup.js +85 -0
  8. package/dist/commands/backup.js.map +1 -0
  9. package/dist/commands/check.d.ts +3 -0
  10. package/dist/commands/check.d.ts.map +1 -0
  11. package/dist/commands/check.js +151 -0
  12. package/dist/commands/check.js.map +1 -0
  13. package/dist/commands/config.d.ts +3 -0
  14. package/dist/commands/config.d.ts.map +1 -0
  15. package/dist/commands/config.js +235 -0
  16. package/dist/commands/config.js.map +1 -0
  17. package/dist/commands/debug-patterns.d.ts +3 -0
  18. package/dist/commands/debug-patterns.d.ts.map +1 -0
  19. package/dist/commands/debug-patterns.js +192 -0
  20. package/dist/commands/debug-patterns.js.map +1 -0
  21. package/dist/commands/debug-patterns.test.d.ts +2 -0
  22. package/dist/commands/debug-patterns.test.d.ts.map +1 -0
  23. package/dist/commands/debug-patterns.test.js +109 -0
  24. package/dist/commands/debug-patterns.test.js.map +1 -0
  25. package/dist/commands/diagnose.d.ts +3 -0
  26. package/dist/commands/diagnose.d.ts.map +1 -0
  27. package/dist/commands/diagnose.js +117 -0
  28. package/dist/commands/diagnose.js.map +1 -0
  29. package/dist/commands/init.d.ts +8 -0
  30. package/dist/commands/init.d.ts.map +1 -0
  31. package/dist/commands/init.js +450 -0
  32. package/dist/commands/init.js.map +1 -0
  33. package/dist/commands/init.test.d.ts +2 -0
  34. package/dist/commands/init.test.d.ts.map +1 -0
  35. package/dist/commands/init.test.js +74 -0
  36. package/dist/commands/init.test.js.map +1 -0
  37. package/dist/commands/install-hooks.d.ts +3 -0
  38. package/dist/commands/install-hooks.d.ts.map +1 -0
  39. package/dist/commands/install-hooks.js +52 -0
  40. package/dist/commands/install-hooks.js.map +1 -0
  41. package/dist/commands/preflight.d.ts +7 -0
  42. package/dist/commands/preflight.d.ts.map +1 -0
  43. package/dist/commands/preflight.js +417 -0
  44. package/dist/commands/preflight.js.map +1 -0
  45. package/dist/commands/preflight.test.d.ts +5 -0
  46. package/dist/commands/preflight.test.d.ts.map +1 -0
  47. package/dist/commands/preflight.test.js +108 -0
  48. package/dist/commands/preflight.test.js.map +1 -0
  49. package/dist/commands/rename.d.ts +6 -0
  50. package/dist/commands/rename.d.ts.map +1 -0
  51. package/dist/commands/rename.js +204 -0
  52. package/dist/commands/rename.js.map +1 -0
  53. package/dist/commands/scaffold-adapter.d.ts +3 -0
  54. package/dist/commands/scaffold-adapter.d.ts.map +1 -0
  55. package/dist/commands/scaffold-adapter.js +204 -0
  56. package/dist/commands/scaffold-adapter.js.map +1 -0
  57. package/dist/commands/scaffold-adapter.test.d.ts +2 -0
  58. package/dist/commands/scaffold-adapter.test.d.ts.map +1 -0
  59. package/dist/commands/scaffold-adapter.test.js +102 -0
  60. package/dist/commands/scaffold-adapter.test.js.map +1 -0
  61. package/dist/commands/scan.d.ts +3 -0
  62. package/dist/commands/scan.d.ts.map +1 -0
  63. package/dist/commands/scan.js +93 -0
  64. package/dist/commands/scan.js.map +1 -0
  65. package/dist/commands/sync-seed.test.d.ts +2 -0
  66. package/dist/commands/sync-seed.test.d.ts.map +1 -0
  67. package/dist/commands/sync-seed.test.js +86 -0
  68. package/dist/commands/sync-seed.test.js.map +1 -0
  69. package/dist/commands/sync.d.ts +3 -0
  70. package/dist/commands/sync.d.ts.map +1 -0
  71. package/dist/commands/sync.js +590 -0
  72. package/dist/commands/sync.js.map +1 -0
  73. package/dist/commands/transform.d.ts +3 -0
  74. package/dist/commands/transform.d.ts.map +1 -0
  75. package/dist/commands/transform.js +114 -0
  76. package/dist/commands/transform.js.map +1 -0
  77. package/dist/commands/translate/csv-handler.d.ts +21 -0
  78. package/dist/commands/translate/csv-handler.d.ts.map +1 -0
  79. package/dist/commands/translate/csv-handler.js +270 -0
  80. package/dist/commands/translate/csv-handler.js.map +1 -0
  81. package/dist/commands/translate/executor.d.ts +31 -0
  82. package/dist/commands/translate/executor.d.ts.map +1 -0
  83. package/dist/commands/translate/executor.js +117 -0
  84. package/dist/commands/translate/executor.js.map +1 -0
  85. package/dist/commands/translate/index.d.ts +10 -0
  86. package/dist/commands/translate/index.d.ts.map +1 -0
  87. package/dist/commands/translate/index.js +170 -0
  88. package/dist/commands/translate/index.js.map +1 -0
  89. package/dist/commands/translate/reporter.d.ts +29 -0
  90. package/dist/commands/translate/reporter.d.ts.map +1 -0
  91. package/dist/commands/translate/reporter.js +103 -0
  92. package/dist/commands/translate/reporter.js.map +1 -0
  93. package/dist/commands/translate/types.d.ts +50 -0
  94. package/dist/commands/translate/types.d.ts.map +1 -0
  95. package/dist/commands/translate/types.js +5 -0
  96. package/dist/commands/translate/types.js.map +1 -0
  97. package/dist/commands/translate.d.ts +7 -0
  98. package/dist/commands/translate.d.ts.map +1 -0
  99. package/dist/commands/translate.js +7 -0
  100. package/dist/commands/translate.js.map +1 -0
  101. package/dist/commands/translate.test.d.ts +2 -0
  102. package/dist/commands/translate.test.d.ts.map +1 -0
  103. package/dist/commands/translate.test.js +118 -0
  104. package/dist/commands/translate.test.js.map +1 -0
  105. package/dist/e2e.test.d.ts +6 -0
  106. package/dist/e2e.test.d.ts.map +1 -0
  107. package/dist/e2e.test.js +376 -0
  108. package/dist/e2e.test.js.map +1 -0
  109. package/dist/index.d.ts +4 -0
  110. package/dist/index.d.ts.map +1 -0
  111. package/dist/index.js +39 -0
  112. package/dist/index.js.map +1 -0
  113. package/dist/integration.test.d.ts +6 -0
  114. package/dist/integration.test.d.ts.map +1 -0
  115. package/dist/integration.test.js +320 -0
  116. package/dist/integration.test.js.map +1 -0
  117. package/dist/utils/diagnostics-exit.d.ts +12 -0
  118. package/dist/utils/diagnostics-exit.d.ts.map +1 -0
  119. package/dist/utils/diagnostics-exit.js +49 -0
  120. package/dist/utils/diagnostics-exit.js.map +1 -0
  121. package/dist/utils/diagnostics-exit.test.d.ts +2 -0
  122. package/dist/utils/diagnostics-exit.test.d.ts.map +1 -0
  123. package/dist/utils/diagnostics-exit.test.js +40 -0
  124. package/dist/utils/diagnostics-exit.test.js.map +1 -0
  125. package/dist/utils/diff-utils.d.ts +4 -0
  126. package/dist/utils/diff-utils.d.ts.map +1 -0
  127. package/dist/utils/diff-utils.js +30 -0
  128. package/dist/utils/diff-utils.js.map +1 -0
  129. package/dist/utils/diff-utils.test.d.ts +2 -0
  130. package/dist/utils/diff-utils.test.d.ts.map +1 -0
  131. package/dist/utils/diff-utils.test.js +30 -0
  132. package/dist/utils/diff-utils.test.js.map +1 -0
  133. package/dist/utils/exit-codes.d.ts +142 -0
  134. package/dist/utils/exit-codes.d.ts.map +1 -0
  135. package/dist/utils/exit-codes.js +168 -0
  136. package/dist/utils/exit-codes.js.map +1 -0
  137. package/dist/utils/package-manager.d.ts +4 -0
  138. package/dist/utils/package-manager.d.ts.map +1 -0
  139. package/dist/utils/package-manager.js +40 -0
  140. package/dist/utils/package-manager.js.map +1 -0
  141. package/dist/utils/pkg.d.ts +3 -0
  142. package/dist/utils/pkg.d.ts.map +1 -0
  143. package/dist/utils/pkg.js +24 -0
  144. package/dist/utils/pkg.js.map +1 -0
  145. package/dist/utils/provider-injector.d.ts +36 -0
  146. package/dist/utils/provider-injector.d.ts.map +1 -0
  147. package/dist/utils/provider-injector.js +223 -0
  148. package/dist/utils/provider-injector.js.map +1 -0
  149. package/dist/utils/provider-injector.test.d.ts +2 -0
  150. package/dist/utils/provider-injector.test.d.ts.map +1 -0
  151. package/dist/utils/provider-injector.test.js +67 -0
  152. package/dist/utils/provider-injector.test.js.map +1 -0
  153. package/dist/utils/scaffold.d.ts +20 -0
  154. package/dist/utils/scaffold.d.ts.map +1 -0
  155. package/dist/utils/scaffold.js +197 -0
  156. package/dist/utils/scaffold.js.map +1 -0
  157. package/package.json +35 -0
  158. package/src/commands/audit.ts +234 -0
  159. package/src/commands/backup.ts +96 -0
  160. package/src/commands/check.ts +191 -0
  161. package/src/commands/config.ts +263 -0
  162. package/src/commands/debug-patterns.test.ts +134 -0
  163. package/src/commands/debug-patterns.ts +257 -0
  164. package/src/commands/diagnose.ts +136 -0
  165. package/src/commands/init.test.ts +82 -0
  166. package/src/commands/init.ts +536 -0
  167. package/src/commands/install-hooks.ts +66 -0
  168. package/src/commands/preflight.test.ts +139 -0
  169. package/src/commands/preflight.ts +488 -0
  170. package/src/commands/rename.ts +264 -0
  171. package/src/commands/scaffold-adapter.test.ts +110 -0
  172. package/src/commands/scaffold-adapter.ts +250 -0
  173. package/src/commands/scan.ts +125 -0
  174. package/src/commands/sync-seed.test.ts +116 -0
  175. package/src/commands/sync.ts +736 -0
  176. package/src/commands/transform.ts +151 -0
  177. package/src/commands/translate/README.md +75 -0
  178. package/src/commands/translate/csv-handler.ts +301 -0
  179. package/src/commands/translate/executor.ts +188 -0
  180. package/src/commands/translate/index.ts +220 -0
  181. package/src/commands/translate/reporter.ts +138 -0
  182. package/src/commands/translate/types.ts +56 -0
  183. package/src/commands/translate.test.ts +173 -0
  184. package/src/commands/translate.ts +6 -0
  185. package/src/e2e.test.ts +479 -0
  186. package/src/fixtures/README.md +61 -0
  187. package/src/fixtures/basic-react/i18n.config.json +15 -0
  188. package/src/fixtures/basic-react/locales/de.json +8 -0
  189. package/src/fixtures/basic-react/locales/en.json +8 -0
  190. package/src/fixtures/basic-react/locales/fr.json +8 -0
  191. package/src/fixtures/basic-react/src/App.tsx +15 -0
  192. package/src/fixtures/basic-react/src/Messages.tsx +12 -0
  193. package/src/fixtures/nested-locales/i18n.config.json +9 -0
  194. package/src/fixtures/nested-locales/locales/en.json +23 -0
  195. package/src/fixtures/nested-locales/locales/fr.json +23 -0
  196. package/src/fixtures/nested-locales/src/HomePage.tsx +13 -0
  197. package/src/fixtures/suspicious-keys/i18n.config.json +9 -0
  198. package/src/fixtures/suspicious-keys/locales/en.json +11 -0
  199. package/src/fixtures/suspicious-keys/locales/fr.json +11 -0
  200. package/src/fixtures/suspicious-keys/src/BadKeys.tsx +19 -0
  201. package/src/index.ts +43 -0
  202. package/src/integration.test.ts +438 -0
  203. package/src/utils/diagnostics-exit.test.ts +47 -0
  204. package/src/utils/diagnostics-exit.ts +63 -0
  205. package/src/utils/diff-utils.test.ts +36 -0
  206. package/src/utils/diff-utils.ts +42 -0
  207. package/src/utils/exit-codes.ts +201 -0
  208. package/src/utils/package-manager.ts +44 -0
  209. package/src/utils/pkg.ts +23 -0
  210. package/src/utils/provider-injector.test.ts +79 -0
  211. package/src/utils/provider-injector.ts +315 -0
  212. package/src/utils/scaffold.ts +240 -0
  213. 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
+ }
@@ -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
+ }