Skip to content

Add Simplified Chinese to the browser extension - #78

Closed
myml wants to merge 2 commits into
oddbit:mainfrom
myml:feat/ext-zh-cn
Closed

myml wants to merge 2 commits into
oddbit:mainfrom
myml:feat/ext-zh-cn

Conversation

@myml

@myml myml commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

What

Follow-up to the review of #71. The admin app gained Simplified Chinese there, and this extension's README promised the admin app's language set, so a browser reporting zh-CN met a Chinese dashboard next to an English popup.

  • src/i18n/zh.ts carries the 69 keys the popup, the form, the error messages and the options page read.
  • zh joins SUPPORTED_LANGUAGES. The table map beside it is typed over that list, so a language registered without a table fails the typecheck rather than resolving to undefined at the first t().
  • The language is the browser's, read by its primary subtag, so zh-CN, zh-SG and zh-Hans all resolve to zh. A script variant the set does not carry, zh-TW or zh-HK, also resolves to zh and reads Simplified: the extension carries one Chinese table and has no picker to choose another. detectLanguage() documents that, and a test pins it as a decision rather than an accident. A Traditional table would have to be matched before the primary subtag is taken, which this does not do.
  • The README no longer enumerates the languages. It points at SUPPORTED_LANGUAGES, which is also where a new locale is registered, so the claim cannot drift from the code.

Tests

  • The parity suites iterate SUPPORTED_LANGUAGES instead of naming id and sv, so this locale and any later one are covered the day they register. The accepts en, id, sv case became every-language acceptance plus an assertion on the exact array, and a new case checks that each table declares its own code in _lang.
  • A new popup case renders with navigator.language set to zh-CN and asserts the Chinese labels, which is the whole path from the browser to the strings on screen. Every other case in that file runs against the en-US pin in tests/setup.ts.
  • Mutating the array to drop zh fails four cases; dropping a placeholder from the zh table fails the placeholder parity case.
  • yarn test: 9 files, 165 tests pass. yarn typecheck: clean. yarn build, scripts/verify-build.mjs: both targets build, 7 referenced files present, 45.1 KB zips.

The Chinese labels are the widest strings the popup and options page carry, so RELEASE.md gains a step that checks them for clipping on those two surfaces before a tag.

myml added 2 commits October 14, 2026 17:22
The admin app gained Simplified Chinese in oddbit#71, and this extension's README
promised the admin app's set, so a browser reporting zh-CN met a Chinese
dashboard beside an English popup. zh.ts carries the 69 keys and joins
SUPPORTED_LANGUAGES, whose table map is typed over the list, so a language
registered without a table fails the typecheck instead of serving undefined.

The language is the browser's, read by its primary subtag, so zh-CN, zh-SG
and zh-Hans all resolve to zh. A script variant the set does not carry,
zh-TW or zh-HK, also resolves to zh and reads Simplified: the extension
carries one Chinese table and has no picker. detectLanguage() now says so,
and a test pins it as a decision rather than an accident.

The parity suites iterate SUPPORTED_LANGUAGES instead of naming id and sv, so
this locale and any later one are covered the day they register, and the
"accepts en, id, sv" case is replaced by every-language acceptance plus an
assertion on the exact array. A new popup case renders with
navigator.language set to zh-CN and asserts the Chinese labels, which is the
whole path from the browser to the strings on screen.

CHANGELOG.md gains the entry under Unreleased, RELEASE.md gains the locale
step and the Chinese clip check on the two narrowest surfaces.
The README enumerated three languages and claimed they mirrored the admin
app, which needed an edit for every locale on either side and read as false
the moment one landed on only one side. It points at SUPPORTED_LANGUAGES
now, which is also where a new locale is registered, and the test summary
line no longer counts the languages it covers.

@DennisAlund DennisAlund left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the Simplified Chinese locale for the browser extension. yarn test (165 cases) and yarn typecheck pass on the head commit. Inline comments cover the findings that land on diff lines.

One finding sits outside the diff hunks, so it goes here.

browser-extensions/src/i18n/index.ts:46: interpolation treats $ sequences in a parameter as replacement patterns. value.replace(regex, String(v)) passes a string replacement, so $&, $', $` and $1 in the value expand instead of printing. error.validation is a bare {message} passthrough for server text (ConfigForm.tsx:144, Popup.tsx:93), so a server message such as Invalid character '$' in slug renders as Invalid character ' in slug. The admin app's createTranslateFn in src/i18n/index.ts already passes a replacer, () => String(v), for this reason. This PR brings the extension's language set in line with the admin app, which makes it the place to carry that fix over too.

* 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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

