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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ The rest of the shortener:
- **Labels from the page title.** A link created without a label gets the destination page's title fetched in the background, with private and internal hosts refused.
- **QR codes** as SVG for any slug, from the admin UI, the API, the SDKs and MCP. Scans are tracked separately from link clicks.
- **Edge redirects.** Slug lookups are cached in Workers KV in front of D1, and click recording runs after the redirect is sent.
- **Admin dashboard** in English, Indonesian and Swedish, with three themes, per-link and per-bundle analytics, API key management and settings.
- **Admin dashboard** in four languages (see [`src/i18n/`](src/i18n/) for the set), with three themes, per-link and per-bundle analytics, API key management and settings.
- **Browser extension** for Chrome and Firefox that shortens the current tab against your own deployment. Source and store status in [browser-extensions/README.md](browser-extensions/README.md).
- **One-click deploy** with automatic provisioning of the database and KV namespace, and migrations that the Worker applies on its first request.

Expand Down
32 changes: 27 additions & 5 deletions src/__tests__/handler/admin-assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { SELF } from "cloudflare:test";
import { applyMigrations, resetData } from "../setup";
import { adminStyles } from "../../styles";
import id from "../../i18n/id";
import zh from "../../i18n/zh";

function req(path: string, headers: Record<string, string> = {}): Request {
return new Request(`https://shrtnr.test${path}`, { headers });
Expand All @@ -22,7 +23,7 @@ async function pageHtml(path: string, headers?: Record<string, string>): Promise
}

const STYLESHEET = /<link rel="stylesheet" href="(\/_\/assets\/admin\.[0-9a-f]{8,}\.css)"\s*\/?>/;
const SCRIPT = /<script src="(\/_\/assets\/client\.([a-z]{2})\.[0-9a-f]{8,}\.js)"><\/script>/;
const SCRIPT = /<script src="(\/_\/assets\/client\.([A-Za-z-]+)\.[0-9a-f]{8,}\.js)"><\/script>/;

beforeAll(applyMigrations);
beforeEach(resetData);
Expand Down Expand Up @@ -83,9 +84,21 @@ describe("GET /_/assets/*", () => {
});

it("gives each language its own script URL", async () => {
const en = (await pageHtml("/_/admin/settings")).match(SCRIPT)![1];
const sv = (await pageHtml("/_/admin/settings", { Cookie: "lang=sv" })).match(SCRIPT)![1];
expect(en).not.toBe(sv);
const enUrl = (await pageHtml("/_/admin/settings")).match(SCRIPT)![1];
const svUrl = (await pageHtml("/_/admin/settings", { Cookie: "lang=sv" })).match(SCRIPT)![1];
const zhUrl = (await pageHtml("/_/admin/settings", { Cookie: "lang=zh" })).match(SCRIPT)![1];
expect(enUrl).not.toBe(svUrl);
expect(enUrl).not.toBe(zhUrl);
expect(svUrl).not.toBe(zhUrl);
});

it("serves the Simplified Chinese client script to a lang=zh viewer", async () => {
const html = await pageHtml("/_/admin/settings", { Cookie: "lang=zh" });
const m = html.match(SCRIPT);
expect(m![2]).toBe("zh");
const res = await SELF.fetch(req(m![1]));
expect(res.status).toBe(200);
expect(await res.text()).toContain(zh["client.themeUpdated"]);
});

