@elyracode/i18n-tools 0.5.2

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/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # @elyracode/i18n-tools
2
+
3
+ Localization tools for Elyra -- find hardcoded strings, extract translations, detect missing keys.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ elyra install npm:@elyracode/i18n-tools
9
+ ```
10
+
11
+ ## Tools
12
+
13
+ | Tool | Description |
14
+ |------|-------------|
15
+ | `find_hardcoded_strings` | Scan Blade/Vue/React files for user-facing strings that need translation |
16
+ | `find_missing_translations` | Compare locales in lang/ to find missing translation keys |
17
+
18
+ ## Commands
19
+
20
+ - `/i18n` -- Run full i18n analysis
21
+
22
+ ## Usage
23
+
24
+ ```
25
+ > Find hardcoded strings in my Blade views
26
+ > Check for missing translations between en and nb
27
+ > Scan the resources/ directory for untranslated text
28
+ /i18n
29
+ ```
@@ -0,0 +1,270 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { extname, join, relative } from "node:path";
3
+ import type { ExtensionAPI } from "@elyracode/coding-agent";
4
+ import { Type } from "typebox";
5
+
6
+ export default function (elyra: ExtensionAPI): void {
7
+
8
+ // ── Tool: find_hardcoded_strings ──
9
+ elyra.registerTool({
10
+ name: "find_hardcoded_strings",
11
+ label: "Find Hardcoded Strings",
12
+ description:
13
+ "Scan Blade, Vue, React, or PHP files for hardcoded user-facing strings " +
14
+ "that should be translated. Finds strings in templates, labels, placeholders, " +
15
+ "error messages, and button text. Ignores code-only strings.",
16
+ parameters: Type.Object({
17
+ path: Type.Optional(
18
+ Type.String({ description: "Directory to scan (default: resources/)" }),
19
+ ),
20
+ format: Type.Optional(
21
+ Type.Union([Type.Literal("blade"), Type.Literal("vue"), Type.Literal("react"), Type.Literal("auto")], {
22
+ description: "File format to scan (default: auto-detect)",
23
+ }),
24
+ ),
25
+ }),
26
+ execute: async (_toolCallId, params) => {
27
+ try {
28
+ const cwd = process.cwd();
29
+ const scanPath = params.path ?? "resources";
30
+ const fullPath = join(cwd, scanPath);
31
+
32
+ if (!existsSync(fullPath)) {
33
+ return {
34
+ content: [{ type: "text", text: `Directory not found: ${scanPath}` }],
35
+ details: {},
36
+ };
37
+ }
38
+
39
+ const findings: Array<{ file: string; line: number; text: string; context: string }> = [];
40
+ const extensions = getExtensionsForFormat(params.format ?? "auto");
41
+
42
+ scanDirectory(fullPath, (filePath) => {
43
+ const ext = extname(filePath);
44
+ if (!extensions.includes(ext)) return;
45
+
46
+ const content = readFileSync(filePath, "utf-8");
47
+ const lines = content.split("\n");
48
+
49
+ for (let i = 0; i < lines.length; i++) {
50
+ const line = lines[i];
51
+ const hardcoded = findHardcodedInLine(line, ext);
52
+ for (const match of hardcoded) {
53
+ findings.push({
54
+ file: relative(cwd, filePath),
55
+ line: i + 1,
56
+ text: match,
57
+ context: line.trim().slice(0, 100),
58
+ });
59
+ }
60
+ }
61
+ });
62
+
63
+ if (findings.length === 0) {
64
+ return {
65
+ content: [{ type: "text", text: "No hardcoded strings found. Files may already be translated." }],
66
+ details: { count: 0 },
67
+ };
68
+ }
69
+
70
+ const lines = [
71
+ `# Hardcoded Strings (${findings.length})`,
72
+ "",
73
+ "These strings should be extracted to translation files:",
74
+ "",
75
+ ];
76
+
77
+ const byFile = new Map<string, typeof findings>();
78
+ for (const f of findings) {
79
+ const list = byFile.get(f.file) ?? [];
80
+ list.push(f);
81
+ byFile.set(f.file, list);
82
+ }
83
+
84
+ for (const [file, items] of byFile) {
85
+ lines.push(`## ${file}`);
86
+ for (const item of items.slice(0, 20)) {
87
+ lines.push(`- Line ${item.line}: "${item.text}"`);
88
+ }
89
+ if (items.length > 20) {
90
+ lines.push(`- ... and ${items.length - 20} more`);
91
+ }
92
+ lines.push("");
93
+ }
94
+
95
+ lines.push("## Suggested Actions");
96
+ lines.push("1. Create translation keys in `lang/en/*.php` or `lang/en.json`");
97
+ lines.push("2. Replace strings with `__('key')` (Blade/PHP) or `$t('key')` (Vue)");
98
+ lines.push("3. Add translations for other locales");
99
+
100
+ return {
101
+ content: [{ type: "text", text: lines.join("\n") }],
102
+ details: { count: findings.length, files: byFile.size },
103
+ };
104
+ } catch (error) {
105
+ const msg = error instanceof Error ? error.message : String(error);
106
+ return {
107
+ content: [{ type: "text", text: `Scan failed: ${msg}` }],
108
+ details: {},
109
+ };
110
+ }
111
+ },
112
+ });
113
+
114
+ // ── Tool: find_missing_translations ──
115
+ elyra.registerTool({
116
+ name: "find_missing_translations",
117
+ label: "Find Missing Translations",
118
+ description:
119
+ "Compare translation files across locales to find missing keys. " +
120
+ "Scans lang/ directory for PHP and JSON translation files. " +
121
+ "Reports keys that exist in one locale but not another.",
122
+ parameters: Type.Object({
123
+ base_locale: Type.Optional(
124
+ Type.String({ description: "Base locale to compare against (default: 'en')" }),
125
+ ),
126
+ }),
127
+ execute: async (_toolCallId, params) => {
128
+ try {
129
+ const cwd = process.cwd();
130
+ const baseLocale = params.base_locale ?? "en";
131
+ const langDir = join(cwd, "lang");
132
+
133
+ if (!existsSync(langDir)) {
134
+ return {
135
+ content: [{ type: "text", text: "No lang/ directory found. Laravel translation files should be at lang/." }],
136
+ details: {},
137
+ };
138
+ }
139
+
140
+ const locales = readdirSync(langDir).filter((f) => {
141
+ const full = join(langDir, f);
142
+ return statSync(full).isDirectory() || f.endsWith(".json");
143
+ });
144
+
145
+ if (locales.length <= 1) {
146
+ return {
147
+ content: [{ type: "text", text: `Only one locale found. Add more locales to lang/ for comparison.` }],
148
+ details: {},
149
+ };
150
+ }
151
+
152
+ // Collect keys per locale from JSON files
153
+ const localeKeys = new Map<string, Set<string>>();
154
+
155
+ for (const locale of locales) {
156
+ const jsonPath = join(langDir, locale.endsWith(".json") ? locale : `${locale}.json`);
157
+ if (existsSync(jsonPath)) {
158
+ const content = JSON.parse(readFileSync(jsonPath, "utf-8"));
159
+ const keys = new Set(Object.keys(content));
160
+ const localeName = locale.replace(".json", "");
161
+ localeKeys.set(localeName, keys);
162
+ }
163
+ }
164
+
165
+ const baseKeys = localeKeys.get(baseLocale);
166
+ if (!baseKeys) {
167
+ return {
168
+ content: [{ type: "text", text: `Base locale '${baseLocale}' not found as JSON file.` }],
169
+ details: {},
170
+ };
171
+ }
172
+
173
+ const lines = ["# Missing Translations", ""];
174
+ let totalMissing = 0;
175
+
176
+ for (const [locale, keys] of localeKeys) {
177
+ if (locale === baseLocale) continue;
178
+ const missing = [...baseKeys].filter((k) => !keys.has(k));
179
+ const extra = [...keys].filter((k) => !baseKeys.has(k));
180
+
181
+ if (missing.length > 0 || extra.length > 0) {
182
+ lines.push(`## ${locale}`);
183
+ if (missing.length > 0) {
184
+ lines.push(`Missing (in ${baseLocale} but not ${locale}): ${missing.length}`);
185
+ for (const k of missing.slice(0, 20)) {
186
+ lines.push(`- ${k}`);
187
+ }
188
+ totalMissing += missing.length;
189
+ }
190
+ if (extra.length > 0) {
191
+ lines.push(`Extra (in ${locale} but not ${baseLocale}): ${extra.length}`);
192
+ }
193
+ lines.push("");
194
+ }
195
+ }
196
+
197
+ if (totalMissing === 0) {
198
+ lines.push("All locales have matching keys.");
199
+ }
200
+
201
+ return {
202
+ content: [{ type: "text", text: lines.join("\n") }],
203
+ details: { totalMissing, locales: localeKeys.size },
204
+ };
205
+ } catch (error) {
206
+ const msg = error instanceof Error ? error.message : String(error);
207
+ return {
208
+ content: [{ type: "text", text: `Translation scan failed: ${msg}` }],
209
+ details: {},
210
+ };
211
+ }
212
+ },
213
+ });
214
+
215
+ // ── Commands ──
216
+ elyra.registerCommand("i18n", {
217
+ description: "Find hardcoded strings and missing translations",
218
+ handler: async (_args, _ctx) => {
219
+ elyra.sendUserMessage("Scan this project for i18n issues: find hardcoded strings that need translation and check for missing translation keys across locales.");
220
+ },
221
+ });
222
+ }
223
+
224
+ function getExtensionsForFormat(format: string): string[] {
225
+ switch (format) {
226
+ case "blade": return [".blade.php"];
227
+ case "vue": return [".vue"];
228
+ case "react": return [".tsx", ".jsx"];
229
+ default: return [".blade.php", ".vue", ".tsx", ".jsx", ".php"];
230
+ }
231
+ }
232
+
233
+ function scanDirectory(dir: string, callback: (path: string) => void): void {
234
+ try {
235
+ for (const entry of readdirSync(dir)) {
236
+ if (entry.startsWith(".") || entry === "node_modules" || entry === "vendor") continue;
237
+ const full = join(dir, entry);
238
+ const stat = statSync(full);
239
+ if (stat.isDirectory()) {
240
+ scanDirectory(full, callback);
241
+ } else if (stat.isFile()) {
242
+ callback(full);
243
+ }
244
+ }
245
+ } catch { /* permission errors */ }
246
+ }
247
+
248
+ function findHardcodedInLine(line: string, _ext: string): string[] {
249
+ const results: string[] = [];
250
+ const trimmed = line.trim();
251
+
252
+ // Skip comments, imports, variables, code-only lines
253
+ if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) return results;
254
+ if (trimmed.startsWith("use ") || trimmed.startsWith("import ")) return results;
255
+ if (trimmed.startsWith("$") || trimmed.startsWith("const ") || trimmed.startsWith("let ") || trimmed.startsWith("var ")) return results;
256
+
257
+ // Find strings in HTML-like contexts (labels, placeholders, button text, headings)
258
+ const htmlStringPattern = /(?:>|label="|placeholder="|title="|alt="|aria-label=")([A-Z][^"<>{}\n]{2,60})/g;
259
+ let match: RegExpExecArray | null = htmlStringPattern.exec(line);
260
+ while (match) {
261
+ const text = match[1].trim();
262
+ // Filter out likely code/CSS/paths
263
+ if (text && !text.includes("$") && !text.includes("{{") && !text.includes("__(") && !text.match(/^[a-z._/]+$/)) {
264
+ results.push(text);
265
+ }
266
+ match = htmlStringPattern.exec(line);
267
+ }
268
+
269
+ return results;
270
+ }
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@elyracode/i18n-tools",
3
+ "version": "0.5.2",
4
+ "description": "Elyra extension for localization -- find hardcoded strings, extract translations, detect missing keys",
5
+ "type": "module",
6
+ "keywords": ["elyra-package", "i18n", "localization", "translation", "laravel", "vue"],
7
+ "license": "MIT",
8
+ "author": "Knut W. Horne",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/kwhorne/elyra.git",
12
+ "directory": "packages/i18n-tools"
13
+ },
14
+ "elyra": { "extensions": ["./extensions/index.ts"] },
15
+ "peerDependencies": { "@elyracode/coding-agent": "*", "typebox": "*" },
16
+ "scripts": { "clean": "echo 'nothing to clean'", "build": "echo 'nothing to build'", "check": "echo 'nothing to check'" }
17
+ }