@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 +29 -0
- package/extensions/index.ts +270 -0
- package/package.json +17 -0
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
|
+
}
|