diff --git a/browser-extensions/CHANGELOG.md b/browser-extensions/CHANGELOG.md index 2cc7b4a..941b431 100644 --- a/browser-extensions/CHANGELOG.md +++ b/browser-extensions/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to the shrtnr browser extensions are documented in this file Repairs the extension in real browsers, adds the parity features a shortener extension is expected to have, and hardens permissions. No new permissions. +- **Simplified Chinese.** The popup and the options page follow the browser's language, and Chinese joins English, Indonesian and Swedish so the extension's set matches the admin app's. A browser reporting `zh-TW` or `zh-HK` reads Simplified: the extension carries one Chinese table, and it has no picker to choose another. + - **Every request works again.** The lockfile pinned `@oddbit/shrtnr` 1.1.0, which calls `fetch` with the client object as receiver. Browsers reject that with `Illegal invocation`, the SDK reported it as a network failure, and the popup and the connection test showed "Can't reach your shrtnr" for every deployment. Verified in Chromium: zero requests left the browser. The floor is now 1.2.1, which also restores the `X-Client` header so links show as created via the SDK. - **Custom slug** field in the popup. Adds the slug to the link just created, swaps the short URL, copies it, and re-targets the QR code at the new slug. 409 shows "already taken" inline; a slug that breaks the rule is rejected before any request. - **Recent** list of the last five links shortened from this browser, kept on the device in `chrome.storage.local`. diff --git a/browser-extensions/README.md b/browser-extensions/README.md index 29cd123..d976dfa 100644 --- a/browser-extensions/README.md +++ b/browser-extensions/README.md @@ -88,7 +88,7 @@ The test suite covers: - `storage.ts`: get / set / clear / onConfigChange semantics, normalization, validation rejections - `recent.ts`: ordering, dedupe by link id, size cap, malformed entries, missing or failing local storage area - `api.ts`: client construction, internal-page guard, error-status to category mapping (including 409), slug validation and add, QR fetch with and without a slug, connection test -- `i18n`: translation lookup, fallback to en, placeholder interpolation, key parity across all three languages +- `i18n`: translation lookup, fallback to en, placeholder interpolation, key parity across every supported language, browser-language detection including script variants - `Popup.tsx`: every state in the state machine (loading, not-configured, success, error categories, recovery actions, QR toggle caching, visible QR errors, custom slug flow, recent list) - `Options.tsx`: CTA banner visibility rule, connection test rendering including the create-only note, save flow with permission request and revocation of the previous origin @@ -161,7 +161,7 @@ Before tagging, run through the checklist in [`RELEASE.md`](RELEASE.md). ## i18n -Three languages, mirroring the admin app: English, Indonesian, Swedish. English is the source of truth and the fallback. Add a key to `src/i18n/en.ts` first; TypeScript will fail to compile until `id.ts` and `sv.ts` add the same key (the `Translations = typeof en` type is the contract). +The same languages as the admin app. `src/i18n/index.ts` lists them in `SUPPORTED_LANGUAGES`, which is where a new one is registered: the table map beside it is typed over that list, so a language without a table fails the typecheck. The language is the browser's, read from `navigator.language` by its primary subtag; there is no picker in the extension. English is the source of truth and the fallback. Add a key to `src/i18n/en.ts` first; TypeScript fails to compile until every other table adds the same key (the `Translations = typeof en` type is the contract). ## Related diff --git a/browser-extensions/RELEASE.md b/browser-extensions/RELEASE.md index db35e98..bc18d61 100644 --- a/browser-extensions/RELEASE.md +++ b/browser-extensions/RELEASE.md @@ -52,11 +52,13 @@ Firefox 140 or newer (`strict_min_version`), so the data-collection declaration ## 4. Locale check -Switch the OS or browser locale to Indonesian, then to Swedish. Reload the popup and options page. +Switch the OS or browser locale to Indonesian, then Swedish, then Chinese (Simplified). Reload the popup and options page. - [ ] Both surfaces render translated strings. +- [ ] The Chinese labels are not clipped: the popup and the options page are the narrowest surfaces the strings land on. - [ ] Placeholders (`{host}`, `{message}`) interpolate correctly in error messages. - [ ] Fall back to English when the OS locale is something else (e.g. German). +- [ ] A Traditional Chinese locale (`zh-TW`) renders Simplified, which is the documented behavior: the extension carries one Chinese table. ## 5. Bump and tag diff --git a/browser-extensions/src/i18n/index.ts b/browser-extensions/src/i18n/index.ts index 752f49d..e2c90b8 100644 --- a/browser-extensions/src/i18n/index.ts +++ b/browser-extensions/src/i18n/index.ts @@ -4,21 +4,29 @@ import en from "./en"; import id from "./id"; import sv from "./sv"; +import zh from "./zh"; import type { TranslationKey, Translations } from "./types"; export type { TranslationKey, Translations }; export type TranslateFn = (key: TranslationKey, params?: Record) => string; export const DEFAULT_LANGUAGE = "en"; -export const SUPPORTED_LANGUAGES = ["en", "id", "sv"] as const; +export const SUPPORTED_LANGUAGES = ["en", "id", "sv", "zh"] as const; export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]; -const translations: Record = { en, id, sv }; +const translations: Record = { en, id, sv, zh }; export function isSupportedLanguage(lang: string): lang is SupportedLanguage { return (SUPPORTED_LANGUAGES as readonly string[]).includes(lang); } +/** + * The browser's language decides, by its primary subtag: "zh-CN", "zh-SG" and + * "zh-Hans" all resolve to "zh". A script variant this set does not carry, + * "zh-TW" or "zh-HK", also resolves to "zh" and reads the Simplified table. + * A Traditional locale would need its own entry here and would have to be + * matched before the primary subtag is taken, which this does not do. + */ export function detectLanguage(): SupportedLanguage { const raw = typeof navigator !== "undefined" ? (navigator.language || "en") : "en"; const primary = raw.split("-")[0]?.toLowerCase() ?? "en"; diff --git a/browser-extensions/src/i18n/zh.ts b/browser-extensions/src/i18n/zh.ts new file mode 100644 index 0000000..66e1935 --- /dev/null +++ b/browser-extensions/src/i18n/zh.ts @@ -0,0 +1,94 @@ +// Copyright 2026 Oddbit (https://oddbit.id) +// SPDX-License-Identifier: Apache-2.0 + +import type { Translations } from "./types"; + +const zh: Translations = { + "_lang": "zh", + + // Brand + "brand.name": "shrtnr", + "brand.tagline": "自托管短链接服务", + + // Popup: generic + "popup.loading": "正在缩短...", + "popup.shortUrlLabel": "短链接", + "popup.copy": "复制", + "popup.copied": "已复制", + "popup.qrShow": "显示二维码", + "popup.qrHide": "隐藏二维码", + "popup.qrLoading": "正在生成二维码...", + "popup.qrAlt": "短链接的二维码", + "popup.slug.label": "自定义短码", + "popup.slug.placeholder": "wo-de-lianjie", + "popup.slug.add": "添加短码", + "popup.slug.adding": "正在添加...", + "popup.slug.help": "可选。只能使用字母、数字和连字符。添加后的短码会立即复制。", + "popup.recent.heading": "此浏览器的最近记录", + "popup.viewInAdmin": "在管理后台查看", + "popup.openSettings": "设置", + "popup.retry": "重试", + + // Popup: not configured + "popup.notConfigured.heading": "设置 shrtnr", + "popup.notConfigured.body": "shrtnr 需要自托管。将此扩展指向你的服务器即可开始缩短链接。", + + // Form + "form.baseUrl.label": "服务器地址", + "form.baseUrl.placeholder": "https://shrtnr.example.com", + "form.baseUrl.help": "部署 shrtnr Worker 的域名。", + "form.apiKey.label": "API 密钥", + "form.apiKey.placeholder": "sk_...", + "form.apiKey.help": "在管理后台的 API 密钥页面创建一个。", + "form.test": "测试连接", + "form.save": "保存", + "form.cancel": "取消", + "form.testing": "正在测试...", + "form.testOk": "已连接", + "form.testOkCreateOnly": "已连接。该密钥只能创建链接、不能读取链接,因此二维码需要具备读取权限的密钥。", + "form.saving": "正在保存...", + "form.saved": "已保存", + + // CTA + "cta.heading": "还没有自己的 shrtnr?", + "cta.body": "在 Cloudflare 上一键部署免费实例。免费套餐,无需信用卡。", + "cta.button": "免费部署", + + // Errors (visible to users) + "error.internalPage": "shrtnr 无法缩短浏览器内部页面。", + "error.unparseable": "无法读取此标签页的网址。", + "error.network": "无法连接 {host} 上的 shrtnr。请检查地址或网络。", + "error.unauthorized": "你的 API 密钥被拒绝。请在设置中更新。", + "error.forbidden": "该 API 密钥不允许创建链接。", + "error.notFound": "在 {host} 上找不到 shrtnr API。是不是域名写错了?", + "error.conflict": "该短码已被占用。换一个试试。", + "error.slugInvalid": "短码只能使用字母、数字和连字符,且不能以连字符开头或结尾。", + "error.qrForbidden": "该 API 密钥无法读取链接,因此无法获取二维码。请使用具备读取权限的密钥。", + "error.qrFailed": "无法获取二维码。请重试。", + "error.rateLimited": "请求过于频繁。请稍后重试。", + "error.server": "你的 shrtnr 服务器返回了错误。", + "error.validation": "{message}", + "error.invalidUrl": "这不是有效的网址。请使用完整地址,例如 https://shrtnr.example.com。", + "error.saveFailed": "无法保存设置。请重试。", + "error.clipboard": "复制失败。请选中上方链接手动复制。", + "error.permissionDenied": "shrtnr 需要访问 {host} 的权限。请再次点击保存并允许。", + "error.permissionDeniedTest": "shrtnr 需要访问 {host} 的权限。请再次点击测试并允许。", + + // Options page + "options.title": "shrtnr 设置", + "options.subtitle": "将此扩展连接到你的 shrtnr 部署。", + "options.section.connection": "连接", + "options.section.connection.body": "这些值保存在浏览器的同步设置中,绝不会发送给 Oddbit。", + "options.section.connection.scope": "只有创建权限的密钥就足以缩短链接。需要二维码时再加上读取权限。", + "options.section.shortcut": "键盘快捷键", + "options.section.shortcut.body": "{shortcut} 会打开弹窗并缩短当前标签页。可在浏览器的扩展快捷键页面修改。", + "options.section.shortcut.unassigned": "尚未分配快捷键。建议的组合可能已被其他扩展占用。请在浏览器的扩展快捷键页面分配。", + "options.section.about": "关于", + "options.section.about.body": "shrtnr 是开源且自托管的项目。源码:github.com/oddbit/shrtnr。", + "options.section.about.website": "oddbit.id", + "options.section.about.version": "版本 {version}", + + // Footer +}; + +export default zh; diff --git a/browser-extensions/tests/i18n.test.ts b/browser-extensions/tests/i18n.test.ts index 73dcd46..3d69fa3 100644 --- a/browser-extensions/tests/i18n.test.ts +++ b/browser-extensions/tests/i18n.test.ts @@ -3,8 +3,6 @@ import { describe, it, expect } from "vitest"; import en from "../src/i18n/en"; -import id from "../src/i18n/id"; -import sv from "../src/i18n/sv"; import { createTranslateFn, detectLanguage, @@ -14,6 +12,18 @@ import { DEFAULT_LANGUAGE, } from "../src/i18n"; +/** + * Every supported language except the fallback, derived once here: the key + * and placeholder parity suites both iterate it, so a change to the default + * language or to the exclusion rule lands in one place. + */ +const translated = SUPPORTED_LANGUAGES.filter((lang) => lang !== DEFAULT_LANGUAGE); + +/** Sets navigator.language for one case. The global beforeEach re-pins it. */ +function browserLanguage(language: string): void { + Object.defineProperty(globalThis.navigator, "language", { configurable: true, value: language }); +} + describe("i18n.createTranslateFn", () => { it("returns en strings for en", () => { const t = createTranslateFn("en"); @@ -31,6 +41,12 @@ describe("i18n.createTranslateFn", () => { expect(t("popup.copy")).toBe("Kopiera"); }); + it("returns zh strings for zh", () => { + // "复制" only comes from the zh table: a fallback would render "Copy". + const t = createTranslateFn("zh"); + expect(t("popup.copy")).toBe("复制"); + }); + it("falls back to en for unknown languages", () => { const t = createTranslateFn("xx"); expect(t("popup.copy")).toBe("Copy"); @@ -46,24 +62,22 @@ describe("i18n.createTranslateFn", () => { expect(t("error.validation", { message: "URL too long" })).toBe("URL too long"); }); - it("localizes the invalid-URL error instead of passing English through", () => { - // error.validation is a "{message}" passthrough for server-supplied text. - // A client-side URL parse failure must carry its own localized key. - const enText = createTranslateFn("en")("error.invalidUrl"); - const idText = createTranslateFn("id")("error.invalidUrl"); - const svText = createTranslateFn("sv")("error.invalidUrl"); - expect(enText).not.toBe("error.invalidUrl"); - expect(idText).not.toBe(enText); - expect(svText).not.toBe(enText); - }); + it.each(SUPPORTED_LANGUAGES)( + "localizes the invalid-URL error instead of passing English through (%s)", + (lang) => { + // error.validation is a "{message}" passthrough for server-supplied + // text. A client-side URL parse failure must carry its own localized + // key, in every language the extension offers. + const text = createTranslateFn(lang)("error.invalidUrl"); + expect(text).not.toBe("error.invalidUrl"); + if (lang !== DEFAULT_LANGUAGE) expect(text).not.toBe(en["error.invalidUrl"]); + }, + ); - it("localizes the save-failure message", () => { - const enText = createTranslateFn("en")("error.saveFailed"); - const idText = createTranslateFn("id")("error.saveFailed"); - const svText = createTranslateFn("sv")("error.saveFailed"); - expect(enText).not.toBe("error.saveFailed"); - expect(idText).not.toBe(enText); - expect(svText).not.toBe(enText); + it.each(SUPPORTED_LANGUAGES)("localizes the save-failure message (%s)", (lang) => { + const text = createTranslateFn(lang)("error.saveFailed"); + expect(text).not.toBe("error.saveFailed"); + if (lang !== DEFAULT_LANGUAGE) expect(text).not.toBe(en["error.saveFailed"]); }); it("returns the key itself when missing in both target and fallback", () => { @@ -74,29 +88,21 @@ describe("i18n.createTranslateFn", () => { }); describe("i18n key parity", () => { - it("every en key exists in id", () => { - const enKeys = Object.keys(en); - const idKeys = new Set(Object.keys(id)); - const missing = enKeys.filter((k) => !idKeys.has(k)); - expect(missing).toEqual([]); - }); + const enKeys = new Set(Object.keys(en)); - it("every en key exists in sv", () => { - const enKeys = Object.keys(en); - const svKeys = new Set(Object.keys(sv)); - const missing = enKeys.filter((k) => !svKeys.has(k)); - expect(missing).toEqual([]); + it.each(translated)("locale %s carries every key en declares", (lang) => { + const keys = new Set(Object.keys(getTranslations(lang))); + expect([...enKeys].filter((k) => !keys.has(k))).toEqual([]); }); - it("id and sv have no extra keys beyond en", () => { - const enKeys = new Set(Object.keys(en)); - expect(Object.keys(id).filter((k) => !enKeys.has(k))).toEqual([]); - expect(Object.keys(sv).filter((k) => !enKeys.has(k))).toEqual([]); + it.each(translated)("locale %s carries no key beyond en", (lang) => { + const keys = new Set(Object.keys(getTranslations(lang))); + expect([...keys].filter((k) => !enKeys.has(k))).toEqual([]); }); it("every value is a non-empty string in every language", () => { - for (const [lang, table] of Object.entries({ en, id, sv })) { - for (const [key, value] of Object.entries(table)) { + for (const lang of SUPPORTED_LANGUAGES) { + for (const [key, value] of Object.entries(getTranslations(lang))) { expect(typeof value, `${lang}.${key} must be a string`).toBe("string"); expect(String(value).trim(), `${lang}.${key} must be non-empty`).not.toBe(""); } @@ -106,14 +112,21 @@ describe("i18n key parity", () => { it("placeholders match between en and translated languages", () => { const placeholderRe = /\{[^}]+\}/g; for (const key of Object.keys(en) as Array) { - const enValue = en[key]; - const idValue = id[key]; - const svValue = sv[key]; - const enPlaceholders = (enValue.match(placeholderRe) ?? []).sort(); - const idPlaceholders = (idValue.match(placeholderRe) ?? []).sort(); - const svPlaceholders = (svValue.match(placeholderRe) ?? []).sort(); - expect(idPlaceholders, `id.${key} placeholders must match en`).toEqual(enPlaceholders); - expect(svPlaceholders, `sv.${key} placeholders must match en`).toEqual(enPlaceholders); + const enPlaceholders = (en[key].match(placeholderRe) ?? []).sort(); + for (const lang of translated) { + const value = getTranslations(lang)[key]; + expect( + (value.match(placeholderRe) ?? []).sort(), + `${lang}.${key} placeholders must match en`, + ).toEqual(enPlaceholders); + } + } + }); + + it("every table declares its own code in _lang", () => { + // Catches a table that was copied from another locale and never renamed. + for (const lang of SUPPORTED_LANGUAGES) { + expect(getTranslations(lang)["_lang"], lang).toBe(lang); } }); }); @@ -123,18 +136,58 @@ describe("i18n.detectLanguage", () => { const detected = detectLanguage(); expect(SUPPORTED_LANGUAGES).toContain(detected); }); + + it("reads a regional variant by its primary subtag", () => { + for (const [reported, expected] of [ + ["en-GB", "en"], + ["id-ID", "id"], + ["sv-SE", "sv"], + ["zh-CN", "zh"], + ["zh-SG", "zh"], + ["ZH-cn", "zh"], + ] as const) { + browserLanguage(reported); + expect(detectLanguage(), reported).toBe(expected); + } + }); + + it("resolves a Chinese script variant to Simplified", () => { + // The extension carries one Chinese table, so a Traditional reader gets + // Simplified rather than English. Pinned so the choice is deliberate: + // a zh-Hant table would have to be matched before the primary subtag is + // taken, which detectLanguage() does not do today. + for (const reported of ["zh-TW", "zh-HK", "zh-Hant"]) { + browserLanguage(reported); + expect(detectLanguage(), reported).toBe("zh"); + } + }); + + it("falls back to English for a language the extension does not carry", () => { + for (const reported of ["fr-FR", "de", "ja-JP", ""]) { + browserLanguage(reported); + expect(detectLanguage(), `reported ${JSON.stringify(reported)}`).toBe(DEFAULT_LANGUAGE); + } + }); }); describe("i18n.isSupportedLanguage", () => { - it("accepts en, id, sv", () => { - expect(isSupportedLanguage("en")).toBe(true); - expect(isSupportedLanguage("id")).toBe(true); - expect(isSupportedLanguage("sv")).toBe(true); + it("accepts every supported language", () => { + for (const lang of SUPPORTED_LANGUAGES) { + expect(isSupportedLanguage(lang), lang).toBe(true); + } + }); + + it("carries exactly the languages it declares", () => { + expect([...SUPPORTED_LANGUAGES]).toEqual(["en", "id", "sv", "zh"]); }); + it("rejects everything else", () => { expect(isSupportedLanguage("fr")).toBe(false); expect(isSupportedLanguage("")).toBe(false); expect(isSupportedLanguage("EN")).toBe(false); + // A regional variant is not a language code of its own: detectLanguage() + // reduces it to the primary subtag before asking. + expect(isSupportedLanguage("zh-CN")).toBe(false); }); }); @@ -142,8 +195,10 @@ describe("i18n.getTranslations", () => { it("returns en for unknown lang", () => { expect(getTranslations("xx")).toBe(en); }); - it("returns id for id", () => { - expect(getTranslations("id")).toBe(id); + it("returns the table the language names", () => { + for (const lang of SUPPORTED_LANGUAGES) { + expect(getTranslations(lang)["_lang"], lang).toBe(lang); + } }); it("DEFAULT_LANGUAGE is en", () => { expect(DEFAULT_LANGUAGE).toBe("en"); diff --git a/browser-extensions/tests/popup.test.tsx b/browser-extensions/tests/popup.test.tsx index 1bee1a5..3d80b9d 100644 --- a/browser-extensions/tests/popup.test.tsx +++ b/browser-extensions/tests/popup.test.tsx @@ -48,6 +48,19 @@ describe("Popup — not configured", () => { expect(link.href).toContain("oddb.it/shrtnr-deploy-ext"); }); }); + + it("renders in Simplified Chinese when the browser reports zh-CN", async () => { + // The language comes from the browser, with no picker in the extension, so + // this is the whole path from navigator.language to the strings on screen. + // Every other case in this file runs against the en-US pin in setup.ts. + Object.defineProperty(globalThis.navigator, "language", { configurable: true, value: "zh-CN" }); + await renderPopup(); + await waitFor(() => { + expect(screen.getByRole("button", { name: "保存" })).toBeTruthy(); + }); + expect(screen.getByRole("button", { name: "测试连接" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /save/i })).toBeNull(); + }); }); describe("Popup — success", () => {