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:
Mohamed Boudra
2026-10-20 08:43:16 +02:00
parent d87998bc8c
commit 87dfe27065
67 changed files with 1680 additions and 194 deletions
+1
View File
@@ -67,6 +67,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
+4 -3
View File
@@ -1,9 +1,10 @@
import type { PluginText } from "./text.js";
import { z } from "zod";
import type { PluginAttachmentSourceContribution } from "./contracts.js";
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition {
export function defineAttachmentSource<
Definition extends PluginAttachmentSourceContribution<PluginText>,
>(definition: Definition): Definition {
return definition;
}
+2 -1
View File
@@ -24,7 +24,8 @@ const clientModule = /^client\//;
function resolveLocal(importer: string, specifier: string): string {
const base = path.resolve(path.dirname(importer), specifier.replace(/\.js$/, ""));
const result = [base + ".ts", base + ".tsx"].find(existsSync);
const candidates = specifier.endsWith(".json") ? [base] : [base + ".ts", base + ".tsx"];
const result = candidates.find(existsSync);
if (!result) throw new Error(`Cannot resolve ${specifier} from ${importer}`);
return result;
}
+17 -21
View File
@@ -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;
}
+48 -38
View File
@@ -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>;
});
+11 -1
View File
@@ -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";
+4
View File
@@ -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();
});
+202
View File
@@ -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 };
}
+7 -6
View File
@@ -1,3 +1,4 @@
import type { PluginText } from "./text.js";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginRpcContract } from "./rpc.js";
@@ -63,19 +64,19 @@ export interface PluginThemeColors {
ring: string;
}
export interface PluginThemeContribution {
export interface PluginThemeContribution<Text extends PluginText = string> {
id: string;
name: string;
name: Text;
appearance: "light" | "dark";
colors: PluginThemeColors;
}
export interface PluginAttachmentSourceContribution {
export interface PluginAttachmentSourceContribution<Text extends PluginText = string> {
id: string;
title: string;
title: Text;
icon: string;
pickerTitle: string;
searchPlaceholder: string;
pickerTitle: Text;
searchPlaceholder: Text;
search: PluginRpcContract;
}
+2
View File
@@ -20,3 +20,5 @@ export {
type PluginAttachmentSearchPayload,
} from "./attachments.js";
export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js";
export type { PluginText, PluginMessage } from "./text.js";
@@ -38,3 +38,77 @@ describe("paseo-plugin skill examples", () => {
expect(typeErrors(sectionCode("Add a composer pill"))).toEqual([]);
});
});
describe("plugin translation author types", () => {
it("checks JSON keys, plural counts, literal interpolation names and imperative translation", () => {
expect(
typeErrors(`
import { defineTranslations, useTranslation, type PluginClientContext } from "@getpaseo/plugin/client";
import en from "../../translations/client/locales/en.json";
const json = defineTranslations({ resources: { en } });
const literal = defineTranslations({ resources: { en: { greeting: "Hello {{name}}", value: "{{amount, number}}" } } });
function Screen() {
const { t } = useTranslation(json);
t("notes", { count: 2 });
// @ts-expect-error unknown JSON key
t("missing");
// @ts-expect-error plural base keys require count
t("notes");
// @ts-expect-error count is numeric
t("notes", { count: "two" });
literal.message("greeting", { name: "Ada" });
// @ts-expect-error inline literal resources infer interpolation names
literal.message("greeting");
return null;
}
function contribute(client: PluginClientContext) {
client.addScreen({ id: "notes", title: json.message("title"), Component: Screen });
client.i18n.t(json, "notes", { count: 2 });
// @ts-expect-error imperative keys are checked
client.i18n.t(json, "missing");
}
`),
).toEqual([]);
});
});
it("keeps historical string descriptor helpers while accepting translated registrations", () => {
expect(
typeErrors(`
import { defineTranslations, type PluginText, type PluginClientContext, type PluginScreenContribution, type PluginWorkspacePanelContribution, type PluginSettingsScreenContribution, type PluginSidebarItemContribution, type PluginSidebarContribution, type PluginCommandCenterItemContribution, type PluginClientSlashCommandContribution, type PluginButton, type PluginHeaderButtonContribution, type PluginComposerPillContribution, type PluginButtonMenuEntry } from "@getpaseo/plugin/client";
import { z } from "zod";
import { defineAttachmentSource, type PluginThemeContribution, type PluginAttachmentSourceContribution } from "@getpaseo/plugin";
const messages = defineTranslations({ resources: { en: { title: "Notes" } } });
const Component = () => null;
function historicalScreen(screen: PluginScreenContribution) {
return typeof screen.title === "function" ? screen.title({}).trim() : screen.title.trim();
}
function historicalLabels(panel: PluginWorkspacePanelContribution, settings: PluginSettingsScreenContribution, sidebar: PluginSidebarItemContribution, legacy: PluginSidebarContribution, command: PluginCommandCenterItemContribution, slash: PluginClientSlashCommandContribution, button: PluginButton, header: PluginHeaderButtonContribution, pill: PluginComposerPillContribution, menu: PluginButtonMenuEntry, theme: PluginThemeContribution, source: PluginAttachmentSourceContribution) {
panel.title.trim(); settings.title.trim(); sidebar.title.trim(); legacy.title.trim(); command.title.trim();
slash.description.trim(); slash.argumentHint.trim(); button.title.trim(); button.label?.trim();
header.button.title.trim(); pill.button.title.trim(); if (menu.kind === "item") menu.title.trim();
theme.name.trim(); source.title.trim(); source.pickerTitle.trim(); source.searchPlaceholder.trim();
}
function contribute(client: PluginClientContext) {
const oldScreen: PluginScreenContribution = { id: "old", title: (params) => params.name ?? "Notes", Component };
historicalScreen(oldScreen);
client.addScreen(oldScreen);
client.addScreen({ id: "translated", title: messages.message("title"), Component });
client.addScreen({ id: "callback", title: (params) => messages.message("title"), Component });
const translated: PluginScreenContribution<PluginText> = { id: "typed", title: messages.message("title"), Component };
client.addScreen(translated);
client.addSidebarHeaderItem({ id: "notes", title: messages.message("title"), Component });
client.addSettingsScreen({ id: "notes", title: messages.message("title"), icon: "File", Component });
client.addWorkspacePanel({ id: "notes", title: messages.message("title"), icon: "File", context: "workspace", Component });
client.addCommandCenterItem({ id: "notes", title: messages.message("title"), icon: "File", context: "global", onSelect() {} });
client.addSlashCommand({ name: "notes", description: messages.message("title"), argumentHint: "", context: "workspace", onSubmit() {} });
const button = client.addHeaderButton({ id: "notes", workspaceId: "workspace", button: { title: messages.message("title"), icon: "File", behavior: { kind: "menu", items: [{ kind: "item", id: "open", title: messages.message("title"), behavior: { kind: "action", onPress() {} } }] } } });
button.update({ label: messages.message("title") });
const source = defineAttachmentSource({ id: "source", title: messages.message("title"), pickerTitle: messages.message("title"), searchPlaceholder: messages.message("title"), icon: "File", search: { name: "source.search", input: z.object({}), output: z.object({}) } });
client.addAttachmentSource(source);
// @ts-expect-error a translated reference is never typed as a string
source.title.trim();
}
`),
).toEqual([]);
});
+67
View File
@@ -0,0 +1,67 @@
/** A persistent label resolved in the presenting app's language. */
declare const pluginMessage: unique symbol;
export interface PluginMessage {
readonly [pluginMessage]: true;
}
export type PluginText = string | PluginMessage;
export interface TranslationResource {
readonly [key: string]: string | TranslationResource;
}
export interface TranslationOptions {
readonly count?: number;
readonly [name: string]: unknown;
}
export type TranslationKey<Resource> = {
[Key in keyof Resource & string]: Resource[Key] extends string
?
| Key
| (Key extends `${infer Base}_${"zero" | "one" | "two" | "few" | "many" | "other"}`
? Base
: never)
: Resource[Key] extends TranslationResource
? `${Key}.${TranslationKey<Resource[Key]>}`
: never;
}[keyof Resource & string];
export interface PluginTranslations<Resource extends TranslationResource = TranslationResource> {
readonly resources: Readonly<Record<string, TranslationResource>> & { readonly en: Resource };
message<Key extends TranslationKey<Resource>>(
key: Key,
...args: TranslationArguments<Resource, Key>
): PluginMessage;
}
type Lookup<Resource, Key extends string> = Key extends `${infer Head}.${infer Tail}`
? Head extends keyof Resource
? Lookup<Resource[Head], Tail>
: never
: Key extends keyof Resource
? Resource[Key]
: never;
type Trim<Value extends string> = Value extends ` ${infer Rest}` | `${infer Rest} `
? Trim<Rest>
: Value;
type InterpolationName<Value extends string> =
Trim<Value> extends `-${infer Rest}`
? InterpolationName<Rest>
: Trim<Value> extends `${infer Name},${string}`
? Trim<Name>
: Trim<Value>;
type InterpolationNames<Value> = Value extends string
? string extends Value
? never
: Value extends `${string}{{${infer Name}}}${infer Rest}`
? InterpolationName<Name> | InterpolationNames<Rest>
: never
: never;
type MessageOptions<Resource, Key extends string> = TranslationOptions &
([Lookup<Resource, `${Key}_other`>] extends [never] ? {} : { count: number }) & {
[Name in InterpolationNames<Lookup<Resource, Key> | Lookup<Resource, `${Key}_other`>>]:
| string
| number
| boolean
| Date;
};
export type TranslationArguments<Resource, Key extends string> =
{} extends MessageOptions<Resource, Key>
? [options?: MessageOptions<Resource, Key>]
: [options: MessageOptions<Resource, Key>];
+2 -1
View File
@@ -10,7 +10,8 @@
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"noEmit": true,
"types": ["node"]
"types": ["node"],
"resolveJsonModule": true
},
"include": ["../../plugin-examples/**/*.ts", "../../plugin-examples/**/*.tsx"]
}