From f4fdbff6ba3e7e3da19053588c530ebea568373d Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 2 Sep 2026 23:55:42 +0200 Subject: [PATCH] translator-tools: parse multi-const inputs and infer source locale --- CHANGELOG.md | 6 + SOURCE.md | 4 +- package-lock.json | 4 +- package.json | 2 +- public/CHANGELOG.md | 6 + public/SOURCE.md | 4 +- public/toolbox-app.json | 2 +- src/components/Workbench.tsx | 215 +++++++++++++++++++++++++++++-- src/styles.css | 29 ++--- src/toolbox/manifest.source.json | 2 +- src/version.ts | 2 +- 11 files changed, 239 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54747a3..d5ba639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ - Added language pair selection, glossary storage and fuzzy in-context suggestions. - Added local-only import/export pipeline with portal handoff integration. +## 0.1.1 - 2026-09-02 + +- Improved parser robustness for JS-like translation files with multiple declarations. +- Added automatic source-language inference from const/let/var declaration names (e.g. `const de = ...`). +- Added defensive parser fallback to avoid intermediate empty states when loading translation files. + ## 0.1.0 - 2026-09-02 - Initial local-first translator review implementation. diff --git a/SOURCE.md b/SOURCE.md index 7d44bc5..5aaf528 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -1,7 +1,7 @@ # Corresponding source -The corresponding source for Text Tools 0.2.0 is available at: +The corresponding source for Translator Tools 0.1.1 is available at: -https://git.add-ideas.de/lotobo/text-tools/src/tag/v0.2.0 +https://git.add-ideas.de/lotobo/translator-tools/src/tag/v0.1.1 Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`. diff --git a/package-lock.json b/package-lock.json index 4e7feec..9014365 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "translator-tools", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "translator-tools", - "version": "0.1.0", + "version": "0.1.1", "license": "GPL-3.0-or-later", "dependencies": { "@add-ideas/toolbox-contract": "0.3.0", diff --git a/package.json b/package.json index e5d249a..a5a9eb7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "translator-tools", - "version": "0.1.0", + "version": "0.1.1", "description": "Review and translate i18next-like JSON language files locally.", "license": "GPL-3.0-or-later", "author": "Albrecht Degering", diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index 54747a3..d5ba639 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -6,6 +6,12 @@ - Added language pair selection, glossary storage and fuzzy in-context suggestions. - Added local-only import/export pipeline with portal handoff integration. +## 0.1.1 - 2026-09-02 + +- Improved parser robustness for JS-like translation files with multiple declarations. +- Added automatic source-language inference from const/let/var declaration names (e.g. `const de = ...`). +- Added defensive parser fallback to avoid intermediate empty states when loading translation files. + ## 0.1.0 - 2026-09-02 - Initial local-first translator review implementation. diff --git a/public/SOURCE.md b/public/SOURCE.md index 6e8a0c9..5aaf528 100644 --- a/public/SOURCE.md +++ b/public/SOURCE.md @@ -1,7 +1,7 @@ # Corresponding source -The corresponding source for Translator Tools 0.1.0 is available at: +The corresponding source for Translator Tools 0.1.1 is available at: -https://git.add-ideas.de/lotobo/translator-tools/src/tag/v0.1.0 +https://git.add-ideas.de/lotobo/translator-tools/src/tag/v0.1.1 Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`. diff --git a/public/toolbox-app.json b/public/toolbox-app.json index 0b9ce61..9a6a62c 100644 --- a/public/toolbox-app.json +++ b/public/toolbox-app.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "id": "de.add-ideas.translator-tools", "name": "Translator Tools", - "version": "0.1.0", + "version": "0.1.1", "description": "Review and edit translation bundles in the browser with glossary suggestions.", "entry": "./", "icon": "./favicon.svg", diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index 702d9ec..9e33431 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -120,16 +120,196 @@ function guessLocaleFromFileName(name: string): string { return match?.[2]?.toLowerCase() ?? "en"; } -function parseTranslationInput(value: string): unknown { +function parseJsLikeValue( + source: string, + start: number, +): { end: number; value: string } | undefined { + let cursor = start; + while (cursor < source.length && /\s/uy.test(source[cursor]!)) { + cursor += 1; + } + const first = source[cursor]; + if (first !== "{" && first !== "[") return undefined; + + const close = first === "{" ? "}" : "]"; + let depth = 0; + let inSingle = false; + let inDouble = false; + let inBacktick = false; + let inLineComment = false; + let inBlockComment = false; + let escaped = false; + + for (let index = cursor; index < source.length; index += 1) { + const char = source[index]!; + if (inLineComment) { + if (char === "\n") inLineComment = false; + continue; + } + if (inBlockComment) { + if (char === "*" && source[index + 1] === "/") { + inBlockComment = false; + index += 1; + } + continue; + } + if (escaped) { + escaped = false; + continue; + } + + if (inSingle) { + if (char === "\\") { + escaped = true; + } else if (char === "'") { + inSingle = false; + } + continue; + } + + if (inDouble) { + if (char === "\\") { + escaped = true; + } else if (char === '"') { + inDouble = false; + } + continue; + } + + if (inBacktick) { + if (char === "\\") { + escaped = true; + } else if (char === "`") { + inBacktick = false; + } + continue; + } + + if (char === "'" && !inSingle && !inDouble && !inBacktick) { + inSingle = true; + continue; + } + if (char === '"' && !inSingle && !inDouble && !inBacktick) { + inDouble = true; + continue; + } + if (char === "`" && !inSingle && !inDouble && !inBacktick) { + inBacktick = true; + continue; + } + + if (char === "/" && source[index + 1] === "/") { + inLineComment = true; + index += 1; + continue; + } + if (char === "/" && source[index + 1] === "*") { + inBlockComment = true; + index += 1; + continue; + } + + if (char === first) { + depth += 1; + continue; + } + if (char === close) { + depth -= 1; + if (depth === 0) { + return { value: source.slice(cursor, index + 1), end: index + 1 }; + } + } + } + return undefined; +} + +function parseNamedAssignments( + source: string, +): Array<{ name: string; value: unknown }> { + const values: Array<{ name: string; value: unknown }> = []; + const declarationRegex = + /(?:^|[\s;])(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][\w$]*)\s*=\s*/guy; + + for (let cursor = 0; cursor < source.length;) { + declarationRegex.lastIndex = cursor; + const match = declarationRegex.exec(source); + if (!match) break; + + const name = match[1]; + if (!name) { + cursor = declarationRegex.lastIndex; + continue; + } + + const matchedEnd = declarationRegex.lastIndex; + const extracted = parseJsLikeValue(source, matchedEnd); + if (!extracted) { + cursor = matchedEnd; + continue; + } + try { + const parsed = JSON5.parse(extracted.value); + values.push({ name, value: parsed }); + } catch { + // Ignore declarations that are not JSON-like values. + } + declarationRegex.lastIndex = extracted.end; + cursor = extracted.end; + } + return values; +} + +type ParsedTranslationInput = { + value: unknown; + sourceLanguageHint?: string; +}; + +function parseTranslationInput(value: string): ParsedTranslationInput { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error("The translation input is empty."); + } try { - return JSON.parse(value); + return { value: JSON.parse(trimmed) }; } catch { - const trimmed = value.trim(); const asAssignment = /^(?:export\s+)?(?:const|let|var)\s+[a-zA-Z_$][\w$]*\s*=\s*([\s\S]*?)\s*;?\s*$/u.exec( trimmed, )?.[1]; - return JSON5.parse(asAssignment ?? trimmed); + const assignments = parseNamedAssignments(trimmed); + if (assignments.length > 0) { + const first = assignments[0]; + if (!first) return { value: JSON5.parse(asAssignment ?? trimmed) }; + if (assignments.length === 1) { + const name = normalizeLocaleCode(first.name); + return { + value: first.value, + sourceLanguageHint: isLikelyLocaleCode(name) ? name : undefined, + }; + } + const localeCount = assignments.filter((entry) => + isLikelyLocaleCode(normalizeLocaleCode(entry.name)), + ).length; + if (localeCount > 1) { + const mapped = assignments.reduce>( + (acc, entry) => { + acc[normalizeLocaleCode(entry.name)] = entry.value; + return acc; + }, + {}, + ); + return { value: mapped }; + } + const mapped = assignments.reduce>( + (acc, entry) => { + acc[entry.name] = entry.value; + return acc; + }, + {}, + ); + return { value: mapped }; + } + return { value: JSON5.parse(asAssignment ?? trimmed) }; } } @@ -164,6 +344,7 @@ function levenshtein(a: string, b: string): number { function extractWorkspace( value: unknown, sourceFileName: string, + sourceLanguageHint?: string, ): Workspace & { warning?: string } { if (!isRecord(value)) throw new Error("Translation input must be an object."); @@ -195,16 +376,24 @@ function extractWorkspace( }; } + const normalizedHint = sourceLanguageHint + ? normalizeLocaleCode(sourceLanguageHint) + : ""; const guessed = normalizeLocaleCode(guessLocaleFromFileName(sourceFileName)); + const inferredSourceLanguage = isLikelyLocaleCode(normalizedHint) + ? normalizedHint + : isLikelyLocaleCode(guessed) + ? guessed + : "en"; const flat: FlatLocale = {}; flattenLocale(value, "", flat, (path) => { warnings.push(`Skipped non-string value at ${path}`); }); - localeMap[guessed] = flat; + localeMap[inferredSourceLanguage] = flat; return { mode: "single-locale", - sourceLanguage: guessed, - targetLanguage: guessed === "en" ? "de" : "en", + sourceLanguage: inferredSourceLanguage, + targetLanguage: inferredSourceLanguage === "en" ? "de" : "en", localeMap, warning: warnings.join("; ") || undefined, }; @@ -449,7 +638,11 @@ export function Workbench({ setRawInput(next); try { const parsed = parseTranslationInput(next); - const nextWorkspace = extractWorkspace(parsed, file.name); + const nextWorkspace = extractWorkspace( + parsed.value, + file.name, + parsed.sourceLanguageHint, + ); setWorkspace({ mode: nextWorkspace.mode, sourceLanguage: nextWorkspace.sourceLanguage, @@ -481,7 +674,11 @@ export function Workbench({ } try { const parsed = parseTranslationInput(rawInput); - const nextWorkspace = extractWorkspace(parsed, sourceFileName); + const nextWorkspace = extractWorkspace( + parsed.value, + sourceFileName, + parsed.sourceLanguageHint, + ); setWorkspace({ mode: nextWorkspace.mode, sourceLanguage: nextWorkspace.sourceLanguage, diff --git a/src/styles.css b/src/styles.css index da89b81..f8131e6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,16 +1,5 @@ :root { - --toolbox-background: #f4f7fc; - --toolbox-surface: #fff; - --toolbox-surface-soft: #eff1f7; - --toolbox-text: #1f2433; - --toolbox-muted: #62697c; - --toolbox-border: #d9deea; - --toolbox-accent: #4f46e5; - --toolbox-accent-hover: #4437d6; - --toolbox-accent-soft: #eceaff; - --toolbox-accent-contrast: #fff; - --toolbox-focus: #107d74; - --toolbox-danger: #b42342; + color-scheme: inherit; } * { @@ -67,7 +56,7 @@ button:disabled { } .secondary { - background: #f7f7ff; + background: var(--toolbox-surface-soft); } button.primary { @@ -154,7 +143,7 @@ a { font-size: 0.84rem; border: 1px solid var(--toolbox-border); background: var(--toolbox-accent-soft); - color: #2f2a6d; + color: var(--toolbox-accent); align-self: flex-start; } @@ -244,7 +233,11 @@ a { } .review-grid thead { - background: #f9fbff; + background: color-mix( + in srgb, + var(--toolbox-surface-soft) 86%, + var(--toolbox-surface) + ); } .review-grid textarea { @@ -265,7 +258,7 @@ a { color: var(--toolbox-muted); padding: 0.4rem 0.5rem; border-radius: 0.5rem; - background: #f8f9fc; + background: color-mix(in srgb, var(--toolbox-surface-soft) 90%, transparent); border: 1px solid var(--toolbox-border); } @@ -295,7 +288,7 @@ a { .notice { margin: 0.75rem 0 0; - color: #1f5a75; + color: var(--toolbox-accent); } .error { @@ -351,7 +344,7 @@ a { .language-list li { border: 1px solid var(--toolbox-border); border-radius: 0.6rem; - background: #fbfcff; + background: color-mix(in srgb, var(--toolbox-surface-soft) 90%, transparent); display: flex; justify-content: space-between; gap: 0.75rem; diff --git a/src/toolbox/manifest.source.json b/src/toolbox/manifest.source.json index 0b9ce61..9a6a62c 100644 --- a/src/toolbox/manifest.source.json +++ b/src/toolbox/manifest.source.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "id": "de.add-ideas.translator-tools", "name": "Translator Tools", - "version": "0.1.0", + "version": "0.1.1", "description": "Review and edit translation bundles in the browser with glossary suggestions.", "entry": "./", "icon": "./favicon.svg", diff --git a/src/version.ts b/src/version.ts index 76162f8..6979bce 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = "0.1.0"; +export const APP_VERSION = "0.1.1";