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
This commit is contained in:
Mohamed Boudra
2026-10-21 13:40:46 +07:00
committed by GitHub
parent 98d34a3dc1
commit 30464cf234
69 changed files with 1717 additions and 209 deletions
+7
View File
@@ -0,0 +1,7 @@
# Workspace notes
Keep a draft beside your workspace and preview it in a modal. Switch Paseo between English and Simplified Chinese to see the content, sidebar row, screen header, panel title, and commands follow your language while your draft stays intact. Notes and drafts last while the surface is mounted.
Open **Workspace notes** in the sidebar or **Open workspace notes** in the Command Center. The panel also appears in workspace and Explorer tab menus. An English-only message shows fallback when Chinese is selected.
This example uses an unreleased translation API. Its `>=0.12.1` requirement is provisional. Assign the release containing the API before publishing it. Listing translations and this overview are independent of the bundled client JSON resources.
@@ -0,0 +1,7 @@
# 工作区笔记
在工作区旁保留草稿,并在模态框中预览。在 Paseo 中切换英语与简体中文,内容、侧边栏项、界面标题、面板标题和命令会跟随语言变化,草稿保持不变。笔记和草稿在界面挂载期间保留。
在侧边栏打开 **工作区笔记**,或在 Command Center 选择 **打开工作区笔记**。工作区和 Explorer 的标签页菜单也提供此面板。选择中文时,一条仅有英语的消息展示回退行为。
此示例使用尚未发布的翻译 API。`>=0.12.1` 版本要求为临时值,发布前须改为实际包含该 API 的版本。列表翻译与本文独立于打包到客户端中的 JSON 资源。
@@ -0,0 +1,23 @@
{
"title": "Workspace notes",
"intro": "A small space for your next idea.",
"draft": "Your draft",
"placeholder": "What would you like to remember?",
"add": "Add note",
"empty": "No notes yet. Start with a thought above.",
"notes_one": "{{count}} note",
"notes_other": "{{count}} notes",
"preview": "Preview draft",
"close": "Close preview",
"previewTitle": "Draft preview",
"draftEmpty": "Your draft is empty.",
"locale": "App language: {{locale}}",
"fallback": "This English-only message demonstrates fallback.",
"open": "Open workspace notes",
"description": "Open the notes screen",
"hint": "[optional text]",
"copyTitle": "Copy notes title",
"tools": "Notes tools",
"pin": "Show notes tools",
"openPanel": "Open notes panel"
}
@@ -0,0 +1,21 @@
{
"title": "工作区笔记",
"intro": "为下一个灵感留一点空间。",
"draft": "你的草稿",
"placeholder": "有什么想记下来的?",
"add": "添加笔记",
"empty": "暂无笔记。在上方写下你的想法吧。",
"notes_other": "{{count}} 条笔记",
"preview": "预览草稿",
"close": "关闭预览",
"previewTitle": "草稿预览",
"draftEmpty": "草稿为空。",
"locale": "应用语言:{{locale}}",
"open": "打开工作区笔记",
"description": "打开笔记页面",
"hint": "[可选文本]",
"copyTitle": "复制笔记标题",
"tools": "笔记工具",
"pin": "显示笔记工具",
"openPanel": "打开笔记面板"
}
@@ -0,0 +1,5 @@
import { defineTranslations } from "@getpaseo/plugin/client";
import en from "./locales/en.json";
import zhCN from "./locales/zh-CN.json";
export const messages = defineTranslations({ resources: { en, "zh-CN": zhCN } });
@@ -0,0 +1,151 @@
import { useCallback, useMemo, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Modal, TextInput, ScrollView } from "@getpaseo/plugin/client/ui";
import { useTranslation, type PluginSurfaceProps } from "@getpaseo/plugin/client";
import { messages } from "./messages";
function useStyles(theme: PluginSurfaceProps["theme"], compact: boolean) {
return useMemo(() => {
const primary = { padding: 12, borderRadius: 8, backgroundColor: theme.colors.accent };
return StyleSheet.create({
screen: { flex: 1, backgroundColor: theme.colors.surface0 },
content: {
padding: compact ? 20 : 36,
gap: 20,
width: "100%",
maxWidth: 760,
alignSelf: "center",
},
heading: { gap: 8 },
title: { color: theme.colors.foreground, fontSize: 28, fontWeight: "600" },
text: { color: theme.colors.foreground },
muted: { color: theme.colors.foregroundMuted },
label: { color: theme.colors.foreground, fontWeight: "600" },
card: {
gap: 12,
padding: 20,
borderRadius: 12,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
},
input: {
color: theme.colors.foreground,
minHeight: 112,
padding: 12,
borderRadius: 8,
borderColor: theme.colors.border,
borderWidth: 1,
backgroundColor: theme.colors.surface0,
},
actions: { flexDirection: "row", gap: 12, flexWrap: "wrap" },
primary,
primaryDisabled: { ...primary, opacity: 0.5 },
primaryText: { color: theme.colors.accentForeground },
secondary: { padding: 12, borderRadius: 8, borderWidth: 1, borderColor: theme.colors.border },
preview: { gap: 16 },
close: { padding: 12 },
closeText: { color: theme.colors.accent },
});
}, [theme, compact]);
}
function Preview({
draft,
theme,
close,
}: {
draft: string;
theme: PluginSurfaceProps["theme"];
close(): void;
}) {
// This hook also runs inside native portal hosts.
const { t, locale } = useTranslation(messages);
const styles = useStyles(theme, false);
return (
<View style={styles.preview}>
<Text style={styles.text}>{draft || t("draftEmpty")}</Text>
<Text style={styles.muted}>{t("locale", { locale })}</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("close")}
onPress={close}
style={styles.close}
>
<Text style={styles.closeText}>{t("close")}</Text>
</Pressable>
</View>
);
}
export function Notes({ theme, layout }: PluginSurfaceProps) {
const { t, locale } = useTranslation(messages);
const [draft, setDraft] = useState("");
const [notes, setNotes] = useState<{ id: number; text: string }[]>([]);
const [preview, setPreview] = useState(false);
const styles = useStyles(theme, layout.compact);
const showPreview = useCallback(() => setPreview(true), []);
const closePreview = useCallback(() => setPreview(false), []);
const addNote = useCallback(() => {
setNotes((previous) => [...previous, { id: previous.length, text: draft }]);
setDraft("");
}, [draft]);
return (
<ScrollView style={styles.screen} contentContainerStyle={styles.content}>
<View style={styles.heading}>
<Text style={styles.title}>{t("title")}</Text>
<Text style={styles.muted}>{t("intro")}</Text>
</View>
<View style={styles.card}>
<Text style={styles.label}>{t("draft")}</Text>
<TextInput
accessibilityLabel={t("draft")}
placeholder={t("placeholder")}
placeholderTextColor={theme.colors.foregroundMuted}
value={draft}
onChangeText={setDraft}
multiline
style={styles.input}
/>
<View style={styles.actions}>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("add")}
disabled={!draft.trim()}
onPress={addNote}
style={draft.trim() ? styles.primary : styles.primaryDisabled}
>
<Text style={styles.primaryText}>{t("add")}</Text>
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("preview")}
onPress={showPreview}
style={styles.secondary}
>
<Text style={styles.text}>{t("preview")}</Text>
</Pressable>
</View>
</View>
<Text style={styles.label}>{t("notes", { count: notes.length })}</Text>
{notes.length === 0 ? (
<Text style={styles.muted}>{t("empty")}</Text>
) : (
notes.map((note) => (
<Text key={note.id} style={styles.text}>
{note.text}
</Text>
))
)}
<View style={styles.heading}>
<Text style={styles.muted}>{t("locale", { locale })}</Text>
<Text style={styles.muted}>{t("fallback")}</Text>
</View>
<Modal title={t("previewTitle")} open={preview} onOpenChange={setPreview}>
<Modal.Content>
<Preview draft={draft} theme={theme} close={closePreview} />
</Modal.Content>
</Modal>
</ScrollView>
);
}
@@ -0,0 +1,100 @@
import { useCallback } from "react";
import { copyText, SidebarRow } from "@getpaseo/plugin/client/ui";
import type {
PluginButtonRegistration,
PluginClientContext,
PluginSidebarItemProps,
} from "@getpaseo/plugin/client";
import { messages } from "./client/messages";
import { Notes } from "./client/notes";
function NotesItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
const open = useCallback(() => openScreen({ screenId: "notes" }), [openScreen]);
return (
<SidebarRow icon="NotebookPen" active={currentScreen?.screenId === "notes"} onPress={open} />
);
}
export default function contribute(client: PluginClientContext) {
let tools: PluginButtonRegistration | undefined;
client.addScreen({ id: "notes", title: messages.message("title"), Component: Notes });
client.addSidebarHeaderItem({
id: "notes",
title: messages.message("title"),
Component: NotesItem,
});
client.addWorkspacePanel({
id: "notes",
title: messages.message("title"),
icon: "NotebookPen",
context: "workspace",
locations: ["workspace", "explorer"],
Component: Notes,
});
client.addSettingsScreen({
id: "notes",
title: messages.message("title"),
icon: "NotebookPen",
Component: Notes,
});
client.addCommandCenterItem({
id: "notes",
title: messages.message("open"),
icon: "NotebookPen",
context: "global",
onSelect({ openScreen }) {
openScreen({ screenId: "notes" });
},
});
client.addSlashCommand({
name: "notes",
description: messages.message("description"),
argumentHint: messages.message("hint"),
context: "workspace",
onSubmit({ openScreen }) {
openScreen({ screenId: "notes" });
},
});
client.addCommandCenterItem({
id: "copy-title",
title: messages.message("copyTitle"),
icon: "Copy",
context: "global",
onSelect() {
return copyText(client.i18n.t(messages, "title"));
},
});
client.addCommandCenterItem({
id: "tools",
title: messages.message("pin"),
icon: "NotebookPen",
context: "workspace",
onSelect({ workspace, openPanel }) {
tools?.remove();
tools = client.addHeaderButton({
id: "notes-tools",
workspaceId: workspace.id,
button: {
title: messages.message("tools"),
icon: "NotebookPen",
behavior: {
kind: "menu",
items: [
{
kind: "item",
id: "open",
title: messages.message("openPanel"),
behavior: {
kind: "action",
onPress() {
openPanel("notes");
},
},
},
],
},
},
});
},
});
return () => tools?.remove();
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "paseo-translation-notes-example",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@getpaseo/plugin": "file:../../packages/plugin",
"@types/react": "~19.2.0",
"react": "19.1.0",
"react-native": "0.81.5",
"typescript": "^5.9.3"
}
}
@@ -0,0 +1,12 @@
{
"id": "translation-notes",
"name": "Workspace notes",
"description": "A bilingual notes example that keeps your draft while the app language changes.",
"translations": {
"zh-CN": {
"name": "工作区笔记",
"description": "跟随应用语言切换的双语笔记示例,切换时保留草稿。"
}
},
"requirements": { "paseo": ">=0.12.1" }
}
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2023"],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"noEmit": true
},
"include": ["index.client.tsx", "client/**/*"]
}