it("serves this build's content for a hash from another build, uncached, so a deploy window never leaves a page unstyled", async () => {
Expand All @@ -107,7 +120,16 @@ describe("GET /_/assets/*", () => {
});

it("answers the branded 404 for a path that names no asset of this app", async () => {
for (const path of ["/_/assets/client.xx.0000000000000000.js", "/_/assets/vendor.js", "/_/assets/admin.css"]) {
// "zh-Hant" is language-shaped but not registered, so it has to be the
// supported-language check that refuses it, not the shape of the path.
// A locale that ever registers such a code gets the no-store fallback
// during a deploy window instead of this 404.
for (const path of [
"/_/assets/client.xx.0000000000000000.js",
"/_/assets/client.zh-Hant.0000000000000000.js",
"/_/assets/vendor.js",
"/_/assets/admin.css",
]) {
const res = await SELF.fetch(req(path));
expect(res.status, path).toBe(404);
expect(res.headers.get("Content-Type"), path).toContain("text/html");
Expand Down
13 changes: 13 additions & 0 deletions src/__tests__/page/settings-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@ describe("Settings page analytics filter toggles", () => {
});
});

describe("Settings page language picker", () => {

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 new picker option is pinned with a render assertion only. No Playwright step selects zh and asserts the reloaded page renders in Chinese, and the PR ran only yarn test.

CLAUDE.md, Testing: "New admin controls (chip, sort, paginator, selector, row action) get an e2e step that clicks them and asserts the URL and the marked state, alongside the vitest render assertions." The selector gains a new offered choice here and e2e/ has no language-picker step at all (only the perf.spec html lang truthiness check). A dead onchange, a reload that lands on English, or CJK text clipped in the fixed-width chart labels would ship unseen.

@myml myml Oct 14, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You're right that the picker has no browser coverage, and the gap is older than this PR. #language-picker (src/pages/settings.tsx:45) shipped 2026-03-31 in 91c1aaa, the original en/id/sv commit, which touched no e2e/ file because the suite did not exist yet. e2e/ arrived 2026-08-27 in 317339a, the same commit that added the "new admin controls get an e2e step" line to CLAUDE.md. No language has a step today, not only the new one: the only lang assertion in e2e/ is perf.spec.ts:219, expect(a11y.lang).toBeTruthy(), and no commit has ever added a step that drives the picker.

So this PR adds a fourth option to a control that was already uncovered, and it does not reduce coverage. I am not adding the step here.

Driving the picker end to end is e2e-suite work rather than locale work. The spec has to select each offered language, follow the cookie and the PUT /_/admin/api/preferences and the reload that setLanguage (src/client.ts:151) performs, and assert both the rendered language and the marked option. That is a larger change than the locale that prompted it, and the shape is identical for en, id and sv, so it belongs in a change that covers the picker as a control. The same reasoning is why I read the rule as aimed at new controls: this PR adds an option to an existing one.

The part of your comment that is a defect rather than a coverage gap is the axis labels. fmtLabel composes a Latin Mon D / Mon YY string out of the month.* keys, so zh renders 7月 16 and 7月 26 where the locale wants 7月16日 and 2026年7月. That wants a locale-aware Intl.DateTimeFormat, the call fmtBucketDate already makes for tooltips, and I will take it as its own change.

it("offers Simplified Chinese and names the other languages in the viewer's language", async () => {
const res = await SELF.fetch(
new Request("https://shrtnr.test/_/admin/settings", { headers: { Cookie: "lang=zh" } }),
);
const html = await res.text();
expect(html).toMatch(/<option value="zh"[^>]*selected[^>]*>简体中文/);
expect(html).toContain("English · 英语");
expect(html).toContain("Svenska · 瑞典语");
expect(html).toContain("Bahasa Indonesia · 印度尼西亚语");
});
});

describe("Settings page MCP OAuth status row", () => {
it("renders the status as its own row with a decorative icon and the MCP OAuth label", async () => {
// Before this row existed, "Not configured" reached the document only
Expand Down
3 changes: 2 additions & 1 deletion src/__tests__/service/admin-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getAppSettings,
updateAppSettings,
} from "../../services/admin-management";
import { SUPPORTED_LANGUAGES } from "../../i18n";

const TEST_IDENTITY = "test@example.com";

Expand Down Expand Up @@ -223,7 +224,7 @@ describe("settings theme and language validation", () => {
});

it("accepts every supported language", async () => {
for (const lang of ["en", "id", "sv"]) {
for (const lang of SUPPORTED_LANGUAGES) {
const result = await updateAppSettings(env as any, TEST_IDENTITY, { lang });
expect(result.ok).toBe(true);
const settings = await getAppSettings(env as any, TEST_IDENTITY);
Expand Down
16 changes: 16 additions & 0 deletions src/__tests__/unit/big-chart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,20 @@ describe("formatBucketLabel", () => {
expect(out).toContain("2026");
expect(out).toContain("09:00");
});

it("formats a daily bucket with the Chinese month name", () => {
// "7月" only comes from zh ICU data. An English fallback would render
// "Jul", so this pins the locale reaching the formatter.
const out = formatBucketLabel("2026-07-16", "zh");
expect(out).toContain("7月");
expect(out).toContain("16");
expect(out).toContain("2026");
});

it("formats a monthly bucket with the Chinese month name and year only", () => {
const out = formatBucketLabel("2026-07", "zh");
expect(out).toContain("7月");
expect(out).toContain("2026");
expect(out).not.toBe(formatBucketLabel("2026-07-01", "zh"));
});
});
7 changes: 7 additions & 0 deletions src/__tests__/unit/country.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ describe("countryName", () => {
expect(result).toBe("Amerika Serikat");
});

it("should resolve US to a localized name in Simplified Chinese", () => {
// A translated name, not the raw code and not the English fallback, which
// is what a runtime without zh ICU data would return.
const result = countryName("US", "zh");
expect(result).toBe("美国");
});

it("should return something for unknown codes without throwing", () => {
const result = countryName("ZZ", "en");
expect(typeof result).toBe("string");
Expand Down
9 changes: 9 additions & 0 deletions src/__tests__/unit/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ describe("fmtNumber", () => {
expect(out).toMatch(/1.234.567/); // any single separator character
});

it("groups thousands for zh", () => {

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 asserts 1,234,567, the exact string the en-US fallback it claims to guard against would also produce, so it pins nothing about zh. A runtime without zh ICU data falls back to en-US, formats the same output, the test passes, and the comment's "deliberate decision rather than a surprise" never triggers.

Assert something the fallback cannot satisfy, such as new Intl.NumberFormat("zh").resolvedOptions().locale starting with zh, or drop the comment and keep this as a plain output pin.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 88beabb. The case asserts new Intl.NumberFormat("zh").resolvedOptions().locale matches /^zh/ first, then the digits, so a runtime without zh ICU data fails here instead of passing on the fallback output.

// Chinese groups with commas like en, so the output alone cannot tell a
// resolved zh locale from the en-US fallback the runtime uses when it has
// no zh ICU data. Assert the locale the formatter actually resolved, then
// the digits it produced.
expect(new Intl.NumberFormat("zh").resolvedOptions().locale).toMatch(/^zh/);
expect(fmtNumber(1234567, "zh")).toBe("1,234,567");
});

it("formats zero and negative values", () => {
expect(fmtNumber(0, "en")).toBe("0");
expect(fmtNumber(-12345, "en")).toBe("-12,345");
Expand Down
47 changes: 34 additions & 13 deletions src/__tests__/unit/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import {
SUPPORTED_LANGUAGES,
} from "../../i18n";
import en from "../../i18n/en";
import id from "../../i18n/id";
import sv from "../../i18n/sv";

function flattenKeys(obj: unknown, prefix = ""): string[] {
if (obj === null || typeof obj !== "object") return [prefix];
Expand All @@ -25,12 +23,21 @@ function placeholders(value: string): string[] {
return [...(value.match(/\{[a-zA-Z0-9_]+\}/g) ?? [])].sort();
}

/**
* Every supported language except the fallback. Derived once here: the
* placeholder and key-parity suites both iterate it, so a change to the
* default language or to the exclusion rule lands in one place instead of
* two copies that drift apart.
*/
const translated = SUPPORTED_LANGUAGES.filter((lang) => lang !== DEFAULT_LANGUAGE);

describe("i18n", () => {
describe("isSupportedLanguage", () => {
it("returns true for supported languages", () => {
expect(isSupportedLanguage("en")).toBe(true);
expect(isSupportedLanguage("id")).toBe(true);
expect(isSupportedLanguage("sv")).toBe(true);
expect(isSupportedLanguage("zh")).toBe(true);
});

it("returns false for unsupported languages", () => {
Expand All @@ -56,6 +63,11 @@ describe("i18n", () => {
expect(t["nav.dashboard"]).toBe("Översikt");
});

it("returns Simplified Chinese translations for 'zh'", () => {
const t = getTranslations("zh");
expect(t["nav.dashboard"]).toBe("仪表盘");
});

it("falls back to English for unsupported language", () => {
const t = getTranslations("fr");
expect(t["nav.dashboard"]).toBe("Dashboard");
Expand All @@ -65,10 +77,12 @@ describe("i18n", () => {
describe("placeholder parity", () => {
// Translations is typed off en, so a missing key fails the build. Nothing
// types the placeholders inside a value, so a locale can keep the key and
// silently drop the interpolation the key exists for.
it.each([["id", id], ["sv", sv]] as const)("keeps every %s placeholder that en declares", (_name, locale) => {
// silently drop the interpolation the key exists for. Driven off
// SUPPORTED_LANGUAGES so a new locale is covered the day it is registered.
it.each(translated)("keeps every %s placeholder that en declares", (lang) => {
const locale = getTranslations(lang);
for (const key of Object.keys(en) as (keyof typeof en)[]) {
expect(placeholders(locale[key])).toEqual(placeholders(en[key]));
expect(placeholders(locale[key]), `${lang} ${key}`).toEqual(placeholders(en[key]));
}
});
});
Expand Down Expand Up @@ -140,20 +154,18 @@ describe("i18n", () => {
});

it("lists all supported languages", () => {
expect(SUPPORTED_LANGUAGES).toContain("en");
expect(SUPPORTED_LANGUAGES).toContain("id");
expect(SUPPORTED_LANGUAGES).toContain("sv");
// The exact set, not one containment per language: a locale dropped from
// the array used to leave this test green, because its sibling parity
// suites iterate the array itself and would simply run one case fewer.
expect([...SUPPORTED_LANGUAGES]).toEqual(["en", "id", "sv", "zh"]);
});
});

describe("key parity across locales", () => {

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 constants › lists all supported languages test just above (line 150) still checks only en, id and sv, while its sibling isSupportedLanguage test was extended with zh. Someone trimming SUPPORTED_LANGUAGES back to three entries sees that test stay green; only the derived parity tests notice, and they notice by running zero cases for zh rather than by failing. Add expect(SUPPORTED_LANGUAGES).toContain("zh") or assert the exact array.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 88beabb. It asserts [...SUPPORTED_LANGUAGES] equals ["en", "id", "sv", "zh"], so trimming the array fails here rather than leaving the parity cases to run one fewer silently.

const enKeys = new Set(flattenKeys(en));

it.each([
["id", id],
["sv", sv],
])("locale %s has the same key set as en", (_name, locale) => {
const localeKeys = new Set(flattenKeys(locale));
it.each(translated)("locale %s has the same key set as en", (lang) => {
const localeKeys = new Set(flattenKeys(getTranslations(lang)));
const missing = [...enKeys].filter((k) => !localeKeys.has(k));
const extra = [...localeKeys].filter((k) => !enKeys.has(k));
expect({ missing, extra }).toEqual({ missing: [], extra: [] });
Expand All @@ -174,21 +186,30 @@ describe("i18n", () => {
expect(translations["lang.en"]).toBe("English");
expect(translations["lang.id"]).toBe("Bahasa Indonesia");
expect(translations["lang.sv"]).toBe("Svenska");
expect(translations["lang.zh"]).toBe("简体中文");
}
});

it("localized language names differ per locale", () => {
const en = getTranslations("en");
const id = getTranslations("id");
const sv = getTranslations("sv");
const zh = getTranslations("zh");

expect(en["langLocal.id"]).toBe("Indonesian");
expect(id["langLocal.id"]).toBe("Indonesia");
expect(sv["langLocal.id"]).toBe("Indonesiska");
expect(zh["langLocal.id"]).toBe("印度尼西亚语");

expect(en["langLocal.sv"]).toBe("Swedish");
expect(id["langLocal.sv"]).toBe("Swedia");
expect(sv["langLocal.sv"]).toBe("Svenska");
expect(zh["langLocal.sv"]).toBe("瑞典语");

expect(en["langLocal.zh"]).toBe("Chinese (Simplified)");
expect(id["langLocal.zh"]).toBe("Tionghoa (Sederhana)");
expect(sv["langLocal.zh"]).toBe("Kinesiska (förenklad)");
expect(zh["langLocal.zh"]).toBe("简体中文");
});
});
});
15 changes: 12 additions & 3 deletions src/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@
*
* The hash covers the whole body, so it moves with the app version (the
* client script embeds it) and with the translations (one script per
* language). The four assets are fixed for the life of the isolate, so they
* are built once at module init and served from a map.
* language). The asset set is fixed for the life of the isolate, so each
* asset is built once at module init and served from a map: every language
* registered in SUPPORTED_LANGUAGES costs one script built here, whatever
* the code's shape (a two-letter code today, a subtagged one if a locale
* ever needs it).
*
* A deploy reaches colos one at a time, so for a short window a document
* from the new build can request its assets from an isolate on the old one
Expand Down Expand Up @@ -89,7 +92,13 @@ export function adminClientScriptPath(lang: string): string {
}

const STYLESHEET_PATH = /^\/_\/assets\/admin\.[0-9a-f]+\.css$/;
const SCRIPT_PATH = /^\/_\/assets\/client\.([a-z]{2})\.[0-9a-f]+\.js$/;
// The language segment is matched loosely, as any language-shaped code: a
// subtagged code such as "zh-Hant" or "pt-BR" is recognized here even though
// no such locale exists yet, so a build that ever registers one serves the
// no-store fallback during a deploy window instead of a 404.
// isSupportedLanguage below is the gate that decides which codes this build
// actually holds a script for.
const SCRIPT_PATH = /^\/_\/assets\/client\.([A-Za-z-]+)\.[0-9a-f]+\.js$/;

/** The asset another build of this app would have emitted at the path, if the path names one. */
function sameAssetFromThisBuild(path: string): Asset | undefined {
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,11 +406,13 @@ const en = {
"lang.en": "English",
"lang.id": "Bahasa Indonesia",
"lang.sv": "Svenska",
"lang.zh": "简体中文",

// Language names (translated into this locale)
"langLocal.en": "English",
"langLocal.id": "Indonesian",
"langLocal.sv": "Swedish",
"langLocal.zh": "Chinese (Simplified)",
};

export default en;
2 changes: 2 additions & 0 deletions src/i18n/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,11 +410,13 @@ const id: Translations = {
"lang.en": "English",
"lang.id": "Bahasa Indonesia",
"lang.sv": "Svenska",
"lang.zh": "简体中文",

// Language names (translated into this locale)
"langLocal.en": "Inggris",
"langLocal.id": "Indonesia",
"langLocal.sv": "Swedia",
"langLocal.zh": "Tionghoa (Sederhana)",
};

export default id;
9 changes: 7 additions & 2 deletions src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,20 @@ import type { TranslationKey, Translations } from "./types";
import en from "./en";
import id from "./id";
import sv from "./sv";
import zh from "./zh";

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<string, Translations> = { en, id, sv };
// Keyed by the union rather than by string: a language added to
// SUPPORTED_LANGUAGES without a table here fails the typecheck instead of
// serving `undefined` through getTranslations, which would put `var T =
// undefined;` in that language's client script and throw on the first t().
const translations: Record<SupportedLanguage, Translations> = { en, id, sv, zh };

export function isSupportedLanguage(lang: string): lang is SupportedLanguage {
return SUPPORTED_LANGUAGES.includes(lang as SupportedLanguage);
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/sv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,11 +410,13 @@ const sv: Translations = {
"lang.en": "English",
"lang.id": "Bahasa Indonesia",
"lang.sv": "Svenska",
"lang.zh": "简体中文",

// Language names (translated into this locale)
"langLocal.en": "Engelska",
"langLocal.id": "Indonesiska",
"langLocal.sv": "Svenska",
"langLocal.zh": "Kinesiska (förenklad)",
};

export default sv;
Loading