Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions browser-extensions/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 2 additions & 2 deletions browser-extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion browser-extensions/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 10 additions & 2 deletions browser-extensions/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, string | number>) => 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<SupportedLanguage, Translations> = { en, id, sv };
const translations: Record<SupportedLanguage, Translations> = { 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";
Expand Down
94 changes: 94 additions & 0 deletions browser-extensions/src/i18n/zh.ts
Original file line number Diff line number Diff line change
@@ -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;
155 changes: 105 additions & 50 deletions browser-extensions/tests/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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", () => {
Expand All @@ -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("");
}
Expand All @@ -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<keyof typeof en>) {
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);
}
});
});
Expand All @@ -123,27 +136,69 @@ 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);
});
});

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");
Expand Down
Loading