@idealyst/translate 1.2.3

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.
@@ -0,0 +1,287 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import type {
4
+ TranslatePluginOptions,
5
+ ExtractedKey,
6
+ TranslationsReport,
7
+ MissingTranslation,
8
+ LoadedTranslations,
9
+ } from './types';
10
+
11
+ /**
12
+ * Check if an object has a nested key
13
+ */
14
+ function hasNestedKey(obj: Record<string, unknown>, keyPath: string): boolean {
15
+ const parts = keyPath.split('.');
16
+ let current: unknown = obj;
17
+
18
+ for (const part of parts) {
19
+ if (current === undefined || current === null) return false;
20
+ if (typeof current !== 'object') return false;
21
+ current = (current as Record<string, unknown>)[part];
22
+ }
23
+
24
+ return current !== undefined;
25
+ }
26
+
27
+ /**
28
+ * Flatten an object's keys into dot-notation
29
+ */
30
+ function flattenKeys(obj: Record<string, unknown>, prefix: string = ''): string[] {
31
+ const keys: string[] = [];
32
+
33
+ for (const [key, value] of Object.entries(obj)) {
34
+ const fullKey = prefix ? `${prefix}.${key}` : key;
35
+
36
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
37
+ keys.push(...flattenKeys(value as Record<string, unknown>, fullKey));
38
+ } else {
39
+ keys.push(fullKey);
40
+ }
41
+ }
42
+
43
+ return keys;
44
+ }
45
+
46
+ /**
47
+ * Parse a key into namespace and local key
48
+ */
49
+ function parseKey(
50
+ fullKey: string,
51
+ defaultNamespace: string = 'translation'
52
+ ): { namespace: string; localKey: string } {
53
+ if (fullKey.includes(':')) {
54
+ const [namespace, ...rest] = fullKey.split(':');
55
+ return { namespace, localKey: rest.join(':') };
56
+ }
57
+
58
+ const segments = fullKey.split('.');
59
+ if (segments.length > 1) {
60
+ return { namespace: segments[0], localKey: segments.slice(1).join('.') };
61
+ }
62
+
63
+ return { namespace: defaultNamespace, localKey: fullKey };
64
+ }
65
+
66
+ /**
67
+ * Extract language code from file path
68
+ */
69
+ function extractLanguageFromPath(filePath: string): string {
70
+ const parts = filePath.split(path.sep);
71
+ const filename = path.basename(filePath, '.json');
72
+
73
+ if (filename.match(/^[a-z]{2}(-[A-Z]{2})?$/)) {
74
+ return filename;
75
+ }
76
+
77
+ const parentDir = parts[parts.length - 2];
78
+ if (parentDir && parentDir.match(/^[a-z]{2}(-[A-Z]{2})?$/)) {
79
+ return parentDir;
80
+ }
81
+
82
+ return filename;
83
+ }
84
+
85
+ /**
86
+ * Extract namespace from file path
87
+ */
88
+ function extractNamespaceFromPath(filePath: string): string {
89
+ const filename = path.basename(filePath, '.json');
90
+
91
+ if (filename.match(/^[a-z]{2}(-[A-Z]{2})?$/)) {
92
+ return 'translation';
93
+ }
94
+
95
+ return filename;
96
+ }
97
+
98
+ /**
99
+ * Load translation files from glob patterns
100
+ */
101
+ export function loadTranslations(
102
+ patterns: string[],
103
+ verbose: boolean = false
104
+ ): LoadedTranslations {
105
+ const result: LoadedTranslations = {};
106
+
107
+ // Dynamic import glob if available
108
+ let globSync: ((pattern: string) => string[]) | null = null;
109
+ try {
110
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
111
+ const glob = require('glob');
112
+ globSync = glob.sync;
113
+ } catch {
114
+ globSync = null;
115
+ }
116
+
117
+ for (const pattern of patterns) {
118
+ let files: string[] = [];
119
+
120
+ if (globSync) {
121
+ try {
122
+ files = globSync(pattern);
123
+ } catch (err) {
124
+ if (verbose) {
125
+ console.warn(`[@idealyst/translate] Failed to glob pattern: ${pattern}`, err);
126
+ }
127
+ continue;
128
+ }
129
+ } else if (fs.existsSync(pattern)) {
130
+ files = [pattern];
131
+ }
132
+
133
+ for (const file of files) {
134
+ try {
135
+ const content = JSON.parse(fs.readFileSync(file, 'utf-8'));
136
+ const lang = extractLanguageFromPath(file);
137
+ const namespace = extractNamespaceFromPath(file);
138
+
139
+ if (!result[lang]) result[lang] = {};
140
+ result[lang][namespace] = content;
141
+
142
+ if (verbose) {
143
+ console.log(`[@idealyst/translate] Loaded: ${file} -> ${lang}/${namespace}`);
144
+ }
145
+ } catch (err) {
146
+ if (verbose) {
147
+ console.warn(`[@idealyst/translate] Failed to load: ${file}`, err);
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ return result;
154
+ }
155
+
156
+ /**
157
+ * Generate a translations report from extracted keys
158
+ */
159
+ export function generateReport(
160
+ extractedKeys: ExtractedKey[],
161
+ options: TranslatePluginOptions
162
+ ): TranslationsReport {
163
+ const {
164
+ translationFiles,
165
+ languages: configLanguages,
166
+ defaultNamespace = 'translation',
167
+ verbose = false,
168
+ } = options;
169
+
170
+ const translations = loadTranslations(translationFiles, verbose);
171
+ const languages = configLanguages || Object.keys(translations);
172
+
173
+ const staticKeys = extractedKeys.filter((k) => !k.isDynamic);
174
+ const dynamicKeys = extractedKeys.filter((k) => k.isDynamic);
175
+ const uniqueKeys = [...new Set(staticKeys.map((k) => k.key))];
176
+
177
+ // Find missing translations per language
178
+ const missing: Record<string, MissingTranslation[]> = {};
179
+ for (const lang of languages) {
180
+ missing[lang] = [];
181
+ const langTranslations = translations[lang] || {};
182
+
183
+ for (const key of uniqueKeys) {
184
+ const { namespace, localKey } = parseKey(key, defaultNamespace);
185
+ const nsTranslations = langTranslations[namespace] || {};
186
+
187
+ if (!hasNestedKey(nsTranslations as Record<string, unknown>, localKey)) {
188
+ const usages = staticKeys.filter((k) => k.key === key);
189
+ missing[lang].push({
190
+ key,
191
+ namespace,
192
+ usedIn: usages.map((u) => ({
193
+ file: u.file,
194
+ line: u.line,
195
+ column: u.column,
196
+ })),
197
+ defaultValue: usages[0]?.defaultValue,
198
+ });
199
+ }
200
+ }
201
+ }
202
+
203
+ // Find unused translations per language
204
+ const unused: Record<string, string[]> = {};
205
+ for (const lang of languages) {
206
+ unused[lang] = [];
207
+ const langTranslations = translations[lang] || {};
208
+
209
+ for (const namespace of Object.keys(langTranslations)) {
210
+ const keys = flattenKeys(
211
+ langTranslations[namespace] as Record<string, unknown>,
212
+ namespace
213
+ );
214
+ for (const translationKey of keys) {
215
+ if (!uniqueKeys.includes(translationKey)) {
216
+ unused[lang].push(translationKey);
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ // Calculate coverage
223
+ const coveragePercent: Record<string, number> = {};
224
+ for (const lang of languages) {
225
+ const totalKeys = uniqueKeys.length;
226
+ const missingCount = missing[lang].length;
227
+ coveragePercent[lang] =
228
+ totalKeys > 0 ? Math.round(((totalKeys - missingCount) / totalKeys) * 100) : 100;
229
+ }
230
+
231
+ return {
232
+ timestamp: new Date().toISOString(),
233
+ totalKeys: uniqueKeys.length,
234
+ dynamicKeys,
235
+ extractedKeys: staticKeys,
236
+ languages,
237
+ missing,
238
+ unused,
239
+ summary: {
240
+ totalMissing: Object.values(missing).flat().length,
241
+ totalUnused: Object.values(unused).flat().length,
242
+ coveragePercent,
243
+ },
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Write a translations report to disk
249
+ */
250
+ export function writeReport(report: TranslationsReport, outputPath: string): void {
251
+ const dir = path.dirname(outputPath);
252
+ if (!fs.existsSync(dir)) {
253
+ fs.mkdirSync(dir, { recursive: true });
254
+ }
255
+ fs.writeFileSync(outputPath, JSON.stringify(report, null, 2), 'utf-8');
256
+ }
257
+
258
+ /**
259
+ * Print report summary to console
260
+ */
261
+ export function printReportSummary(report: TranslationsReport): void {
262
+ console.log('\n[@idealyst/translate] Translation Report Summary');
263
+ console.log('='.repeat(50));
264
+ console.log(`Total keys: ${report.totalKeys}`);
265
+ console.log(`Dynamic keys: ${report.dynamicKeys.length}`);
266
+ console.log(`Languages: ${report.languages.join(', ')}`);
267
+ console.log('');
268
+
269
+ for (const lang of report.languages) {
270
+ const coverage = report.summary.coveragePercent[lang];
271
+ const missingCount = report.missing[lang]?.length || 0;
272
+ const unusedCount = report.unused[lang]?.length || 0;
273
+
274
+ console.log(`${lang}: ${coverage}% coverage`);
275
+ if (missingCount > 0) {
276
+ console.log(` - ${missingCount} missing translation(s)`);
277
+ }
278
+ if (unusedCount > 0) {
279
+ console.log(` - ${unusedCount} unused translation(s)`);
280
+ }
281
+ }
282
+
283
+ console.log('');
284
+ console.log(`Total missing: ${report.summary.totalMissing}`);
285
+ console.log(`Total unused: ${report.summary.totalUnused}`);
286
+ console.log('='.repeat(50));
287
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Options for the @idealyst/translate Babel plugin
3
+ */
4
+ export interface TranslatePluginOptions {
5
+ /**
6
+ * Paths to translation JSON files
7
+ * Supports glob patterns: ['./locales/*.json', './src/locales/**\/*.json']
8
+ */
9
+ translationFiles: string[];
10
+
11
+ /**
12
+ * Output path for the translation report
13
+ * @default '.idealyst/translations-report.json'
14
+ */
15
+ reportPath?: string;
16
+
17
+ /**
18
+ * Languages to check against
19
+ * If not provided, inferred from translation file names/directories
20
+ */
21
+ languages?: string[];
22
+
23
+ /**
24
+ * Default namespace used when namespace is not specified in the key
25
+ * @default 'translation'
26
+ */
27
+ defaultNamespace?: string;
28
+
29
+ /**
30
+ * Whether to fail the build if missing translations are found
31
+ * @default false
32
+ */
33
+ failOnMissing?: boolean;
34
+
35
+ /**
36
+ * Whether to emit console warnings for missing translations
37
+ * @default true
38
+ */
39
+ emitWarnings?: boolean;
40
+
41
+ /**
42
+ * Whether to include source locations in the report
43
+ * @default true
44
+ */
45
+ includeLocations?: boolean;
46
+
47
+ /**
48
+ * Enable verbose logging
49
+ * @default false
50
+ */
51
+ verbose?: boolean;
52
+ }
53
+
54
+ /**
55
+ * A translation key extracted from source code
56
+ */
57
+ export interface ExtractedKey {
58
+ /**
59
+ * Full key path including namespace: "common.buttons.submit" or "common:buttons.submit"
60
+ */
61
+ key: string;
62
+
63
+ /**
64
+ * Namespace portion: "common"
65
+ */
66
+ namespace: string;
67
+
68
+ /**
69
+ * Key without namespace: "buttons.submit"
70
+ */
71
+ localKey: string;
72
+
73
+ /**
74
+ * Source file path
75
+ */
76
+ file: string;
77
+
78
+ /**
79
+ * Line number in source
80
+ */
81
+ line: number;
82
+
83
+ /**
84
+ * Column number in source
85
+ */
86
+ column: number;
87
+
88
+ /**
89
+ * Default value if provided in code
90
+ */
91
+ defaultValue?: string;
92
+
93
+ /**
94
+ * Whether this is a dynamic key (contains variables, cannot be statically analyzed)
95
+ */
96
+ isDynamic: boolean;
97
+ }
98
+
99
+ /**
100
+ * A missing translation entry
101
+ */
102
+ export interface MissingTranslation {
103
+ /**
104
+ * The missing key
105
+ */
106
+ key: string;
107
+
108
+ /**
109
+ * Namespace
110
+ */
111
+ namespace: string;
112
+
113
+ /**
114
+ * Files where this key is used
115
+ */
116
+ usedIn: Array<{
117
+ file: string;
118
+ line: number;
119
+ column: number;
120
+ }>;
121
+
122
+ /**
123
+ * Default value if provided in code
124
+ */
125
+ defaultValue?: string;
126
+ }
127
+
128
+ /**
129
+ * Translation coverage statistics
130
+ */
131
+ export interface CoverageStats {
132
+ /**
133
+ * Total number of unique static keys found in code
134
+ */
135
+ totalKeys: number;
136
+
137
+ /**
138
+ * Number of keys missing translation for this language
139
+ */
140
+ missingCount: number;
141
+
142
+ /**
143
+ * Percentage of keys that have translations (0-100)
144
+ */
145
+ coveragePercent: number;
146
+ }
147
+
148
+ /**
149
+ * The full translations report output
150
+ */
151
+ export interface TranslationsReport {
152
+ /**
153
+ * Report generation timestamp (ISO 8601)
154
+ */
155
+ timestamp: string;
156
+
157
+ /**
158
+ * Total number of unique static keys extracted
159
+ */
160
+ totalKeys: number;
161
+
162
+ /**
163
+ * Keys that couldn't be statically analyzed (dynamic keys)
164
+ */
165
+ dynamicKeys: ExtractedKey[];
166
+
167
+ /**
168
+ * All extracted static keys
169
+ */
170
+ extractedKeys: ExtractedKey[];
171
+
172
+ /**
173
+ * Languages analyzed
174
+ */
175
+ languages: string[];
176
+
177
+ /**
178
+ * Missing translations per language
179
+ * { "es": [...], "fr": [...] }
180
+ */
181
+ missing: Record<string, MissingTranslation[]>;
182
+
183
+ /**
184
+ * Unused translations per language (keys in JSON but not in code)
185
+ * { "en": ["legacy.oldKey"], ... }
186
+ */
187
+ unused: Record<string, string[]>;
188
+
189
+ /**
190
+ * Summary statistics
191
+ */
192
+ summary: {
193
+ /**
194
+ * Total missing translations across all languages
195
+ */
196
+ totalMissing: number;
197
+
198
+ /**
199
+ * Total unused translations across all languages
200
+ */
201
+ totalUnused: number;
202
+
203
+ /**
204
+ * Coverage percentage per language
205
+ */
206
+ coveragePercent: Record<string, number>;
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Loaded translations structure
212
+ * { [language]: { [namespace]: { nested: { key: "value" } } } }
213
+ */
214
+ export type LoadedTranslations = Record<string, Record<string, Record<string, unknown>>>;
@@ -0,0 +1,72 @@
1
+ import React from 'react';
2
+ import { Trans as I18nextTrans } from 'react-i18next';
3
+ import type { TransProps } from './types';
4
+
5
+ /**
6
+ * Component for rendering translations with rich text and interpolation
7
+ *
8
+ * @example Basic usage
9
+ * ```tsx
10
+ * import { Trans } from '@idealyst/translate';
11
+ *
12
+ * // Translation: "Hello, {{name}}!"
13
+ * <Trans i18nKey="greeting" values={{ name: 'World' }} />
14
+ * ```
15
+ *
16
+ * @example With components
17
+ * ```tsx
18
+ * // Translation: "Click <bold>here</bold> for <link>help</link>"
19
+ * <Trans
20
+ * i18nKey="helpText"
21
+ * components={{
22
+ * bold: <strong />,
23
+ * link: <a href="/help" />,
24
+ * }}
25
+ * />
26
+ * ```
27
+ *
28
+ * @example With pluralization
29
+ * ```tsx
30
+ * // Translation: "{{count}} item" / "{{count}} items"
31
+ * <Trans i18nKey="itemCount" count={5} />
32
+ * ```
33
+ *
34
+ * @example With children for interpolation
35
+ * ```tsx
36
+ * // Translation: "Read <0>our terms</0> and <1>privacy policy</1>"
37
+ * <Trans i18nKey="legal">
38
+ * <a href="/terms">our terms</a>
39
+ * <a href="/privacy">privacy policy</a>
40
+ * </Trans>
41
+ * ```
42
+ */
43
+ export function Trans({
44
+ i18nKey,
45
+ ns,
46
+ defaults,
47
+ components,
48
+ values,
49
+ count,
50
+ context,
51
+ parent,
52
+ children,
53
+ shouldUnescape,
54
+ }: TransProps) {
55
+ return (
56
+ <I18nextTrans
57
+ i18nKey={i18nKey}
58
+ ns={ns}
59
+ defaults={defaults}
60
+ components={components}
61
+ values={values}
62
+ count={count}
63
+ context={context}
64
+ parent={parent}
65
+ shouldUnescape={shouldUnescape}
66
+ >
67
+ {children}
68
+ </I18nextTrans>
69
+ );
70
+ }
71
+
72
+ export default Trans;
@@ -0,0 +1,2 @@
1
+ export { Trans, default } from './Trans';
2
+ export type { TransProps } from './types';
@@ -0,0 +1,64 @@
1
+ import type { ReactElement, ReactNode } from 'react';
2
+ import type { Namespace } from 'i18next';
3
+
4
+ /**
5
+ * Props for the Trans component
6
+ */
7
+ export interface TransProps {
8
+ /**
9
+ * Translation key
10
+ */
11
+ i18nKey: string;
12
+
13
+ /**
14
+ * Namespace for the translation
15
+ */
16
+ ns?: Namespace;
17
+
18
+ /**
19
+ * Default value if key is not found
20
+ */
21
+ defaults?: string;
22
+
23
+ /**
24
+ * Component map for interpolation
25
+ * Keys correspond to tags in the translation string
26
+ *
27
+ * @example
28
+ * Translation: "Click <bold>here</bold> for <link>help</link>"
29
+ * Components: { bold: <strong />, link: <a href="/help" /> }
30
+ */
31
+ components?: Record<string, ReactElement>;
32
+
33
+ /**
34
+ * Values for interpolation
35
+ */
36
+ values?: Record<string, unknown>;
37
+
38
+ /**
39
+ * Count for pluralization
40
+ */
41
+ count?: number;
42
+
43
+ /**
44
+ * Context for contextual translations
45
+ */
46
+ context?: string;
47
+
48
+ /**
49
+ * Tag name for the wrapper element
50
+ * @default React.Fragment
51
+ */
52
+ parent?: string | null;
53
+
54
+ /**
55
+ * Children elements that can be used for interpolation
56
+ */
57
+ children?: ReactNode;
58
+
59
+ /**
60
+ * Whether to unescape HTML entities
61
+ * @default false
62
+ */
63
+ shouldUnescape?: boolean;
64
+ }
@@ -0,0 +1,2 @@
1
+ export { defineConfig } from './types';
2
+ export type { TranslateConfig } from './types';
@@ -0,0 +1,10 @@
1
+ import type { TranslateConfig } from '../provider/types';
2
+
3
+ /**
4
+ * Define a translation configuration with type safety
5
+ */
6
+ export function defineConfig(config: TranslateConfig): TranslateConfig {
7
+ return config;
8
+ }
9
+
10
+ export type { TranslateConfig };
@@ -0,0 +1,8 @@
1
+ export { useTranslation, default as useTranslationDefault } from './useTranslation';
2
+ export { useLanguage } from './useLanguage';
3
+ export type {
4
+ UseTranslationOptions,
5
+ UseTranslationResult,
6
+ TranslationOptions,
7
+ UseLanguageResult,
8
+ } from './types';