popup.html and options.html both ship <html lang="en">, and nothing sets document.documentElement.lang from the value this function returns. With zh registered, both surfaces now carry Chinese text under an English language tag.

  • Screen readers pick the voice from lang, so VoiceOver and NVDA read the Chinese labels with an English voice, or skip them.
  • Browsers pick the Han fallback font from lang. On a system whose default CJK font is Japanese or Traditional (a zh-TW reader, whom this PR routes to the Simplified table), Simplified-only glyphs such as 码 and 链 render from a mismatched font.
  • The options tab title stays the hardcoded English shrtnr settings from options.html.

Setting document.documentElement.lang in the popup and options entry points fixes every locale at once. For the zh table, zh-Hans describes the strings on screen better than the browser's own tag. document.title = t("options.title") covers the tab title.

/**
* 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Routing zh-TW, zh-HK and zh-Hant to the Simplified table departs from how the browser resolves extension locales: chrome.i18n treats zh_CN and zh_TW as separate locales and falls back to default_locale rather than crossing scripts. A reader in Taiwan or Hong Kong gets a script they did not choose, and the extension has no picker to undo it.

Deciding the script by the primary subtag also means a later zh-Hant table needs this function rewritten. Matching the full tag first, and sending zh-TW, zh-HK, zh-MO and any zh-Hant* tag to the fallback until a Traditional table exists, keeps both paths open. The case at tests/i18n.test.ts:154 pins the current choice, so it follows whichever way this is decided.

## 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).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"The same languages as the admin app." keeps the cross-package claim the second commit message names as the defect: it reads as false the moment the root src/i18n/index.ts registers a locale the extension lacks, or the reverse, and nothing checks it. Either drop the sentence or back it with a test that imports the admin app's SUPPORTED_LANGUAGES and asserts equality (see the comment on tests/i18n.test.ts:180).

}
});

it("carries exactly the languages it declares", () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion hardcodes the list the README now refuses to enumerate. Every new locale edits it by hand, and it never catches the drift that matters: the extension falling behind the admin app. Both lists live in this repo, and the extension's vitest resolves import { SUPPORTED_LANGUAGES as ADMIN_LANGUAGES } from "../../src/i18n" (checked locally). Asserting [...SUPPORTED_LANGUAGES] equals [...ADMIN_LANGUAGES] pins the README claim and retires the literal.

## 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Root CLAUDE.md, Documentation: "Never enumerate plugins, skills, dependencies, components, or any list backed by a file/folder source of truth. Reference the source directly." This line enumerates the locales SUPPORTED_LANGUAGES holds, the same drift the README edit in this PR removes: the next locale drops out of the release check without anyone noticing. Suggest: "Switch the OS or browser locale to each non-English entry in SUPPORTED_LANGUAGES (src/i18n/index.ts)."

// 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" });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line repeats the browserLanguage() helper the PR adds at tests/i18n.test.ts:23. Exporting one setBrowserLanguage() from tests/setup.ts, beside the en-US pin it overrides, keeps the property descriptor in one place and puts the re-pin contract next to the pin.


// Form
"form.baseUrl.label": "服务器地址",
"form.baseUrl.placeholder": "https://shrtnr.example.com",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

en, id and sv all mark the example host as the reader's own (your-shrtnr, shrtnr-anda, din-shrtnr). This placeholder and the example in error.invalidUrl (line 71) drop that cue, so https://shrtnr.example.com reads as a real host to paste. https://your-shrtnr.example.com, or a pinyin form such as https://ni-de-shrtnr.example.com in the style of popup.slug.placeholder, keeps the tables aligned.

"form.cancel": "取消",
"form.testing": "正在测试...",
"form.testOk": "已连接",
"form.testOkCreateOnly": "已连接。该密钥只能创建链接、不能读取链接,因此二维码需要具备读取权限的密钥。",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 顿号 (、) separates items in a list, not two clauses, so 只能创建链接、不能读取链接 reads as a list of two items. 已连接。该密钥可以创建链接,但不能读取链接,因此二维码需要具备读取权限的密钥。 carries the English contrast ("can create links but not read them").


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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The blank line after this bullet (line 10) turns the whole Unreleased list into a loose list in CommonMark: every item renders wrapped in <p> with paragraph spacing, unlike the 0.1.1 and 0.1.0 sections. Dropping it keeps the list tight.

// 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"]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on translated (line 15) says a change to the default language or the exclusion rule lands in one place, but this case and the save-failure case below re-derive it inline with lang !== DEFAULT_LANGUAGE. Splitting each into it.each(SUPPORTED_LANGUAGES) for the not-the-key assertion and it.each(translated) for the not-English assertion keeps the rule where the comment promises.

@DennisAlund

Copy link
Copy Markdown
Member

Thanks @myml. This work continues in #84, which carries both of your commits (you stay their author) and addresses the review findings on top. Closing this one so the work lives in one place.

@DennisAlund DennisAlund closed this Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants