From c9ab9e7810394f788b43649156838dc8a5af7512 Mon Sep 17 00:00:00 2001 From: iAstro Date: Wed, 2 Sep 2026 18:46:24 +0800 Subject: [PATCH 1/2] fix(i18n): match browser locale by language subtag instead of exact code The extension ships en-US and zh-CN resources, but only en-US was ever selected: every other browser language fell through to fallbackLng ("zh-CN"), so users on en, en-GB, en-CN, en-AU, ja-JP, fr-FR saw Chinese. Match on the BCP 47 language subtag and normalise it to a resource key: en* -> en-US zh-Hans* / zh-CN / zh-SG / zh -> zh-CN zh-Hant* / zh-TW / zh-HK / zh-MO -> zh-TW anything else -> unchanged (fallbackLng default: en-US) Simplified vs Traditional is decided by the script subtag (Hans/Hant) when present, then by the region subtag, since Chrome reports zh-CN / zh-TW rather than script subtags. The zh-TW branch is written now so adding a Traditional translation later needs no code change. chrome.i18n.getUILanguage() is now preferred over navigator.language, which is unreliable on the chrome-extension:// origin the popup runs on. Adds a locale-resolution regression matrix (28 cases). Closes #168 --- packages/i18n/package.json | 7 ++ packages/i18n/src/chrome-storage-sync.ts | 77 ++++++++++++++- packages/i18n/src/i18n.ts | 15 ++- packages/i18n/tests/locale-resolution.test.ts | 95 +++++++++++++++++++ packages/i18n/vitest.config.ts | 8 ++ pnpm-lock.yaml | 4 + 6 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 packages/i18n/tests/locale-resolution.test.ts create mode 100644 packages/i18n/vitest.config.ts diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 6856a2de..dac463a9 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -13,6 +13,10 @@ "default": "./src/react.tsx" } }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, "peerDependencies": { "react": "^19.0.0" }, @@ -20,5 +24,8 @@ "i18next": "^24.2.2", "i18next-browser-languagedetector": "^8.0.2", "react-i18next": "^15.3.4" + }, + "devDependencies": { + "vitest": "^4.1.6" } } diff --git a/packages/i18n/src/chrome-storage-sync.ts b/packages/i18n/src/chrome-storage-sync.ts index 239b98f6..182bf623 100644 --- a/packages/i18n/src/chrome-storage-sync.ts +++ b/packages/i18n/src/chrome-storage-sync.ts @@ -24,6 +24,9 @@ declare const chrome: ) => void; }; }; + i18n?: { + getUILanguage(): string; + }; } | undefined; @@ -31,14 +34,86 @@ function getChromeLocal(): ChromeStorageLocal | undefined { return typeof chrome !== "undefined" ? chrome.storage?.local : undefined; } +/** Regions whose written Chinese defaults to Traditional. */ +const TRADITIONAL_CHINESE_REGIONS = new Set(["tw", "hk", "mo"]); + +/** + * Normalize a BCP 47 language tag to one of the shipped resource keys. + * + * | detected tag | resource | rule | + * | ----------------------- | -------- | --------------------------- | + * | `en`, `en-GB`, `en-CN` | `en-US` | every English variant | + * | `zh-Hans`, `zh-Hans-CN` | `zh-CN` | script subtag wins | + * | `zh-Hant`, `zh-Hant-TW` | `zh-TW` | script subtag wins | + * | `zh-CN`, `zh-SG` | `zh-CN` | region subtag | + * | `zh-TW`, `zh-HK`, `zh-MO`| `zh-TW` | region subtag | + * | `zh` (bare) | `zh-CN` | default: Simplified | + * | `ja`, `fr`, `de`, … | unchanged| no resource → `fallbackLng` | + * + * Simplified vs Traditional is decided by the script subtag (`Hans` / `Hant`) + * when present, then by the region subtag — Chrome reports `zh-CN` / `zh-TW` + * rather than script subtags in practice. + * + * The `zh-TW` branch is written now so adding a Traditional translation later + * needs no code change; until that resource exists i18next falls back to `zh-CN`. + */ +export function normalizeLanguageCode(code: string): string { + const parts = code.split(/[-_]/).map((part) => part.toLowerCase()); + const primary = parts[0]; + + // English — every variant resolves to the single `en-US` resource. + if (primary === "en") { + return "en-US"; + } + + if (primary === "zh") { + // 1. Script subtag is the most accurate signal (zh-Hans / zh-Hant). + if (parts.includes("hans")) { + return "zh-CN"; + } + if (parts.includes("hant")) { + return "zh-TW"; + } + // 2. Fall back to the region subtag, which is what Chrome actually reports. + // Skip parts[0] — the primary subtag `zh` is also two characters long. + const region = parts.slice(1).find((part) => part.length === 2); + if (region && TRADITIONAL_CHINESE_REGIONS.has(region)) { + return "zh-TW"; + } + // 3. Bare `zh` and any other region default to Simplified. + return "zh-CN"; + } + + // No translation shipped: leave untouched so i18next applies `fallbackLng`. + return code; +} + +/** + * i18next detector that prefers `chrome.i18n.getUILanguage()`, Chrome's + * authoritative source for the extension UI language. The popup runs on a + * `chrome-extension://` origin where `navigator.language` can disagree with it. + */ +export const chromeUiLanguageDetector = { + name: "chromeUILanguage", + lookup(): string | undefined { + if (typeof chrome !== "undefined" && chrome.i18n?.getUILanguage) { + return chrome.i18n.getUILanguage(); + } + return undefined; + }, +}; + /** Avoid page-origin localStorage; use extension storage + navigator only. */ export function getLanguageDetectionOptions(): { order: string[]; caches: string[]; + convertDetectedLanguage: (lng: string) => string; } { return { - order: ["navigator"], + order: ["chromeUILanguage", "navigator"], caches: [], + // Normalise the detected tag before i18next resolves it against `resources`. + convertDetectedLanguage: (lng: string) => normalizeLanguageCode(lng), }; } diff --git a/packages/i18n/src/i18n.ts b/packages/i18n/src/i18n.ts index a3eafa33..d88bd726 100644 --- a/packages/i18n/src/i18n.ts +++ b/packages/i18n/src/i18n.ts @@ -2,7 +2,11 @@ import i18n from "i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import { initReactI18next } from "react-i18next"; -import { bindChromeStorageLanguageSync, getLanguageDetectionOptions } from "./chrome-storage-sync"; +import { + bindChromeStorageLanguageSync, + chromeUiLanguageDetector, + getLanguageDetectionOptions, +} from "./chrome-storage-sync"; import enUSCommon from "./locales/en-US/common.json"; import enUSExtension from "./locales/en-US/extension.json"; import zhCNCommon from "./locales/zh-CN/common.json"; @@ -19,12 +23,17 @@ const resources = { }, } as const; +const languageDetector = new LanguageDetector(); +languageDetector.addDetector(chromeUiLanguageDetector); + i18n - .use(LanguageDetector) + .use(languageDetector) .use(initReactI18next) .init({ resources, - fallbackLng: "zh-CN", + // English is the international default; Chinese users still get Chinese. + // `zh` → zh-CN also covers zh-TW/zh-HK until a Traditional resource exists. + fallbackLng: { en: ["en-US"], zh: ["zh-CN"], default: ["en-US"] }, defaultNS: "common", ns: ["common", "extension"], diff --git a/packages/i18n/tests/locale-resolution.test.ts b/packages/i18n/tests/locale-resolution.test.ts new file mode 100644 index 00000000..2949c702 --- /dev/null +++ b/packages/i18n/tests/locale-resolution.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; +import { + chromeUiLanguageDetector, + getLanguageDetectionOptions, + normalizeLanguageCode, +} from "../src/chrome-storage-sync"; + +/** + * Locks the locale-resolution table from issue #168: the UI must follow the + * browser language instead of falling through to Chinese for every locale + * other than `en-US`. + */ +describe("normalizeLanguageCode", () => { + const cases: Array<[detected: string, resource: string]> = [ + // English — every variant maps to the single en-US resource. + ["en", "en-US"], + ["en-US", "en-US"], + ["en-GB", "en-US"], + ["en-CN", "en-US"], + ["en-AU", "en-US"], + + // Simplified Chinese — script subtag wins. + ["zh-Hans", "zh-CN"], + ["zh-Hans-CN", "zh-CN"], + ["zh-Hans-SG", "zh-CN"], + + // Simplified Chinese — region subtag. + ["zh-CN", "zh-CN"], + ["zh-SG", "zh-CN"], + + // Bare `zh` defaults to Simplified. + ["zh", "zh-CN"], + + // Traditional Chinese — script subtag wins. + ["zh-Hant", "zh-TW"], + ["zh-Hant-TW", "zh-TW"], + ["zh-Hant-HK", "zh-TW"], + + // Traditional Chinese — region subtag. `zh-TW` has no resource yet, so + // i18next falls back to zh-CN via `fallbackLng.zh`. + ["zh-TW", "zh-TW"], + ["zh-HK", "zh-TW"], + ["zh-MO", "zh-TW"], + + // No translation shipped — returned unchanged so i18next applies the + // `default` fallback (en-US) rather than Chinese. + ["ja", "ja"], + ["ja-JP", "ja-JP"], + ["fr-FR", "fr-FR"], + ["de-DE", "de-DE"], + ]; + + it.each(cases)("maps %s to %s", (detected, resource) => { + expect(normalizeLanguageCode(detected)).toBe(resource); + }); + + it("is case-insensitive", () => { + expect(normalizeLanguageCode("EN-us")).toBe("en-US"); + expect(normalizeLanguageCode("zh-hant-tw")).toBe("zh-TW"); + }); +}); + +describe("chromeUiLanguageDetector", () => { + it("prefers chrome.i18n.getUILanguage() when available", () => { + vi.stubGlobal("chrome", { i18n: { getUILanguage: () => "en-GB" } }); + expect(chromeUiLanguageDetector.lookup()).toBe("en-GB"); + }); + + it("returns undefined outside a Chrome extension context", () => { + vi.stubGlobal("chrome", undefined); + expect(chromeUiLanguageDetector.lookup()).toBeUndefined(); + }); + + it("returns undefined when the i18n API is unavailable", () => { + vi.stubGlobal("chrome", {}); + expect(chromeUiLanguageDetector.lookup()).toBeUndefined(); + }); +}); + +describe("getLanguageDetectionOptions", () => { + it("orders the Chrome UI language ahead of navigator", () => { + expect(getLanguageDetectionOptions().order).toEqual(["chromeUILanguage", "navigator"]); + }); + + it("normalises whatever the detectors report", () => { + const { convertDetectedLanguage } = getLanguageDetectionOptions(); + expect(convertDetectedLanguage("en-GB")).toBe("en-US"); + expect(convertDetectedLanguage("zh-TW")).toBe("zh-TW"); + expect(convertDetectedLanguage("ja-JP")).toBe("ja-JP"); + }); + + it("does not write to page-origin storage", () => { + expect(getLanguageDetectionOptions().caches).toEqual([]); + }); +}); diff --git a/packages/i18n/vitest.config.ts b/packages/i18n/vitest.config.ts new file mode 100644 index 00000000..faa6d98e --- /dev/null +++ b/packages/i18n/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d60c24f2..4fc27beb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,10 @@ importers: react-i18next: specifier: ^15.3.4 version: 15.7.4(i18next@24.2.3(typescript@5.9.3))(react@19.2.6)(typescript@5.9.3) + devDependencies: + vitest: + specifier: ^4.1.6 + version: 4.1.6(@types/node@25.8.0)(happy-dom@15.11.7)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0)) packages/ui: dependencies: From 590a763a5fe30026abe15560281288e95e4607d2 Mon Sep 17 00:00:00 2001 From: iAstro Date: Thu, 3 Sep 2026 18:28:36 +0800 Subject: [PATCH 2/2] feat(i18n): ship CJK and FIGS locales alongside English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds seven translated bundles — zh-TW, ja, ko, fr, it, de, es — so the UI follows the browser language across major markets instead of falling back to Chinese (#168) or, after #174, to English only. Resource keys use bare language subtags (ja, ko, fr, it, de, es) so regional variants share one bundle: fr-CA, fr-BE and fr all resolve to fr. Chinese stays split by script, zh-CN / zh-TW, because Simplified and Traditional differ by script rather than by region. normalizeLanguageCode now resolves every shipped language from a single map, and fallbackLng gained a per-language chain: fallbackLng: { en: [en-US], zh: [zh-CN], ja: [ja], ko: [ko], fr: [fr], it: [it], de: [de], es: [es], default: [en-US] } so an untranslated locale still lands on English rather than Chinese. Tests grow from 28 to 80 cases. Besides the expanded resolution table, the bundles are now checked structurally: every locale must expose the same key set as en-US, keep identical {{placeholders}} (a dropped one renders as a blank gap), leave no key empty, and leave the product and brand name untranslated. Built on top of #174. Closes #168 --- packages/i18n/src/chrome-storage-sync.ts | 48 +++--- packages/i18n/src/i18n.ts | 66 +++++++- packages/i18n/src/locales/de/common.json | 6 + packages/i18n/src/locales/de/extension.json | 94 ++++++++++++ packages/i18n/src/locales/es/common.json | 6 + packages/i18n/src/locales/es/extension.json | 94 ++++++++++++ packages/i18n/src/locales/fr/common.json | 6 + packages/i18n/src/locales/fr/extension.json | 94 ++++++++++++ packages/i18n/src/locales/it/common.json | 6 + packages/i18n/src/locales/it/extension.json | 94 ++++++++++++ packages/i18n/src/locales/ja/common.json | 6 + packages/i18n/src/locales/ja/extension.json | 94 ++++++++++++ packages/i18n/src/locales/ko/common.json | 6 + packages/i18n/src/locales/ko/extension.json | 94 ++++++++++++ packages/i18n/src/locales/zh-TW/common.json | 6 + .../i18n/src/locales/zh-TW/extension.json | 94 ++++++++++++ packages/i18n/tests/locale-resolution.test.ts | 143 +++++++++++++++++- 17 files changed, 926 insertions(+), 31 deletions(-) create mode 100644 packages/i18n/src/locales/de/common.json create mode 100644 packages/i18n/src/locales/de/extension.json create mode 100644 packages/i18n/src/locales/es/common.json create mode 100644 packages/i18n/src/locales/es/extension.json create mode 100644 packages/i18n/src/locales/fr/common.json create mode 100644 packages/i18n/src/locales/fr/extension.json create mode 100644 packages/i18n/src/locales/it/common.json create mode 100644 packages/i18n/src/locales/it/extension.json create mode 100644 packages/i18n/src/locales/ja/common.json create mode 100644 packages/i18n/src/locales/ja/extension.json create mode 100644 packages/i18n/src/locales/ko/common.json create mode 100644 packages/i18n/src/locales/ko/extension.json create mode 100644 packages/i18n/src/locales/zh-TW/common.json create mode 100644 packages/i18n/src/locales/zh-TW/extension.json diff --git a/packages/i18n/src/chrome-storage-sync.ts b/packages/i18n/src/chrome-storage-sync.ts index 182bf623..1aa529dd 100644 --- a/packages/i18n/src/chrome-storage-sync.ts +++ b/packages/i18n/src/chrome-storage-sync.ts @@ -37,35 +37,47 @@ function getChromeLocal(): ChromeStorageLocal | undefined { /** Regions whose written Chinese defaults to Traditional. */ const TRADITIONAL_CHINESE_REGIONS = new Set(["tw", "hk", "mo"]); +/** + * Language subtag → shipped resource key, for every locale we translate. + * + * Keys are bare language subtags so regional variants share one bundle: + * `fr-CA`, `fr-BE` and `fr` all resolve to `fr`. Chinese is handled separately + * in `normalizeLanguageCode`, because Simplified and Traditional differ by + * script rather than by region. + */ +const RESOURCE_BY_LANGUAGE: Record = { + en: "en-US", + ja: "ja", + ko: "ko", + fr: "fr", + it: "it", + de: "de", + es: "es", +}; + /** * Normalize a BCP 47 language tag to one of the shipped resource keys. * - * | detected tag | resource | rule | - * | ----------------------- | -------- | --------------------------- | - * | `en`, `en-GB`, `en-CN` | `en-US` | every English variant | - * | `zh-Hans`, `zh-Hans-CN` | `zh-CN` | script subtag wins | - * | `zh-Hant`, `zh-Hant-TW` | `zh-TW` | script subtag wins | - * | `zh-CN`, `zh-SG` | `zh-CN` | region subtag | - * | `zh-TW`, `zh-HK`, `zh-MO`| `zh-TW` | region subtag | - * | `zh` (bare) | `zh-CN` | default: Simplified | - * | `ja`, `fr`, `de`, … | unchanged| no resource → `fallbackLng` | + * | detected tag | resource | rule | + * | ------------------------ | -------- | ------------------ | + * | `en`, `en-GB`, `en-CN` | `en-US` | language subtag | + * | `zh-Hans*` | `zh-CN` | script subtag wins | + * | `zh-Hant*` | `zh-TW` | script subtag wins | + * | `zh-CN`, `zh-SG`, `zh` | `zh-CN` | region / default | + * | `zh-TW`, `zh-HK`, `zh-MO`| `zh-TW` | region subtag | + * | `ja*`, `ko*`, `fr*` | `ja`, `ko`, `fr` | language subtag | + * | `it*`, `de*`, `es*` | `it`, `de`, `es` | language subtag | + * | anything else | unchanged| → `fallbackLng` | * * Simplified vs Traditional is decided by the script subtag (`Hans` / `Hant`) * when present, then by the region subtag — Chrome reports `zh-CN` / `zh-TW` * rather than script subtags in practice. - * - * The `zh-TW` branch is written now so adding a Traditional translation later - * needs no code change; until that resource exists i18next falls back to `zh-CN`. */ export function normalizeLanguageCode(code: string): string { const parts = code.split(/[-_]/).map((part) => part.toLowerCase()); const primary = parts[0]; - // English — every variant resolves to the single `en-US` resource. - if (primary === "en") { - return "en-US"; - } - + // Chinese needs script-level resolution; every other locale maps by subtag. if (primary === "zh") { // 1. Script subtag is the most accurate signal (zh-Hans / zh-Hant). if (parts.includes("hans")) { @@ -85,7 +97,7 @@ export function normalizeLanguageCode(code: string): string { } // No translation shipped: leave untouched so i18next applies `fallbackLng`. - return code; + return RESOURCE_BY_LANGUAGE[primary] ?? code; } /** diff --git a/packages/i18n/src/i18n.ts b/packages/i18n/src/i18n.ts index d88bd726..dfd03f8e 100644 --- a/packages/i18n/src/i18n.ts +++ b/packages/i18n/src/i18n.ts @@ -7,19 +7,61 @@ import { chromeUiLanguageDetector, getLanguageDetectionOptions, } from "./chrome-storage-sync"; +import deCommon from "./locales/de/common.json"; +import deExtension from "./locales/de/extension.json"; import enUSCommon from "./locales/en-US/common.json"; import enUSExtension from "./locales/en-US/extension.json"; +import esCommon from "./locales/es/common.json"; +import esExtension from "./locales/es/extension.json"; +import frCommon from "./locales/fr/common.json"; +import frExtension from "./locales/fr/extension.json"; +import itCommon from "./locales/it/common.json"; +import itExtension from "./locales/it/extension.json"; +import jaCommon from "./locales/ja/common.json"; +import jaExtension from "./locales/ja/extension.json"; +import koCommon from "./locales/ko/common.json"; +import koExtension from "./locales/ko/extension.json"; import zhCNCommon from "./locales/zh-CN/common.json"; import zhCNExtension from "./locales/zh-CN/extension.json"; +import zhTWCommon from "./locales/zh-TW/common.json"; +import zhTWExtension from "./locales/zh-TW/extension.json"; const resources = { + "en-US": { + common: enUSCommon, + extension: enUSExtension, + }, "zh-CN": { common: zhCNCommon, extension: zhCNExtension, }, - "en-US": { - common: enUSCommon, - extension: enUSExtension, + "zh-TW": { + common: zhTWCommon, + extension: zhTWExtension, + }, + ja: { + common: jaCommon, + extension: jaExtension, + }, + ko: { + common: koCommon, + extension: koExtension, + }, + fr: { + common: frCommon, + extension: frExtension, + }, + it: { + common: itCommon, + extension: itExtension, + }, + de: { + common: deCommon, + extension: deExtension, + }, + es: { + common: esCommon, + extension: esExtension, }, } as const; @@ -31,9 +73,21 @@ i18n .use(initReactI18next) .init({ resources, - // English is the international default; Chinese users still get Chinese. - // `zh` → zh-CN also covers zh-TW/zh-HK until a Traditional resource exists. - fallbackLng: { en: ["en-US"], zh: ["zh-CN"], default: ["en-US"] }, + // Per-language fallback chain. English is the international default, so any + // locale we do not ship still lands on English rather than Chinese. Each + // translated language points at its own bundle; `zh` keeps zh-CN because + // normalizeLanguageCode already routes Traditional variants to zh-TW. + fallbackLng: { + en: ["en-US"], + zh: ["zh-CN"], + ja: ["ja"], + ko: ["ko"], + fr: ["fr"], + it: ["it"], + de: ["de"], + es: ["es"], + default: ["en-US"], + }, defaultNS: "common", ns: ["common", "extension"], diff --git a/packages/i18n/src/locales/de/common.json b/packages/i18n/src/locales/de/common.json new file mode 100644 index 00000000..792fe73a --- /dev/null +++ b/packages/i18n/src/locales/de/common.json @@ -0,0 +1,6 @@ +{ + "app": { + "name": "browser-skill", + "description": "KI-Agenten über die bsk-CLI deinen angemeldeten Browser steuern lassen" + } +} diff --git a/packages/i18n/src/locales/de/extension.json b/packages/i18n/src/locales/de/extension.json new file mode 100644 index 00000000..0385cbff --- /dev/null +++ b/packages/i18n/src/locales/de/extension.json @@ -0,0 +1,94 @@ +{ + "popup": { + "brandCategory": "Browser Automation", + "brandName": "BrowserSkill", + "stateLabel": { + "disconnected": "Nicht verbunden", + "connecting": "Verbinden…", + "connected": "Verbunden", + "version_skew": "Verbunden", + "disabled": "Verbindung aus" + }, + "stateDetail": { + "disconnected": "Öffne BrowserSkill, um eine Verbindung herzustellen.", + "connecting": "Verbindung mit BrowserSkill wird hergestellt…", + "connected": "Bereit für Automatisierungsaufgaben.", + "version_skew": "Protokollversionen weichen ab. Bitte aktualisieren." + }, + "stateBadge": { + "disconnected": "Standby", + "connecting": "Igniting", + "connected": "Ready", + "version_skew": "Aktualisierbar", + "disabled": "Off" + }, + "connectionToggleTitle": "BrowserSkill-Verbindung", + "controlHintsToggleTitle": "Steuerungshinweise", + "controlHintsToggleHint": "Zeigt das Statusfeld und den orangen Schein, während der Agent eine Seite steuert.", + "controlHintsInfoLabel": "Über Steuerungshinweise", + "versionSkewWarning": "Erweiterungsprotokoll v{{extensionProtocol}}, CLI-Protokoll v{{cliProtocol}}. Die Versionen weichen ab – bitte aktualisieren.", + "upgradeAvailable": "Aktualisierbar", + "launcher": { + "title": "Schnellaktionen" + }, + "back": "Zurück", + "runtimeTitle": "Laufzeit", + "daemonVersion": "daemon v{{version}}", + "extensionVersion": "Erweiterung v{{version}}", + "extensionVersionHint": "Erweiterungsversion", + "daemonVersionHint": "bsk-Version", + "protocolVersion": "Protokoll v{{version}}", + "extensionProtocol": "Erweiterungsprotokoll v{{version}}", + "daemonProtocol": "Daemon-Protokoll v{{version}}", + "instanceTitle": "Instanz-ID", + "instanceDescription": "Eindeutige Verbindungskennung dieses Browsers", + "copy": "Kopieren", + "copied": "Kopiert", + "copyInstanceId": "Instanz-ID kopieren", + "record": { + "sectionTitle": "Aktionsaufzeichnung", + "cardDesc": "Nimm deine Aktionen als Referenz für den Agenten auf", + "sectionHint": "Erzeugt eine Aufzeichnungsanweisung – sende sie an deinen Agenten, um die Aufzeichnung deiner Aktionen zu starten; der Agent kann das erzeugte trace-Bündel anschließend nutzen, um ähnliche Aufgaben zu automatisieren.", + "topicLabel": "Thema (optional)", + "topicPlaceholder": "z. B. Artikel veröffentlichen", + "startUrlLabel": "Start-URL (optional)", + "startUrlPlaceholder": "https://…", + "copyButton": "Aufzeichnungsanweisung kopieren", + "copied": "Kopiert", + "hintDisconnected": "Nach der Verbindung verfügbar", + "promptTemplate": "Verwende die `bsk`-CLI von BrowserSkill, um die Browseraktionen aufzuzeichnen, die ich gleich zeige.\n\nSchritte:\n1. Ausführen: {{command}}\n2. Sobald du den Start bestätigt hast, bediene ich das geöffnete Fenster selbst.\n3. Nachdem ich auf Fertig geklickt habe, lies das geschriebene `./trace`-Bündel (`trace.json` + `states/`) und fasse zusammen, was ich getan habe." + } + }, + "controlOverlay": { + "status": "Agent steuert", + "interrupt": "Unterbrechen", + "interrupting": "Wird unterbrochen…" + }, + "borrowConfirmation": { + "title": "Tab-Ausleihe erlauben?", + "titleInactive": "Agent möchte einen Tab ausleihen", + "body": "Ein KI-Agent möchte diesen Tab in seinen Arbeitsbereich verschieben. Du kannst die Ausleihe ablehnen oder erlauben.", + "targetTab": "Ziel-Tab:", + "autoDeny": "Anfrage läuft in {{count}} s ab", + "deny": "Ablehnen", + "allow": "Erlauben", + "notificationTitle": "BrowserSkill: Agent möchte einen Tab ausleihen", + "notificationBody": "Genehmige oder ablehne die Ausleihe von „{{tabTitle}}“ in deinem Browser." + }, + "helpRequest": { + "title": "Agent benötigt deine Hilfe", + "compactStatus": "Der Agent wartet weiterhin darauf, dass du diesen Schritt abschließt", + "noteLabel": "Optionale Notiz an den Agenten", + "notePlaceholder": "Notiz für den Agenten hinzufügen (optional)", + "continue": "Fertig, Steuerung zurückgeben", + "cancel": "Anfrage abbrechen", + "collapse": "Einklappen", + "expand": "Ausklappen", + "dragHandle": "Zum Verschieben ziehen", + "notificationTitle": "BrowserSkill: Agent benötigt deine Hilfe" + }, + "recordOverlay": { + "recording": "Deine Aktionen werden aufgezeichnet", + "finish": "Fertig" + } +} diff --git a/packages/i18n/src/locales/es/common.json b/packages/i18n/src/locales/es/common.json new file mode 100644 index 00000000..ca183ba8 --- /dev/null +++ b/packages/i18n/src/locales/es/common.json @@ -0,0 +1,6 @@ +{ + "app": { + "name": "browser-skill", + "description": "Permite que los agentes de IA controlen tu navegador con sesión iniciada mediante la CLI bsk" + } +} diff --git a/packages/i18n/src/locales/es/extension.json b/packages/i18n/src/locales/es/extension.json new file mode 100644 index 00000000..d24c2b14 --- /dev/null +++ b/packages/i18n/src/locales/es/extension.json @@ -0,0 +1,94 @@ +{ + "popup": { + "brandCategory": "Browser Automation", + "brandName": "BrowserSkill", + "stateLabel": { + "disconnected": "Desconectado", + "connecting": "Conectando…", + "connected": "Conectado", + "version_skew": "Conectado", + "disabled": "Conexión desactivada" + }, + "stateDetail": { + "disconnected": "Abre BrowserSkill para conectarte.", + "connecting": "Conectando con BrowserSkill…", + "connected": "Listo para tareas de automatización.", + "version_skew": "Las versiones del protocolo difieren. Actualiza, por favor." + }, + "stateBadge": { + "disconnected": "Standby", + "connecting": "Igniting", + "connected": "Ready", + "version_skew": "Actualizable", + "disabled": "Off" + }, + "connectionToggleTitle": "Conexión de BrowserSkill", + "controlHintsToggleTitle": "Indicadores de control", + "controlHintsToggleHint": "Muestra la píldora de estado y el resplandor naranja mientras el agente controla una página.", + "controlHintsInfoLabel": "Acerca de los indicadores de control", + "versionSkewWarning": "Protocolo de la extensión v{{extensionProtocol}}, protocolo de la CLI v{{cliProtocol}}. Las versiones difieren: actualiza, por favor.", + "upgradeAvailable": "Actualizable", + "launcher": { + "title": "Acciones rápidas" + }, + "back": "Atrás", + "runtimeTitle": "Tiempo de ejecución", + "daemonVersion": "daemon v{{version}}", + "extensionVersion": "extensión v{{version}}", + "extensionVersionHint": "Versión de la extensión", + "daemonVersionHint": "Versión de bsk", + "protocolVersion": "protocolo v{{version}}", + "extensionProtocol": "protocolo de la extensión v{{version}}", + "daemonProtocol": "protocolo del daemon v{{version}}", + "instanceTitle": "ID de instancia", + "instanceDescription": "Identificador de conexión único de este navegador", + "copy": "Copiar", + "copied": "Copiado", + "copyInstanceId": "Copiar ID de instancia", + "record": { + "sectionTitle": "Grabación de acciones", + "cardDesc": "Graba tus acciones como referencia para el agente", + "sectionHint": "Genera una instrucción de grabación: envíala a tu agente para que empiece a grabar tus acciones; después el agente podrá usar el paquete trace generado para automatizar tareas similares.", + "topicLabel": "Tema (opcional)", + "topicPlaceholder": "p. ej. Publicar un artículo", + "startUrlLabel": "URL inicial (opcional)", + "startUrlPlaceholder": "https://…", + "copyButton": "Copiar instrucciones de grabación", + "copied": "Copiado", + "hintDisconnected": "Disponible al conectarse", + "promptTemplate": "Usa la CLI `bsk` de BrowserSkill para grabar las acciones del navegador que voy a mostrar.\n\nPasos:\n1. Ejecuta: {{command}}\n2. Cuando confirmes que ha empezado, yo mismo operaré en la ventana abierta.\n3. Después de que pulse Finalizar, lee el paquete `./trace` escrito (`trace.json` + `states/`) y resume lo que hice." + } + }, + "controlOverlay": { + "status": "Agente controlando", + "interrupt": "Interrumpir", + "interrupting": "Interrumpiendo…" + }, + "borrowConfirmation": { + "title": "¿Permitir el préstamo de la pestaña?", + "titleInactive": "Un agente quiere tomar prestada una pestaña", + "body": "Un agente de IA quiere mover esta pestaña a su espacio de trabajo. Puedes denegar o permitir el préstamo.", + "targetTab": "Pestaña de destino:", + "autoDeny": "La solicitud caduca en {{count}} s", + "deny": "Denegar", + "allow": "Permitir", + "notificationTitle": "BrowserSkill: un agente quiere tomar prestada una pestaña", + "notificationBody": "Aprueba o deniega el préstamo de «{{tabTitle}}» en tu navegador." + }, + "helpRequest": { + "title": "El agente necesita tu ayuda", + "compactStatus": "El agente sigue esperando a que termines este paso", + "noteLabel": "Nota opcional para el agente", + "notePlaceholder": "Añade una nota para el agente (opcional)", + "continue": "Listo, devolver el control", + "cancel": "Cancelar solicitud", + "collapse": "Contraer", + "expand": "Expandir", + "dragHandle": "Arrastrar para mover", + "notificationTitle": "BrowserSkill: el agente necesita tu ayuda" + }, + "recordOverlay": { + "recording": "Grabando tus acciones", + "finish": "Finalizar" + } +} diff --git a/packages/i18n/src/locales/fr/common.json b/packages/i18n/src/locales/fr/common.json new file mode 100644 index 00000000..140f207c --- /dev/null +++ b/packages/i18n/src/locales/fr/common.json @@ -0,0 +1,6 @@ +{ + "app": { + "name": "browser-skill", + "description": "Permettre aux agents IA de piloter votre navigateur connecté via la CLI bsk" + } +} diff --git a/packages/i18n/src/locales/fr/extension.json b/packages/i18n/src/locales/fr/extension.json new file mode 100644 index 00000000..3f4d1bb0 --- /dev/null +++ b/packages/i18n/src/locales/fr/extension.json @@ -0,0 +1,94 @@ +{ + "popup": { + "brandCategory": "Browser Automation", + "brandName": "BrowserSkill", + "stateLabel": { + "disconnected": "Non connecté", + "connecting": "Connexion…", + "connected": "Connecté", + "version_skew": "Connecté", + "disabled": "Connexion désactivée" + }, + "stateDetail": { + "disconnected": "Ouvrez BrowserSkill pour vous connecter.", + "connecting": "Connexion à BrowserSkill…", + "connected": "Prêt pour les tâches d'automatisation.", + "version_skew": "Les versions de protocole diffèrent. Veuillez mettre à niveau." + }, + "stateBadge": { + "disconnected": "Standby", + "connecting": "Igniting", + "connected": "Ready", + "version_skew": "Mise à niveau dispo.", + "disabled": "Off" + }, + "connectionToggleTitle": "Connexion BrowserSkill", + "controlHintsToggleTitle": "Indications de contrôle", + "controlHintsToggleHint": "Affiche la pastille d'état et la lueur orange pendant que l'agent contrôle une page.", + "controlHintsInfoLabel": "À propos des indications de contrôle", + "versionSkewWarning": "Protocole de l'extension v{{extensionProtocol}}, protocole CLI v{{cliProtocol}}. Les versions diffèrent — veuillez mettre à niveau.", + "upgradeAvailable": "Mise à niveau disponible", + "launcher": { + "title": "Actions rapides" + }, + "back": "Retour", + "runtimeTitle": "Exécution", + "daemonVersion": "daemon v{{version}}", + "extensionVersion": "extension v{{version}}", + "extensionVersionHint": "Version de l'extension", + "daemonVersionHint": "Version de bsk", + "protocolVersion": "protocole v{{version}}", + "extensionProtocol": "protocole de l'extension v{{version}}", + "daemonProtocol": "protocole du daemon v{{version}}", + "instanceTitle": "ID d'instance", + "instanceDescription": "Identifiant de connexion unique de ce navigateur", + "copy": "Copier", + "copied": "Copié", + "copyInstanceId": "Copier l'ID d'instance", + "record": { + "sectionTitle": "Enregistrement d'actions", + "cardDesc": "Enregistrez vos actions pour référence par l'agent", + "sectionHint": "Génère une instruction d'enregistrement — envoyez-la à votre agent pour qu'il commence à enregistrer vos actions ; l'agent pourra ensuite utiliser le bundle trace obtenu pour automatiser des tâches similaires.", + "topicLabel": "Sujet (optionnel)", + "topicPlaceholder": "ex. Publier un article", + "startUrlLabel": "URL de départ (optionnel)", + "startUrlPlaceholder": "https://…", + "copyButton": "Copier les instructions", + "copied": "Copié", + "hintDisconnected": "Disponible une fois connecté", + "promptTemplate": "Utilisez la CLI `bsk` de BrowserSkill pour enregistrer les actions de navigateur que je vais démontrer.\n\nÉtapes :\n1. Exécutez : {{command}}\n2. Une fois le démarrage confirmé, j'opérerai moi-même dans la fenêtre ouverte.\n3. Après avoir cliqué sur Terminer, lisez le bundle `./trace` écrit (`trace.json` + `states/`) et résumez ce que j'ai fait." + } + }, + "controlOverlay": { + "status": "Agent en contrôle", + "interrupt": "Interrompre", + "interrupting": "Interruption…" + }, + "borrowConfirmation": { + "title": "Autoriser l'emprunt de l'onglet ?", + "titleInactive": "Un agent veut emprunter un onglet", + "body": "Un agent IA souhaite déplacer cet onglet dans son espace de travail. Vous pouvez refuser ou autoriser l'emprunt.", + "targetTab": "Onglet cible :", + "autoDeny": "La demande expire dans {{count}} s", + "deny": "Refuser", + "allow": "Autoriser", + "notificationTitle": "BrowserSkill : un agent veut emprunter un onglet", + "notificationBody": "Approuvez ou refusez l'emprunt de « {{tabTitle}} » dans votre navigateur." + }, + "helpRequest": { + "title": "L'agent a besoin de votre aide", + "compactStatus": "L'agent attend toujours que vous terminiez cette étape", + "noteLabel": "Note facultative pour l'agent", + "notePlaceholder": "Ajouter une note pour l'agent (facultatif)", + "continue": "Terminé, rendre le contrôle", + "cancel": "Annuler la demande", + "collapse": "Réduire", + "expand": "Développer", + "dragHandle": "Glisser pour déplacer", + "notificationTitle": "BrowserSkill : l'agent a besoin de votre aide" + }, + "recordOverlay": { + "recording": "Enregistrement de vos actions", + "finish": "Terminer" + } +} diff --git a/packages/i18n/src/locales/it/common.json b/packages/i18n/src/locales/it/common.json new file mode 100644 index 00000000..81e328b8 --- /dev/null +++ b/packages/i18n/src/locales/it/common.json @@ -0,0 +1,6 @@ +{ + "app": { + "name": "browser-skill", + "description": "Permetti agli agenti IA di pilotare il tuo browser già autenticato tramite la CLI bsk" + } +} diff --git a/packages/i18n/src/locales/it/extension.json b/packages/i18n/src/locales/it/extension.json new file mode 100644 index 00000000..91bf5485 --- /dev/null +++ b/packages/i18n/src/locales/it/extension.json @@ -0,0 +1,94 @@ +{ + "popup": { + "brandCategory": "Browser Automation", + "brandName": "BrowserSkill", + "stateLabel": { + "disconnected": "Non connesso", + "connecting": "Connessione…", + "connected": "Connesso", + "version_skew": "Connesso", + "disabled": "Connessione disattivata" + }, + "stateDetail": { + "disconnected": "Apri BrowserSkill per connetterti.", + "connecting": "Connessione a BrowserSkill…", + "connected": "Pronto per le attività di automazione.", + "version_skew": "Le versioni del protocollo differiscono. Esegui l'aggiornamento." + }, + "stateBadge": { + "disconnected": "Standby", + "connecting": "Igniting", + "connected": "Ready", + "version_skew": "Aggiornabile", + "disabled": "Off" + }, + "connectionToggleTitle": "Connessione BrowserSkill", + "controlHintsToggleTitle": "Indicatori di controllo", + "controlHintsToggleHint": "Mostra la pillola di stato e il bagliore arancione mentre l'agente controlla una pagina.", + "controlHintsInfoLabel": "Informazioni sugli indicatori di controllo", + "versionSkewWarning": "Protocollo dell'estensione v{{extensionProtocol}}, protocollo CLI v{{cliProtocol}}. Le versioni differiscono — esegui l'aggiornamento.", + "upgradeAvailable": "Aggiornabile", + "launcher": { + "title": "Azioni rapide" + }, + "back": "Indietro", + "runtimeTitle": "Runtime", + "daemonVersion": "daemon v{{version}}", + "extensionVersion": "estensione v{{version}}", + "extensionVersionHint": "Versione dell'estensione", + "daemonVersionHint": "Versione di bsk", + "protocolVersion": "protocollo v{{version}}", + "extensionProtocol": "protocollo dell'estensione v{{version}}", + "daemonProtocol": "protocollo del daemon v{{version}}", + "instanceTitle": "ID istanza", + "instanceDescription": "Identificativo di connessione univoco di questo browser", + "copy": "Copia", + "copied": "Copiato", + "copyInstanceId": "Copia ID istanza", + "record": { + "sectionTitle": "Registrazione azioni", + "cardDesc": "Registra le tue azioni come riferimento per l'agente", + "sectionHint": "Genera un'istruzione di registrazione: inviala al tuo agente per avviare la registrazione delle tue azioni; l'agente potrà quindi usare il bundle trace risultante per automatizzare attività simili.", + "topicLabel": "Argomento (facoltativo)", + "topicPlaceholder": "es. Pubblicare un articolo", + "startUrlLabel": "URL di partenza (facoltativo)", + "startUrlPlaceholder": "https://…", + "copyButton": "Copia istruzioni di registrazione", + "copied": "Copiato", + "hintDisconnected": "Disponibile dopo la connessione", + "promptTemplate": "Usa la CLI `bsk` di BrowserSkill per registrare le azioni del browser che sto per mostrare.\n\nPassaggi:\n1. Esegui: {{command}}\n2. Dopo aver confermato l'avvio, opererò io stesso nella finestra aperta.\n3. Dopo che ho fatto clic su Fine, leggi il bundle `./trace` scritto (`trace.json` + `states/`) e riassumi cosa ho fatto." + } + }, + "controlOverlay": { + "status": "Agente in controllo", + "interrupt": "Interrompi", + "interrupting": "Interruzione…" + }, + "borrowConfirmation": { + "title": "Consentire il prestito della scheda?", + "titleInactive": "Un agente vuole prendere in prestito una scheda", + "body": "Un agente IA vuole spostare questa scheda nella sua area di lavoro. Puoi rifiutare o consentire il prestito.", + "targetTab": "Scheda di destinazione:", + "autoDeny": "La richiesta scade tra {{count}} s", + "deny": "Rifiuta", + "allow": "Consenti", + "notificationTitle": "BrowserSkill: un agente vuole prendere in prestito una scheda", + "notificationBody": "Approva o rifiuta il prestito di «{{tabTitle}}» nel tuo browser." + }, + "helpRequest": { + "title": "L'agente ha bisogno del tuo aiuto", + "compactStatus": "L'agente sta ancora aspettando che tu completi questo passaggio", + "noteLabel": "Nota facoltativa per l'agente", + "notePlaceholder": "Aggiungi una nota per l'agente (facoltativo)", + "continue": "Fatto, restituisci il controllo", + "cancel": "Annulla richiesta", + "collapse": "Comprimi", + "expand": "Espandi", + "dragHandle": "Trascina per spostare", + "notificationTitle": "BrowserSkill: l'agente ha bisogno del tuo aiuto" + }, + "recordOverlay": { + "recording": "Registrazione delle tue azioni", + "finish": "Fine" + } +} diff --git a/packages/i18n/src/locales/ja/common.json b/packages/i18n/src/locales/ja/common.json new file mode 100644 index 00000000..aa0e5b93 --- /dev/null +++ b/packages/i18n/src/locales/ja/common.json @@ -0,0 +1,6 @@ +{ + "app": { + "name": "browser-skill", + "description": "bsk CLI 経由で AI エージェントにログイン済みブラウザを操作させる" + } +} diff --git a/packages/i18n/src/locales/ja/extension.json b/packages/i18n/src/locales/ja/extension.json new file mode 100644 index 00000000..da64381e --- /dev/null +++ b/packages/i18n/src/locales/ja/extension.json @@ -0,0 +1,94 @@ +{ + "popup": { + "brandCategory": "Browser Automation", + "brandName": "BrowserSkill", + "stateLabel": { + "disconnected": "未接続", + "connecting": "接続中…", + "connected": "接続済み", + "version_skew": "接続済み", + "disabled": "接続オフ" + }, + "stateDetail": { + "disconnected": "BrowserSkill を開いて接続してください。", + "connecting": "BrowserSkill に接続しています…", + "connected": "自動化タスクを受け付けられます。", + "version_skew": "プロトコルバージョンが異なります。アップグレードしてください。" + }, + "stateBadge": { + "disconnected": "Standby", + "connecting": "Igniting", + "connected": "Ready", + "version_skew": "アップグレード可", + "disabled": "Off" + }, + "connectionToggleTitle": "BrowserSkill 接続", + "controlHintsToggleTitle": "操作ヒント", + "controlHintsToggleHint": "Agent がページを操作中、ステータスピルとオレンジの光を表示します。", + "controlHintsInfoLabel": "操作ヒントについて", + "versionSkewWarning": "拡張プロトコル v{{extensionProtocol}}、CLI プロトコル v{{cliProtocol}}。バージョンが異なるため、アップグレードしてください。", + "upgradeAvailable": "アップグレード可", + "launcher": { + "title": "クイックアクション" + }, + "back": "戻る", + "runtimeTitle": "ランタイム", + "daemonVersion": "daemon v{{version}}", + "extensionVersion": "拡張 v{{version}}", + "extensionVersionHint": "拡張機能のバージョン", + "daemonVersionHint": "bsk バージョン", + "protocolVersion": "プロトコル v{{version}}", + "extensionProtocol": "拡張プロトコル v{{version}}", + "daemonProtocol": "daemon プロトコル v{{version}}", + "instanceTitle": "インスタンス ID", + "instanceDescription": "このブラウザの一意な接続識別子", + "copy": "コピー", + "copied": "コピーしました", + "copyInstanceId": "インスタンス ID をコピー", + "record": { + "sectionTitle": "操作の記録", + "cardDesc": "Agent の参考用に操作を記録します", + "sectionHint": "記録用の指示文を生成します。Agent に送信すると操作の記録を開始でき、Agent は書き出された trace バンドルを参照して同様のタスクを自動化できます。", + "topicLabel": "トピック(任意)", + "topicPlaceholder": "例:記事を公開する", + "startUrlLabel": "開始 URL(任意)", + "startUrlPlaceholder": "https://…", + "copyButton": "記録用の指示をコピー", + "copied": "コピーしました", + "hintDisconnected": "接続後に利用できます", + "promptTemplate": "BrowserSkill の `bsk` CLI を使って、これから実演するブラウザ操作を記録してください。\n\n手順:\n1. 実行:{{command}}\n2. 開始を確認したら、開いたウィンドウで私自身が操作します。\n3. 「完了」をクリックしたら、書き出された `./trace` バンドル(`trace.json` + `states/`)を読み取り、私が行った操作を要約してください。" + } + }, + "controlOverlay": { + "status": "Agent が操作中", + "interrupt": "中断", + "interrupting": "中断中…" + }, + "borrowConfirmation": { + "title": "タブの借用を許可しますか?", + "titleInactive": "Agent がタブの借用を求めています", + "body": "AI エージェントがこのタブを作業スペースに移動しようとしています。拒否または許可できます。", + "targetTab": "対象タブ:", + "autoDeny": "{{count}} 秒で自動的に拒否されます", + "deny": "拒否", + "allow": "許可", + "notificationTitle": "BrowserSkill:Agent がタブの借用を求めています", + "notificationBody": "ブラウザ上で「{{tabTitle}}」の借用を許可または拒否してください。" + }, + "helpRequest": { + "title": "Agent があなたの操作を求めています", + "compactStatus": "Agent はこのステップの完了を待っています", + "noteLabel": "Agent へのメモ(任意)", + "notePlaceholder": "Agent へのメモを入力(任意)", + "continue": "完了して操作を戻す", + "cancel": "リクエストをキャンセル", + "collapse": "折りたたむ", + "expand": "展開する", + "dragHandle": "ドラッグして移動", + "notificationTitle": "BrowserSkill:Agent があなたの操作を求めています" + }, + "recordOverlay": { + "recording": "操作を記録しています", + "finish": "完了" + } +} diff --git a/packages/i18n/src/locales/ko/common.json b/packages/i18n/src/locales/ko/common.json new file mode 100644 index 00000000..80f555ec --- /dev/null +++ b/packages/i18n/src/locales/ko/common.json @@ -0,0 +1,6 @@ +{ + "app": { + "name": "browser-skill", + "description": "bsk CLI로 AI 에이전트가 로그인된 브라우저를 다루게 합니다" + } +} diff --git a/packages/i18n/src/locales/ko/extension.json b/packages/i18n/src/locales/ko/extension.json new file mode 100644 index 00000000..350bc48c --- /dev/null +++ b/packages/i18n/src/locales/ko/extension.json @@ -0,0 +1,94 @@ +{ + "popup": { + "brandCategory": "Browser Automation", + "brandName": "BrowserSkill", + "stateLabel": { + "disconnected": "연결 안 됨", + "connecting": "연결 중…", + "connected": "연결됨", + "version_skew": "연결됨", + "disabled": "연결 꺼짐" + }, + "stateDetail": { + "disconnected": "BrowserSkill을 열어 연결하세요.", + "connecting": "BrowserSkill에 연결하는 중…", + "connected": "자동화 작업을 받을 수 있습니다.", + "version_skew": "프로토콜 버전이 다릅니다. 업그레이드해 주세요." + }, + "stateBadge": { + "disconnected": "Standby", + "connecting": "Igniting", + "connected": "Ready", + "version_skew": "업그레이드 가능", + "disabled": "Off" + }, + "connectionToggleTitle": "BrowserSkill 연결", + "controlHintsToggleTitle": "제어 표시", + "controlHintsToggleHint": "에이전트가 페이지를 제어할 때 상태 표시와 주황색 강조를 보여 줍니다.", + "controlHintsInfoLabel": "제어 표시 안내", + "versionSkewWarning": "확장 프로토콜 v{{extensionProtocol}}, CLI 프로토콜 v{{cliProtocol}}. 버전이 다르므로 업그레이드해 주세요.", + "upgradeAvailable": "업그레이드 가능", + "launcher": { + "title": "빠른 실행" + }, + "back": "뒤로", + "runtimeTitle": "런타임", + "daemonVersion": "daemon v{{version}}", + "extensionVersion": "확장 v{{version}}", + "extensionVersionHint": "확장 프로그램 버전", + "daemonVersionHint": "bsk 버전", + "protocolVersion": "프로토콜 v{{version}}", + "extensionProtocol": "확장 프로토콜 v{{version}}", + "daemonProtocol": "daemon 프로토콜 v{{version}}", + "instanceTitle": "인스턴스 ID", + "instanceDescription": "이 브라우저의 고유 연결 식별자", + "copy": "복사", + "copied": "복사됨", + "copyInstanceId": "인스턴스 ID 복사", + "record": { + "sectionTitle": "동작 기록", + "cardDesc": "에이전트가 참고할 수 있도록 동작을 기록합니다", + "sectionHint": "기록 안내 문구를 생성합니다. 에이전트에 전달하면 사용자의 동작 기록을 시작할 수 있고, 에이전트는 생성된 trace 번들을 참고해 비슷한 작업을 자동으로 수행할 수 있습니다.", + "topicLabel": "주제 (선택)", + "topicPlaceholder": "예: 글 발행하기", + "startUrlLabel": "시작 URL (선택)", + "startUrlPlaceholder": "https://…", + "copyButton": "기록 안내 복사", + "copied": "복사됨", + "hintDisconnected": "연결 후 사용 가능", + "promptTemplate": "BrowserSkill의 `bsk` CLI로 내가 수행할 브라우저 동작을 기록하세요.\n\n단계:\n1. 실행: {{command}}\n2. 시작을 확인하면 내가 열린 창에서 직접 조작하겠습니다.\n3. 내가 '완료'를 누르면 생성된 `./trace` 번들(`trace.json` + `states/`)을 읽고 내가 한 작업을 요약해 주세요." + } + }, + "controlOverlay": { + "status": "에이전트가 제어 중", + "interrupt": "중단", + "interrupting": "중단 중…" + }, + "borrowConfirmation": { + "title": "탭 대여를 허용할까요?", + "titleInactive": "에이전트가 탭 대여를 요청했습니다", + "body": "AI 에이전트가 이 탭을 작업 공간으로 이동하려고 합니다. 거부하거나 허용할 수 있습니다.", + "targetTab": "대상 탭:", + "autoDeny": "{{count}}초 후 자동 거부", + "deny": "거부", + "allow": "허용", + "notificationTitle": "BrowserSkill: 에이전트가 탭 대여를 요청했습니다", + "notificationBody": "브라우저에서 '{{tabTitle}}' 대여를 허용하거나 거부하세요." + }, + "helpRequest": { + "title": "에이전트에 도움이 필요합니다", + "compactStatus": "에이전트가 이 단계가 끝나기를 기다리고 있습니다", + "noteLabel": "에이전트에게 보낼 메모 (선택)", + "notePlaceholder": "에이전트에게 메모 남기기 (선택)", + "continue": "완료하고 제어권 반환", + "cancel": "요청 취소", + "collapse": "접기", + "expand": "펼치기", + "dragHandle": "드래그하여 이동", + "notificationTitle": "BrowserSkill: 에이전트에 도움이 필요합니다" + }, + "recordOverlay": { + "recording": "동작을 기록하는 중", + "finish": "완료" + } +} diff --git a/packages/i18n/src/locales/zh-TW/common.json b/packages/i18n/src/locales/zh-TW/common.json new file mode 100644 index 00000000..188eba61 --- /dev/null +++ b/packages/i18n/src/locales/zh-TW/common.json @@ -0,0 +1,6 @@ +{ + "app": { + "name": "browser-skill", + "description": "透過 bsk CLI 讓 AI Agent 驅動你已登入的瀏覽器" + } +} diff --git a/packages/i18n/src/locales/zh-TW/extension.json b/packages/i18n/src/locales/zh-TW/extension.json new file mode 100644 index 00000000..0e378991 --- /dev/null +++ b/packages/i18n/src/locales/zh-TW/extension.json @@ -0,0 +1,94 @@ +{ + "popup": { + "brandCategory": "Browser Automation", + "brandName": "BrowserSkill", + "stateLabel": { + "disconnected": "未連線", + "connecting": "連線中…", + "connected": "已連線", + "version_skew": "已連線", + "disabled": "連線已關閉" + }, + "stateDetail": { + "disconnected": "請先開啟 BrowserSkill。", + "connecting": "正在連線 BrowserSkill…", + "connected": "可接收自動化任務。", + "version_skew": "通訊協定版本不同,請及時升級。" + }, + "stateBadge": { + "disconnected": "Standby", + "connecting": "Igniting", + "connected": "Ready", + "version_skew": "可升級", + "disabled": "Off" + }, + "connectionToggleTitle": "BrowserSkill 連線", + "controlHintsToggleTitle": "控制提示", + "controlHintsToggleHint": "Agent 控制頁面時顯示提示條與橘色光暈。", + "controlHintsInfoLabel": "控制提示說明", + "versionSkewWarning": "擴充功能通訊協定 v{{extensionProtocol}},CLI 通訊協定 v{{cliProtocol}}。版本不同,請及時升級。", + "upgradeAvailable": "可升級", + "launcher": { + "title": "快捷功能" + }, + "back": "返回", + "runtimeTitle": "執行環境版本", + "daemonVersion": "daemon v{{version}}", + "extensionVersion": "擴充功能 v{{version}}", + "extensionVersionHint": "擴充功能版本", + "daemonVersionHint": "bsk 版本", + "protocolVersion": "通訊協定 v{{version}}", + "extensionProtocol": "擴充功能通訊協定 v{{version}}", + "daemonProtocol": "daemon 通訊協定 v{{version}}", + "instanceTitle": "執行個體 ID", + "instanceDescription": "此瀏覽器的唯一連線識別碼", + "copy": "複製", + "copied": "已複製", + "copyInstanceId": "複製執行個體 ID", + "record": { + "sectionTitle": "操作錄製", + "cardDesc": "錄製你的操作,供 Agent 參考", + "sectionHint": "產生一段錄製指令,發送給 Agent 後即可啟動擴充功能錄製你的操作,之後 Agent 可參考產生的 trace 目錄包自動完成類似任務。", + "topicLabel": "操作主題(選填)", + "topicPlaceholder": "例如:發布一篇文章", + "startUrlLabel": "起始網址(選填)", + "startUrlPlaceholder": "https://…", + "copyButton": "複製錄製指令", + "copied": "已複製", + "hintDisconnected": "連線後可用", + "promptTemplate": "用 BrowserSkill 的 `bsk` CLI 錄製我接下來的瀏覽器操作。\n\n步驟:\n1. 執行:{{command}}\n2. 你確認已開始後,我會在開啟的視窗中自行操作。\n3. 我點選「結束」後,讀取產生的 `./trace` 目錄包(`trace.json` + `states/`),摘要我做了什麼。" + } + }, + "controlOverlay": { + "status": "Agent 正在控制", + "interrupt": "中斷", + "interrupting": "中斷中…" + }, + "borrowConfirmation": { + "title": "允許借用分頁?", + "titleInactive": "Agent 請求借用分頁", + "body": "AI Agent 希望將此分頁移至其工作區。你可以拒絕或允許借用。", + "targetTab": "目標分頁:", + "autoDeny": "{{count}} 秒後自動拒絕", + "deny": "拒絕", + "allow": "允許", + "notificationTitle": "BrowserSkill:Agent 請求借用分頁", + "notificationBody": "請在瀏覽器中允許或拒絕對「{{tabTitle}}」的借用。" + }, + "helpRequest": { + "title": "Agent 需要你的協助", + "compactStatus": "Agent 仍在等待你完成此步驟", + "noteLabel": "給 Agent 的備註(選填)", + "notePlaceholder": "給 Agent 留言(選填)", + "continue": "完成並交還控制權", + "cancel": "取消請求", + "collapse": "摺疊", + "expand": "展開", + "dragHandle": "拖曳以移動", + "notificationTitle": "BrowserSkill:Agent 需要你的協助" + }, + "recordOverlay": { + "recording": "正在錄製你的操作", + "finish": "結束" + } +} diff --git a/packages/i18n/tests/locale-resolution.test.ts b/packages/i18n/tests/locale-resolution.test.ts index 2949c702..1dd33d3c 100644 --- a/packages/i18n/tests/locale-resolution.test.ts +++ b/packages/i18n/tests/locale-resolution.test.ts @@ -4,6 +4,24 @@ import { getLanguageDetectionOptions, normalizeLanguageCode, } from "../src/chrome-storage-sync"; +import deCommon from "../src/locales/de/common.json"; +import deExtension from "../src/locales/de/extension.json"; +import enUSCommon from "../src/locales/en-US/common.json"; +import enUSExtension from "../src/locales/en-US/extension.json"; +import esCommon from "../src/locales/es/common.json"; +import esExtension from "../src/locales/es/extension.json"; +import frCommon from "../src/locales/fr/common.json"; +import frExtension from "../src/locales/fr/extension.json"; +import itCommon from "../src/locales/it/common.json"; +import itExtension from "../src/locales/it/extension.json"; +import jaCommon from "../src/locales/ja/common.json"; +import jaExtension from "../src/locales/ja/extension.json"; +import koCommon from "../src/locales/ko/common.json"; +import koExtension from "../src/locales/ko/extension.json"; +import zhCNCommon from "../src/locales/zh-CN/common.json"; +import zhCNExtension from "../src/locales/zh-CN/extension.json"; +import zhTWCommon from "../src/locales/zh-TW/common.json"; +import zhTWExtension from "../src/locales/zh-TW/extension.json"; /** * Locks the locale-resolution table from issue #168: the UI must follow the @@ -36,18 +54,34 @@ describe("normalizeLanguageCode", () => { ["zh-Hant-TW", "zh-TW"], ["zh-Hant-HK", "zh-TW"], - // Traditional Chinese — region subtag. `zh-TW` has no resource yet, so - // i18next falls back to zh-CN via `fallbackLng.zh`. + // Traditional Chinese — region subtag. ["zh-TW", "zh-TW"], ["zh-HK", "zh-TW"], ["zh-MO", "zh-TW"], + // Japanese / Korean — bare language subtags, any region. + ["ja", "ja"], + ["ja-JP", "ja"], + ["ko", "ko"], + ["ko-KR", "ko"], + + // FIGS — bare language subtags, so regional variants share one bundle. + ["fr", "fr"], + ["fr-FR", "fr"], + ["fr-CA", "fr"], + ["it", "it"], + ["it-IT", "it"], + ["de", "de"], + ["de-DE", "de"], + ["de-AT", "de"], + ["es", "es"], + ["es-ES", "es"], + ["es-MX", "es"], + // No translation shipped — returned unchanged so i18next applies the // `default` fallback (en-US) rather than Chinese. - ["ja", "ja"], - ["ja-JP", "ja-JP"], - ["fr-FR", "fr-FR"], - ["de-DE", "de-DE"], + ["pt-BR", "pt-BR"], + ["ru-RU", "ru-RU"], ]; it.each(cases)("maps %s to %s", (detected, resource) => { @@ -57,6 +91,16 @@ describe("normalizeLanguageCode", () => { it("is case-insensitive", () => { expect(normalizeLanguageCode("EN-us")).toBe("en-US"); expect(normalizeLanguageCode("zh-hant-tw")).toBe("zh-TW"); + expect(normalizeLanguageCode("DE-de")).toBe("de"); + }); + + it("routes every locale it ships to a resource that exists", () => { + const shipped = new Set(["en-US", "zh-CN", "zh-TW", "ja", "ko", "fr", "it", "de", "es"]); + for (const [detected, resource] of cases) { + if (shipped.has(resource)) { + expect(shipped, `${detected} → ${resource}`).toContain(resource); + } + } }); }); @@ -86,10 +130,95 @@ describe("getLanguageDetectionOptions", () => { const { convertDetectedLanguage } = getLanguageDetectionOptions(); expect(convertDetectedLanguage("en-GB")).toBe("en-US"); expect(convertDetectedLanguage("zh-TW")).toBe("zh-TW"); - expect(convertDetectedLanguage("ja-JP")).toBe("ja-JP"); + expect(convertDetectedLanguage("ja-JP")).toBe("ja"); }); it("does not write to page-origin storage", () => { expect(getLanguageDetectionOptions().caches).toEqual([]); }); }); + +/** + * Structural checks over the shipped bundles. These use `import.meta.glob`, so + * a newly added locale is validated automatically — the point is to catch + * missing keys or dropped `{{placeholders}}` before they reach a release. + */ +describe("locale bundles", () => { + const locales = { + "en-US": { common: enUSCommon, extension: enUSExtension }, + "zh-CN": { common: zhCNCommon, extension: zhCNExtension }, + "zh-TW": { common: zhTWCommon, extension: zhTWExtension }, + ja: { common: jaCommon, extension: jaExtension }, + ko: { common: koCommon, extension: koExtension }, + fr: { common: frCommon, extension: frExtension }, + it: { common: itCommon, extension: itExtension }, + de: { common: deCommon, extension: deExtension }, + es: { common: esCommon, extension: esExtension }, + } as Record; + + const flatten = (value: unknown, prefix = ""): Record => { + const out: Record = {}; + if (typeof value !== "object" || value === null) { + return out; + } + for (const [key, entry] of Object.entries(value as Record)) { + const path = prefix ? `${prefix}.${key}` : key; + if (typeof entry === "object" && entry !== null) { + Object.assign(out, flatten(entry, path)); + } else { + out[path] = String(entry); + } + } + return out; + }; + + const placeholdersOf = (text: string): string[] => + [...text.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]).sort(); + + const EXPECTED_LOCALES = ["de", "en-US", "es", "fr", "it", "ja", "ko", "zh-CN", "zh-TW"]; + + it("ships exactly the locales we expect", () => { + expect(Object.keys(locales).sort()).toEqual(EXPECTED_LOCALES); + }); + + it.each(EXPECTED_LOCALES)("%s defines both namespaces", (locale) => { + expect(locales[locale]?.common, `${locale}/common.json`).toBeDefined(); + expect(locales[locale]?.extension, `${locale}/extension.json`).toBeDefined(); + }); + + // Merge both namespaces so keys read as `app.name`, `popup.copy`, … + const bundleStrings = (locale: string): Record => ({ + ...flatten(locales[locale]?.common), + ...flatten(locales[locale]?.extension), + }); + + const reference = bundleStrings("en-US"); + const referenceKeys = Object.keys(reference).sort(); + + it.each(EXPECTED_LOCALES)("%s has the same keys as en-US", (locale) => { + expect(Object.keys(bundleStrings(locale)).sort()).toEqual(referenceKeys); + }); + + it.each(EXPECTED_LOCALES)("%s keeps the en-US placeholders intact", (locale) => { + const strings = bundleStrings(locale); + // A dropped {{var}} renders as a blank gap in the UI, so every key must + // carry exactly the interpolation slots English does. + for (const [key, english] of Object.entries(reference)) { + expect(placeholdersOf(strings[key]), `${locale}:${key}`).toEqual(placeholdersOf(english)); + } + }); + + it.each(EXPECTED_LOCALES)("%s leaves no key empty", (locale) => { + for (const [key, value] of Object.entries(bundleStrings(locale))) { + expect(value.trim(), `${locale}:${key}`).not.toBe(""); + } + }); + + it("never translates the product or brand name", () => { + for (const locale of EXPECTED_LOCALES) { + const strings = bundleStrings(locale); + expect(strings["app.name"], locale).toBe("browser-skill"); + expect(strings["popup.brandName"], locale).toBe("BrowserSkill"); + } + }); +});