mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
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.
This commit is contained in:
@@ -1,57 +1,53 @@
|
||||
import type { PluginText } from "../text.js";
|
||||
import type { ComponentType } from "react";
|
||||
import type { PluginHostProps } from "./contracts.js";
|
||||
|
||||
export type PluginButtonContext =
|
||||
| { context: "workspace"; workspaceId: string }
|
||||
| { context: "agent"; workspaceId: string; agentId: string };
|
||||
|
||||
export type PluginButtonIconProps = PluginHostProps &
|
||||
PluginButtonContext & { size: number; color: string };
|
||||
|
||||
export type PluginButtonContentProps = PluginHostProps & PluginButtonContext & { close(): void };
|
||||
|
||||
export type PluginButtonIcon = string | ComponentType<PluginButtonIconProps>;
|
||||
|
||||
export type PluginButtonBehavior =
|
||||
export type PluginButtonBehavior<Text extends PluginText = string> =
|
||||
| { kind: "action"; onPress(): void | Promise<void> }
|
||||
| { kind: "menu"; items: readonly PluginButtonMenuEntry[] }
|
||||
| { kind: "menu"; items: readonly PluginButtonMenuEntry<Text>[] }
|
||||
| { kind: "popover"; Content: ComponentType<PluginButtonContentProps> };
|
||||
|
||||
export type PluginButtonMenuEntry =
|
||||
export type PluginButtonMenuEntry<Text extends PluginText = string> =
|
||||
| { kind: "separator"; id: string }
|
||||
| {
|
||||
kind: "item";
|
||||
id: string;
|
||||
title: string;
|
||||
title: Text;
|
||||
icon?: PluginButtonIcon;
|
||||
visible?: boolean;
|
||||
disabled?: boolean;
|
||||
behavior: PluginButtonBehavior;
|
||||
behavior: PluginButtonBehavior<Text>;
|
||||
};
|
||||
|
||||
export interface PluginButton {
|
||||
title: string;
|
||||
export interface PluginButton<Text extends PluginText = string> {
|
||||
title: Text;
|
||||
icon: PluginButtonIcon;
|
||||
/** Omit for an icon-only header button. Composer pills use title when omitted. */
|
||||
label?: string;
|
||||
label?: Text;
|
||||
visible?: boolean;
|
||||
disabled?: boolean;
|
||||
behavior: PluginButtonBehavior;
|
||||
behavior: PluginButtonBehavior<Text>;
|
||||
}
|
||||
|
||||
export interface PluginButtonRegistration {
|
||||
export interface PluginButtonRegistration<Text extends PluginText = string> {
|
||||
/** Updates presentation in place. Supply a complete behavior to replace it. */
|
||||
update(patch: Partial<PluginButton>): void;
|
||||
update(patch: Partial<PluginButton<Text>>): void;
|
||||
/** Idempotent. Updates after removal do nothing. */
|
||||
remove(): void;
|
||||
}
|
||||
|
||||
export interface PluginHeaderButtonContribution {
|
||||
export interface PluginHeaderButtonContribution<Text extends PluginText = string> {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
button: PluginButton;
|
||||
button: PluginButton<Text>;
|
||||
}
|
||||
|
||||
export interface PluginComposerPillContribution extends PluginHeaderButtonContribution {
|
||||
export interface PluginComposerPillContribution<
|
||||
Text extends PluginText = string,
|
||||
> extends PluginHeaderButtonContribution<Text> {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PluginText } from "../text.js";
|
||||
import type { ComponentType } from "react";
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
@@ -80,10 +81,10 @@ export interface PluginSidebarItemProps extends PluginHostProps {
|
||||
openPopover(Content: ComponentType<PluginPopoverProps>): void;
|
||||
}
|
||||
|
||||
export interface PluginSidebarItemContribution {
|
||||
export interface PluginSidebarItemContribution<Text extends PluginText = string> {
|
||||
id: string;
|
||||
/** Settings row label, accessibility label, and default label for SidebarRow. */
|
||||
title: string;
|
||||
title: Text;
|
||||
Component: ComponentType<PluginSidebarItemProps>;
|
||||
}
|
||||
|
||||
@@ -99,9 +100,9 @@ export interface PluginOpenPanelOptions {
|
||||
location?: PluginPanelLocation;
|
||||
}
|
||||
|
||||
interface PluginWorkspacePanelBase {
|
||||
interface PluginWorkspacePanelBase<Text extends PluginText = string> {
|
||||
id: string;
|
||||
title: string;
|
||||
title: Text;
|
||||
icon: string;
|
||||
locations?: readonly PluginPanelLocation[];
|
||||
}
|
||||
@@ -127,23 +128,30 @@ interface PluginClientContextAliases {
|
||||
/** @deprecated Use `addScreen`. */
|
||||
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): PluginCleanup;
|
||||
/** @deprecated Use `addSidebarHeaderItem`. */
|
||||
addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup;
|
||||
addSidebarItem(contribution: PluginSidebarContribution<PluginText>): PluginCleanup;
|
||||
}
|
||||
|
||||
export interface PluginClientContext extends PluginCommandCapabilities, PluginClientContextAliases {
|
||||
readonly i18n: import("./translations.js").PluginI18n;
|
||||
/** Play a base64-encoded audio file on this client; resolves when playback ends. */
|
||||
playAudio(source: { base64: string; mimeType: string }): Promise<void>;
|
||||
addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup;
|
||||
addScreen(contribution: PluginScreenContribution): PluginCleanup;
|
||||
addSidebarHeaderItem(contribution: PluginSidebarItemContribution): PluginCleanup;
|
||||
addSidebarFooterItem(contribution: PluginSidebarItemContribution): PluginCleanup;
|
||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup;
|
||||
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup;
|
||||
addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup;
|
||||
addHeaderButton(contribution: PluginHeaderButtonContribution): PluginButtonRegistration;
|
||||
addComposerPill(contribution: PluginComposerPillContribution): PluginButtonRegistration;
|
||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup;
|
||||
addTheme(contribution: PluginThemeContribution): PluginCleanup;
|
||||
addSettingsScreen(contribution: PluginSettingsScreenContribution<PluginText>): PluginCleanup;
|
||||
addScreen(contribution: PluginScreenContribution<PluginText>): PluginCleanup;
|
||||
addSidebarHeaderItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
|
||||
addSidebarFooterItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
|
||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution<PluginText>): PluginCleanup;
|
||||
addCommandCenterItem(
|
||||
contribution: PluginCommandCenterItemContribution<PluginText>,
|
||||
): PluginCleanup;
|
||||
addSlashCommand(contribution: PluginClientSlashCommandContribution<PluginText>): PluginCleanup;
|
||||
addHeaderButton(
|
||||
contribution: PluginHeaderButtonContribution<PluginText>,
|
||||
): PluginButtonRegistration<PluginText>;
|
||||
addComposerPill(
|
||||
contribution: PluginComposerPillContribution<PluginText>,
|
||||
): PluginButtonRegistration<PluginText>;
|
||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution<PluginText>): PluginCleanup;
|
||||
addTheme(contribution: PluginThemeContribution<PluginText>): PluginCleanup;
|
||||
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
|
||||
contribution: PluginTimelineTransformerContribution<ItemType>,
|
||||
): PluginCleanup;
|
||||
@@ -155,29 +163,31 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
|
||||
|
||||
export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup;
|
||||
|
||||
export type PluginWorkspacePanelContribution =
|
||||
| (PluginWorkspacePanelBase & {
|
||||
export type PluginWorkspacePanelContribution<Text extends PluginText = string> =
|
||||
| (PluginWorkspacePanelBase<Text> & {
|
||||
context: "workspace";
|
||||
Component: ComponentType<PluginWorkspacePanelProps>;
|
||||
})
|
||||
| (PluginWorkspacePanelBase & {
|
||||
| (PluginWorkspacePanelBase<Text> & {
|
||||
context: "agent";
|
||||
Component: ComponentType<PluginAgentPanelProps>;
|
||||
});
|
||||
|
||||
export interface PluginSettingsScreenContribution {
|
||||
export interface PluginSettingsScreenContribution<Text extends PluginText = string> {
|
||||
id: string;
|
||||
title: string;
|
||||
title: Text;
|
||||
icon: string;
|
||||
Component: ComponentType<PluginSurfaceProps>;
|
||||
}
|
||||
|
||||
/** The screen header's title: fixed, or derived from the params the screen was opened with. */
|
||||
export type PluginScreenTitle = string | ((params: PluginScreenParams) => string);
|
||||
export type PluginScreenTitle<Text extends PluginText = string> =
|
||||
| Text
|
||||
| ((params: PluginScreenParams) => Text);
|
||||
|
||||
export interface PluginScreenContribution {
|
||||
export interface PluginScreenContribution<Text extends PluginText = string> {
|
||||
id: string;
|
||||
title: PluginScreenTitle;
|
||||
title: PluginScreenTitle<Text>;
|
||||
Component: ComponentType<PluginScreenProps>;
|
||||
}
|
||||
|
||||
@@ -187,9 +197,9 @@ export interface PluginSurfaceContribution {
|
||||
}
|
||||
|
||||
/** @deprecated Use `PluginSidebarItemContribution` with `addSidebarHeaderItem`. */
|
||||
export interface PluginSidebarContribution {
|
||||
export interface PluginSidebarContribution<Text extends PluginText = string> {
|
||||
id: string;
|
||||
title: string;
|
||||
title: Text;
|
||||
icon: string;
|
||||
surface: string;
|
||||
}
|
||||
@@ -256,39 +266,39 @@ export interface PluginAgentCommandContext extends PluginCommandCapabilities {
|
||||
openPanel(id: string, options?: PluginOpenPanelOptions): void;
|
||||
}
|
||||
|
||||
interface PluginCommandCenterItemBase {
|
||||
interface PluginCommandCenterItemBase<Text extends PluginText = string> {
|
||||
id: string;
|
||||
title: string;
|
||||
title: Text;
|
||||
icon: string;
|
||||
keywords?: readonly string[];
|
||||
}
|
||||
|
||||
export type PluginCommandCenterItemContribution =
|
||||
| (PluginCommandCenterItemBase & {
|
||||
export type PluginCommandCenterItemContribution<Text extends PluginText = string> =
|
||||
| (PluginCommandCenterItemBase<Text> & {
|
||||
context: "global";
|
||||
onSelect(context: PluginGlobalCommandContext): void | Promise<void>;
|
||||
})
|
||||
| (PluginCommandCenterItemBase & {
|
||||
| (PluginCommandCenterItemBase<Text> & {
|
||||
context: "workspace";
|
||||
onSelect(context: PluginWorkspaceCommandContext): void | Promise<void>;
|
||||
})
|
||||
| (PluginCommandCenterItemBase & {
|
||||
| (PluginCommandCenterItemBase<Text> & {
|
||||
context: "agent";
|
||||
onSelect(context: PluginAgentCommandContext): void | Promise<void>;
|
||||
});
|
||||
|
||||
interface PluginClientSlashCommandBase {
|
||||
interface PluginClientSlashCommandBase<Text extends PluginText = string> {
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint: string;
|
||||
description: Text;
|
||||
argumentHint: Text;
|
||||
}
|
||||
|
||||
export type PluginClientSlashCommandContribution =
|
||||
| (PluginClientSlashCommandBase & {
|
||||
export type PluginClientSlashCommandContribution<Text extends PluginText = string> =
|
||||
| (PluginClientSlashCommandBase<Text> & {
|
||||
context: "workspace";
|
||||
onSubmit(context: PluginWorkspaceCommandContext & { args: string }): void | Promise<void>;
|
||||
})
|
||||
| (PluginClientSlashCommandBase & {
|
||||
| (PluginClientSlashCommandBase<Text> & {
|
||||
context: "agent";
|
||||
onSubmit(context: PluginAgentCommandContext & { args: string }): void | Promise<void>;
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PluginText } from "../text.js";
|
||||
import { PluginAttachmentSearchPayloadSchema } from "../attachments.js";
|
||||
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
|
||||
export {
|
||||
@@ -10,7 +11,7 @@ import { PaseoApiProvider } from "./paseo-context.js";
|
||||
import { callPluginRpc } from "../rpc.js";
|
||||
|
||||
export async function searchPluginAttachments(
|
||||
source: PluginAttachmentSourceContribution,
|
||||
source: PluginAttachmentSourceContribution<PluginText>,
|
||||
invoke: (method: string, input: unknown) => Promise<unknown>,
|
||||
query: string,
|
||||
) {
|
||||
@@ -19,3 +20,12 @@ export async function searchPluginAttachments(
|
||||
}
|
||||
|
||||
export { callPluginRpc, PaseoApiProvider, PluginRpcProvider };
|
||||
|
||||
export {
|
||||
PluginTranslationRuntime,
|
||||
PluginTranslationProvider,
|
||||
usePluginTranslationRuntime,
|
||||
requirePluginText,
|
||||
isPluginMessage,
|
||||
} from "./translations.js";
|
||||
export type { PluginLocaleSource } from "./translations.js";
|
||||
|
||||
@@ -67,3 +67,7 @@ export declare function useHosts(): readonly PluginHostSummary[];
|
||||
export declare function getPaseoClient(serverId: string): import("@getpaseo/client").PaseoApi;
|
||||
/** Open an absolute HTTP(S) URL using the client platform’s external opener. */
|
||||
export declare function openExternalUrl(url: string): Promise<void>;
|
||||
|
||||
export { defineTranslations, useTranslation } from "./translations.js";
|
||||
export type { PluginI18n } from "./translations.js";
|
||||
export type { PluginText, PluginMessage, PluginTranslations } from "../text.js";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PluginTranslationProvider, usePluginTranslationRuntime } from "./translations.js";
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { PluginClientStateProvider, usePluginClientStateSource } from "./client-state.js";
|
||||
import { PaseoApiProvider, usePaseoContextValue } from "./paseo-context.js";
|
||||
@@ -7,6 +8,7 @@ export type PluginRuntimeContextBridge = (children: ReactNode) => ReactNode;
|
||||
|
||||
/** Rebuilds plugin runtime contexts inside React Native portal hosts. */
|
||||
export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
|
||||
const translations = usePluginTranslationRuntime();
|
||||
const paseo = usePaseoContextValue();
|
||||
const rpc = usePluginRpcContextValue();
|
||||
const state = usePluginClientStateSource();
|
||||
@@ -24,10 +26,12 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
|
||||
);
|
||||
return (
|
||||
<PaseoApiProvider paseo={paseo}>
|
||||
<PluginRpcProvider invoke={rpc.invoke}>{content}</PluginRpcProvider>
|
||||
<PluginRpcProvider invoke={rpc.invoke}>
|
||||
<PluginTranslationProvider runtime={translations}>{content}</PluginTranslationProvider>
|
||||
</PluginRpcProvider>
|
||||
</PaseoApiProvider>
|
||||
);
|
||||
},
|
||||
[paseo, rpc, state],
|
||||
[paseo, rpc, state, translations],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { defineTranslations, PluginTranslationRuntime } from "./translations.js";
|
||||
|
||||
function language(initial = "en") {
|
||||
let locale = initial;
|
||||
const listeners = new Set<() => void>();
|
||||
return {
|
||||
getLocale: () => locale,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
set(next: string) {
|
||||
locale = next;
|
||||
for (const listener of listeners) listener();
|
||||
},
|
||||
subscriptions: () => listeners.size,
|
||||
};
|
||||
}
|
||||
const messages = defineTranslations({
|
||||
resources: {
|
||||
en: {
|
||||
screen: {
|
||||
title: "Notes",
|
||||
fallback: "English fallback",
|
||||
notes_one: "{{count}} note for {{name}}",
|
||||
notes_other: "{{count}} notes for {{name}}",
|
||||
},
|
||||
},
|
||||
"zh-CN": { screen: { title: "笔记", notes_other: "{{name}}的 {{count}} 条笔记" } },
|
||||
},
|
||||
});
|
||||
it("resolves messages, interpolation, plurals and English fallback with the current app locale", () => {
|
||||
const source = language();
|
||||
const runtime = new PluginTranslationRuntime(source);
|
||||
const title = messages.message("screen.title");
|
||||
expect(runtime.resolve(title)).toBe("Notes");
|
||||
expect(runtime.t(messages, "screen.notes", { count: 1, name: "Ada" })).toBe("1 note for Ada");
|
||||
expect(runtime.t(messages, "screen.notes", { count: 2, name: "Ada" })).toBe("2 notes for Ada");
|
||||
source.set("zh-CN");
|
||||
expect(runtime.resolve(title)).toBe("笔记");
|
||||
expect(runtime.t(messages, "screen.notes", { count: 2, name: "Ada" })).toBe("Ada的 2 条笔记");
|
||||
expect(runtime.t(messages, "screen.fallback")).toBe("English fallback");
|
||||
expect(runtime.locale).toBe("zh-CN");
|
||||
source.set("fr");
|
||||
expect(runtime.resolve(title)).toBe("Notes");
|
||||
expect(runtime.locale).toBe("fr");
|
||||
runtime.dispose();
|
||||
});
|
||||
it("isolates installations, resources and clients, and unsubscribes on unload", () => {
|
||||
const first = language("zh-CN");
|
||||
const second = language();
|
||||
const a = new PluginTranslationRuntime(first);
|
||||
const b = new PluginTranslationRuntime(second);
|
||||
const other = defineTranslations({ resources: { en: { screen: { title: "Other plugin" } } } });
|
||||
expect(a.t(messages, "screen.title")).toBe("笔记");
|
||||
expect(b.t(messages, "screen.title")).toBe("Notes");
|
||||
expect(a.t(other, "screen.title")).toBe("Other plugin");
|
||||
let updates = 0;
|
||||
a.subscribe(() => updates++);
|
||||
first.set("en");
|
||||
expect(updates).toBe(1);
|
||||
expect(first.subscriptions()).toBe(1);
|
||||
a.dispose();
|
||||
a.dispose();
|
||||
expect(first.subscriptions()).toBe(0);
|
||||
first.set("zh-CN");
|
||||
expect(updates).toBe(1);
|
||||
expect(b.t(messages, "screen.title")).toBe("Notes");
|
||||
b.dispose();
|
||||
});
|
||||
|
||||
it("rejects unknown keys, malformed resources, noncanonical locales and placeholder drift", () => {
|
||||
expect(() =>
|
||||
defineTranslations({ resources: { en: { title: "Notes" }, fr: { typo: "Notes" } } }),
|
||||
).toThrow("Unknown translation key");
|
||||
expect(() =>
|
||||
defineTranslations({ resources: { en: { title: "Notes" }, "zh-cn": { title: "笔记" } } }),
|
||||
).toThrow("canonical");
|
||||
expect(() =>
|
||||
defineTranslations({
|
||||
resources: { en: { title: "Hello {{name}}" }, fr: { title: "Bonjour {{other}}" } },
|
||||
}),
|
||||
).toThrow("placeholders differ");
|
||||
expect(() => defineTranslations({ resources: { en: {} } })).toThrow("English messages");
|
||||
expect(() => defineTranslations({ resources: { en: { value: 42 } } } as never)).toThrow(
|
||||
"objects of strings",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Intl formatting and reports missing JSON interpolation values", () => {
|
||||
const source = language("en");
|
||||
const runtime = new PluginTranslationRuntime(source);
|
||||
const en: { amount: string; welcome: string } = {
|
||||
amount: "{{value, number}}",
|
||||
welcome: "Hello {{name}}",
|
||||
};
|
||||
const formatted = defineTranslations({ resources: { en } });
|
||||
expect(runtime.t(formatted, "amount", { value: 1234.5 })).toBe("1,234.5");
|
||||
expect(() => runtime.t(formatted, "welcome")).toThrow("Missing translation value: name");
|
||||
runtime.dispose();
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user