Files
paseo/packages/plugin/src/client/translations.tsx
T
Mohamed Boudra 30464cf234 Translate plugin content and labels in each app language (#7478)
* feat(website): serve paseo.sh in Simplified Chinese

Every page of the website now has a Simplified Chinese version under
/zh-CN: the interface, docs, blog posts, comparison pages, legal pages,
all changelog releases and the homepage mockup. English URLs are unchanged.

The URL alone decides the language. There is no browser-language redirect
and no stored preference, because either would have to redirect the
unprefixed English URLs that people share and search engines index.
Internal links carry the page's language instead, and hreflang alternates
point Chinese search results at /zh-CN.

Translated headings pin the English anchor with {#id}, so section links
work in both languages. content-translations.test.ts fails when a page
has no translation or a translation's headings drift from the English.
Changelog releases and plugin listings without a translation render
whole in English under a notice, so a release never waits on one.

Plugin manifests and the registry gain optional translations and
OVERVIEW.zh-CN.md fields; older clients and registries ignore them.

* feat(website): replace the native language select with an EN/ZH menu

The header showed an OS-native select whose popup did not match the site.
The trigger now shows the language abbreviated (EN, ZH) and opens a menu in
the app's dropdown style that names each language in itself, with its name
in the page language underneath. Rows are real links to the same page in
that language; arrows, Escape, outside presses and taps behave as a menu
button, and the menu keeps itself on screen when a narrow header pushes the
trigger toward an edge.

Also drops the stray space after the full-width comma in the Chinese
homepage subtitle.

* docs(website): translate main's new docs and 0.12.0 release notes

Main added Keep run workspaces to the schedules docs, rewrote plugin icons
around inline SVG, and promoted 0.12.0 with new entries. Bring the Chinese
pages and changelog in line so no section falls back to English.

* fix(server): leave plugin listing translations to the registry

The daemon rejected manifests whose translations the registry would refuse,
so a plugin main installs could fail to load here. Paseo never reads
translations; the registry validates them at publish. Docs now say which
checks are automated at publish and which are human review.

* docs(plugins): describe the registry's final overview translation discovery

* style(server): format manifest test

* feat(plugins): translate plugin UI in each app language

Keep translation resources and subscriptions inside each installation. Resolve host labels against the presentation locale so live language changes preserve mounted drafts and RPC state. Existing descriptor types retain string defaults for source compatibility.

* test(plugins): dismiss developer chrome in second translation client

* test(app): measure sidebar rows one at a time

After a reload the developer error toast appears while expectVerticalOrder
measures every row at once. Each concurrent boundingBox runs the toast
locator handler, and only one of them resumes, so the poll timed out on
every attempt since #7424. Measuring sequentially keeps one action in flight.

* test(app): return sidebar row measuring to main

#7480 hides the developer toast instead of running a locator handler, so
measuring the rows concurrently no longer races it. Reverts 41ac23016.

* test(app): wait for the compact shell before opening compact Changes

The shell survives the breakpoint, so right after the resize the wide
Explorer could still be visible: the test skipped the Explorer toggle and
the tab then disappeared, or toggled at the wrong time. Wait for the
compact-shell marker, as the composer helpers do since #7424.

* docs(website): translate main's plugin file panel docs
2026-10-21 13:40:46 +07:00

203 lines
7.2 KiB
TypeScript

import {
createContext,
useCallback,
useContext,
useSyncExternalStore,
type ReactNode,
} from "react";
import { createInstance, type i18n } from "i18next";
import type {
PluginMessage,
PluginText,
PluginTranslations,
TranslationKey,
TranslationArguments,
TranslationResource,
TranslationOptions,
} from "../text.js";
type Resources = PluginTranslations["resources"];
interface MessageDefinition {
resources: Resources;
key: string;
options?: TranslationOptions;
}
const messageDefinitions = new WeakMap<PluginMessage, MessageDefinition>();
export function defineTranslations<const Resource extends TranslationResource>(definition: {
resources: Readonly<Record<string, TranslationResource>> & { en: Resource };
}): PluginTranslations<Resource> {
const english = validateResources(definition.resources);
return {
resources: definition.resources,
message: (key, ...args) => {
const source = english.get(key) ?? english.get(`${key}_other`);
if (source === undefined) throw new Error(`Unknown translation key: ${key}`);
const options = args[0];
for (const name of placeholders(source)) {
if (options?.[name] === undefined)
throw new Error(`Missing translation value: ${name} (${key})`);
}
const message = Object.freeze({}) as PluginMessage;
messageDefinitions.set(message, { resources: definition.resources, key, options });
return message;
},
};
}
/** App language source. Each installation subscribes for exactly its own lifetime. */
export interface PluginLocaleSource {
getLocale(): string;
subscribe(listener: () => void): () => void;
}
export interface PluginI18n {
readonly locale: string;
t<Resource extends TranslationResource, Key extends TranslationKey<Resource>>(
messages: PluginTranslations<Resource>,
key: Key,
...args: TranslationArguments<Resource, Key>
): string;
}
export class PluginTranslationRuntime implements PluginI18n {
private readonly engines = new Map<Resources, i18n>();
private readonly listeners = new Set<() => void>();
private readonly unsubscribe: () => void;
private stopped = false;
constructor(private readonly source: PluginLocaleSource) {
this.unsubscribe = source.subscribe(() => {
for (const listener of this.listeners) listener();
});
}
get locale(): string {
return this.source.getLocale();
}
getSnapshot = (): string => this.locale;
subscribe = (listener: () => void): (() => void) => {
if (this.stopped) throw new Error("Plugin translations have stopped");
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
t<Resource extends TranslationResource, Key extends TranslationKey<Resource>>(
messages: PluginTranslations<Resource>,
key: Key,
...args: TranslationArguments<Resource, Key>
): string {
return this.resolve(messages.message(key, ...args));
}
resolve = (text: PluginText, locale = this.locale): string => {
if (typeof text === "string") return text;
if (this.stopped) throw new Error("Plugin translations have stopped");
const message = messageDefinitions.get(text);
if (!message) throw new Error("Plugin labels must use messages.message()");
let engine = this.engines.get(message.resources);
if (!engine) {
engine = createInstance();
void engine.init({
initAsync: false,
lng: this.locale,
fallbackLng: "en",
resources: Object.fromEntries(
Object.entries(message.resources).map(([language, translation]) => [
language,
{ translation },
]),
),
interpolation: { escapeValue: false },
returnNull: false,
returnObjects: false,
});
this.engines.set(message.resources, engine);
}
return engine.t(message.key, { ...message.options, lng: locale });
};
dispose(): void {
if (this.stopped) return;
this.stopped = true;
this.unsubscribe();
this.listeners.clear();
this.engines.clear();
}
}
export function isPluginMessage(value: unknown): value is PluginMessage {
return (
typeof value === "object" && value !== null && messageDefinitions.has(value as PluginMessage)
);
}
export function requirePluginText(text: unknown): PluginText {
if (typeof text === "string" && text.trim()) return text.trim();
if (isPluginMessage(text)) return text;
throw new Error("Plugin label must be a non-empty string or a translation message");
}
function flatten(resource: TranslationResource, prefix = ""): Map<string, string> {
if (!resource || typeof resource !== "object" || Array.isArray(resource))
throw new Error("Translation resources must be objects of strings");
const result = new Map<string, string>();
for (const [key, value] of Object.entries(resource)) {
if (!key || key.includes(".") || ["__proto__", "constructor", "prototype"].includes(key))
throw new Error(`Invalid translation key: ${key}`);
const path = prefix ? `${prefix}.${key}` : key;
if (typeof value === "string") result.set(path, value);
else for (const entry of flatten(value, path)) result.set(...entry);
}
return result;
}
function pluralBase(key: string): string {
return key.replace(/_(zero|one|two|few|many|other)$/, "");
}
function placeholders(value: string): string[] {
return [
...new Set([...value.matchAll(/{{-?\s*([^},\s]+)(?:[^}]*)}}/g)].map((match) => match[1])),
].sort();
}
function validateResources(resources: Resources): Map<string, string> {
const english = flatten(resources.en);
if (!english.size) throw new Error("Plugin translations require English messages");
for (const [locale, resource] of Object.entries(resources)) {
if (Intl.getCanonicalLocales(locale)[0] !== locale)
throw new Error(`Translation locale must be canonical: ${locale}`);
for (const [key, value] of flatten(resource)) {
const source = english.get(key) ?? english.get(`${pluralBase(key)}_other`);
if (source === undefined) throw new Error(`Unknown translation key in ${locale}: ${key}`);
if (placeholders(value).join(",") !== placeholders(source).join(","))
throw new Error(`Translation placeholders differ in ${locale}: ${key}`);
}
}
return english;
}
const Context = createContext<PluginTranslationRuntime | null>(null);
export function PluginTranslationProvider({
runtime,
children,
}: {
runtime: PluginTranslationRuntime;
children: ReactNode;
}) {
return <Context.Provider value={runtime}>{children}</Context.Provider>;
}
export function usePluginTranslationRuntime(): PluginTranslationRuntime {
const runtime = useContext(Context);
if (!runtime) throw new Error("Plugin translations must run inside a contributed plugin surface");
return runtime;
}
export function useTranslation<Resource extends TranslationResource>(
messages: PluginTranslations<Resource>,
) {
const runtime = usePluginTranslationRuntime();
const locale = useSyncExternalStore(runtime.subscribe, runtime.getSnapshot, runtime.getSnapshot);
const t = useCallback(
<Key extends TranslationKey<Resource>>(
key: Key,
...args: TranslationArguments<Resource, Key>
) => runtime.resolve(messages.message(key, ...args), locale),
[runtime, messages, locale],
);
return { locale, t };
}