From e6da01e998d3ddbb3b3a51c44c0864be2ca87f6c Mon Sep 17 00:00:00 2001 From: Siri-Ray <109605599+Siri-Ray@users.noreply.github.com> Date: Fri, 22 May 2026 16:39:32 +0800 Subject: [PATCH] Add i18n metadata for official content (#2692) --- apps/daemon/src/plugins/apply.ts | 10 +- apps/daemon/src/server.ts | 6 +- apps/daemon/src/skills.ts | 40 +++ apps/daemon/tests/skills.test.ts | 47 +++ apps/web/src/components/ChatComposer.tsx | 18 +- apps/web/src/components/ExamplesTab.tsx | 15 +- apps/web/src/components/HomeHero.tsx | 8 +- apps/web/src/components/HomeView.tsx | 8 +- .../web/src/components/PluginsHomeSection.tsx | 11 +- apps/web/src/components/SkillsSection.tsx | 14 +- .../components/plugins-home/PluginCard.tsx | 30 +- .../web/src/components/plugins-home/facets.ts | 6 +- .../components/plugins-home/localization.ts | 24 ++ .../plugins-home/usePluginFacets.ts | 6 +- apps/web/src/i18n/content.ts | 29 ++ .../components/plugins-home-section.test.tsx | 51 +++ apps/web/tests/i18n/content.test.ts | 32 +- docs/plugins-spec.md | 6 + docs/plugins-spec.zh-CN.md | 6 + docs/schemas/open-design.marketplace.v1.json | 9 + docs/schemas/open-design.plugin.v1.json | 2 + docs/skills-contributing.md | 1 + docs/skills-protocol.md | 13 + packages/contracts/src/api/registry.ts | 3 + packages/contracts/src/plugins/manifest.ts | 2 + packages/contracts/src/plugins/marketplace.ts | 3 + .../contracts/tests/plugins-manifest.test.ts | 44 ++- .../examples/article-magazine/SKILL.md | 7 +- .../article-magazine/open-design.json | 21 +- .../_official/examples/card-twitter/SKILL.md | 8 +- .../examples/card-twitter/open-design.json | 23 +- .../examples/card-xiaohongshu/SKILL.md | 9 +- .../card-xiaohongshu/open-design.json | 23 +- .../_official/examples/data-report/SKILL.md | 9 +- .../examples/data-report/open-design.json | 23 +- .../examples/deck-guizang-editorial/SKILL.md | 8 +- .../deck-guizang-editorial/open-design.json | 23 +- .../examples/deck-open-slide-canvas/SKILL.md | 8 +- .../deck-open-slide-canvas/open-design.json | 23 +- .../deck-swiss-international/SKILL.md | 8 +- .../deck-swiss-international/open-design.json | 23 +- .../examples/doc-kami-parchment/SKILL.md | 8 +- .../doc-kami-parchment/open-design.json | 23 +- .../examples/frame-data-chart-nyt/SKILL.md | 9 +- .../frame-data-chart-nyt/open-design.json | 23 +- .../examples/frame-flowchart-sticky/SKILL.md | 9 +- .../frame-flowchart-sticky/open-design.json | 23 +- .../examples/frame-glitch-title/SKILL.md | 8 +- .../frame-glitch-title/open-design.json | 23 +- .../examples/frame-light-leak-cinema/SKILL.md | 9 +- .../frame-light-leak-cinema/open-design.json | 23 +- .../examples/frame-liquid-bg-hero/SKILL.md | 9 +- .../frame-liquid-bg-hero/open-design.json | 23 +- .../examples/frame-logo-outro/SKILL.md | 8 +- .../frame-logo-outro/open-design.json | 23 +- .../frame-macos-notification/SKILL.md | 9 +- .../frame-macos-notification/open-design.json | 23 +- .../examples/mockup-device-3d/SKILL.md | 9 +- .../mockup-device-3d/open-design.json | 23 +- .../_official/examples/poster-hero/SKILL.md | 8 +- .../examples/poster-hero/open-design.json | 23 +- .../_official/examples/ppt-keynote/SKILL.md | 9 +- .../examples/ppt-keynote/open-design.json | 23 +- .../_official/examples/resume-modern/SKILL.md | 9 +- .../examples/resume-modern/open-design.json | 23 +- .../examples/social-reddit-card/SKILL.md | 9 +- .../social-reddit-card/open-design.json | 23 +- .../examples/social-spotify-card/SKILL.md | 9 +- .../social-spotify-card/open-design.json | 23 +- .../examples/social-x-post-card/SKILL.md | 9 +- .../social-x-post-card/open-design.json | 23 +- .../examples/vfx-text-cursor/SKILL.md | 8 +- .../examples/vfx-text-cursor/open-design.json | 23 +- .../examples/video-hyperframes/SKILL.md | 8 +- .../video-hyperframes/open-design.json | 23 +- .../official/open-design-marketplace.json | 334 ++++++++++++++---- skills/article-magazine/SKILL.md | 6 +- skills/card-twitter/SKILL.md | 8 +- skills/card-xiaohongshu/SKILL.md | 8 +- skills/data-report/SKILL.md | 8 +- skills/deck-guizang-editorial/SKILL.md | 8 +- skills/deck-open-slide-canvas/SKILL.md | 8 +- skills/deck-swiss-international/SKILL.md | 8 +- skills/doc-kami-parchment/SKILL.md | 8 +- skills/frame-data-chart-nyt/SKILL.md | 8 +- skills/frame-flowchart-sticky/SKILL.md | 8 +- skills/frame-glitch-title/SKILL.md | 8 +- skills/frame-light-leak-cinema/SKILL.md | 8 +- skills/frame-liquid-bg-hero/SKILL.md | 8 +- skills/frame-logo-outro/SKILL.md | 8 +- skills/frame-macos-notification/SKILL.md | 8 +- skills/mockup-device-3d/SKILL.md | 8 +- skills/poster-hero/SKILL.md | 8 +- skills/ppt-keynote/SKILL.md | 8 +- skills/resume-modern/SKILL.md | 8 +- skills/social-reddit-card/SKILL.md | 8 +- skills/social-spotify-card/SKILL.md | 8 +- skills/social-x-post-card/SKILL.md | 8 +- skills/vfx-text-cursor/SKILL.md | 8 +- skills/video-hyperframes/SKILL.md | 8 +- 100 files changed, 1401 insertions(+), 321 deletions(-) create mode 100644 apps/web/src/components/plugins-home/localization.ts diff --git a/apps/daemon/src/plugins/apply.ts b/apps/daemon/src/plugins/apply.ts index 7b03434d5..0c248fd06 100644 --- a/apps/daemon/src/plugins/apply.ts +++ b/apps/daemon/src/plugins/apply.ts @@ -146,8 +146,12 @@ export function applyPlugin(input: ApplyInput): ApplyComputed { autoAtom, ); + const pluginTitle = resolveLocalizedText(manifest.title_i18n, input.locale) || (manifest.title ?? manifest.name); + const pluginDescription = + resolveLocalizedText(manifest.description_i18n, input.locale) || manifest.description; + const projectMetadata: PluginProjectMetadataPatch = { - name: manifest.title ?? manifest.name, + name: pluginTitle, taskKind, }; const skillRef = pickFirstSkillId(manifest); @@ -187,8 +191,8 @@ export function applyPlugin(input: ApplyInput): ApplyComputed { mcpServers, pipeline: appliedPipeline, genuiSurfaces, - pluginTitle: manifest.title ?? manifest.name, - pluginDescription: manifest.description, + pluginTitle, + pluginDescription, query: queryText || undefined, status: 'fresh', }; diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index be293de17..2baafa962 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -3547,13 +3547,15 @@ export async function startServer({ bundledMarketplaceEntries = result.registered.map((plugin) => ({ name: `open-design/${plugin.id}`, title: plugin.title, - description: plugin.description, + title_i18n: plugin.manifest.title_i18n, + description: plugin.manifest.description, + description_i18n: plugin.manifest.description_i18n, version: plugin.version, source: bundledPluginRegistrySource(plugin.source), publisher: { id: 'open-design', url: 'https://open-design.ai' }, homepage: plugin.manifest.homepage, license: plugin.manifest.license, - tags: plugin.tags, + tags: plugin.manifest.tags, capabilitiesSummary: Array.isArray(plugin.manifest.od?.capabilities) ? plugin.manifest.od.capabilities : undefined, diff --git a/apps/daemon/src/skills.ts b/apps/daemon/src/skills.ts index 1ed1a120b..b7ff72181 100644 --- a/apps/daemon/src/skills.ts +++ b/apps/daemon/src/skills.ts @@ -33,9 +33,15 @@ type JsonRecord = Record; interface SkillFrontmatter extends JsonRecord { name?: unknown; + zh_name?: unknown; + en_name?: unknown; description?: unknown; + zh_description?: unknown; + en_description?: unknown; triggers?: unknown; od?: JsonRecord & { + example_prompt?: unknown; + example_prompt_i18n?: unknown; craft?: JsonRecord; preview?: JsonRecord; design_system?: JsonRecord; @@ -53,7 +59,9 @@ export type SkillSource = "user" | "built-in"; export interface SkillInfo { id: string; name: string; + displayName?: Record; description: string; + descriptionI18n?: Record; triggers: unknown[]; mode: SkillMode; surface: SkillSurface; @@ -76,6 +84,7 @@ export interface SkillInfo { speakerNotes: boolean | null; animations: boolean | null; examplePrompt: string; + examplePromptI18n?: Record; aggregatesExamples: boolean; /** * Per-skill Critique Theater override declared via `od.critique.policy` @@ -195,6 +204,12 @@ export async function listSkills( : "html"; const description = typeof data.description === "string" ? data.description : ""; + const displayName = localizedMapFromFields(data.en_name, data.zh_name); + const descriptionI18n = localizedMapFromFields( + data.en_description, + data.zh_description, + ); + const examplePromptI18n = localizedMapFromRecord(data.od?.example_prompt_i18n); const parentBody = hasAttachments ? withSkillRootPreamble(body, dir) : body; @@ -209,7 +224,9 @@ export async function listSkills( out.push({ id: parentId, name: parentId, + ...(displayName ? { displayName } : {}), description, + ...(descriptionI18n ? { descriptionI18n } : {}), triggers: Array.isArray(data.triggers) ? data.triggers : [], mode, surface, @@ -231,6 +248,7 @@ export async function listSkills( speakerNotes: normalizeBoolHint(data.od?.speaker_notes), animations: normalizeBoolHint(data.od?.animations), examplePrompt: derivePrompt(data), + ...(examplePromptI18n ? { examplePromptI18n } : {}), aggregatesExamples, critiquePolicy: normalizeCritiquePolicy(data.od?.critique?.policy), body: parentBody, @@ -254,6 +272,7 @@ export async function listSkills( id: derivedId, name: humanizeExampleName(example.key), description, + ...(descriptionI18n ? { descriptionI18n } : {}), triggers: Array.isArray(data.triggers) ? data.triggers : [], mode, surface, @@ -271,6 +290,7 @@ export async function listSkills( speakerNotes: normalizeBoolHint(data.od?.speaker_notes), animations: normalizeBoolHint(data.od?.animations), examplePrompt: derivePrompt(data), + ...(examplePromptI18n ? { examplePromptI18n } : {}), aggregatesExamples: false, // Derived cards inherit the parent's critique policy so a // single SKILL.md that opts in (or out) applies the same @@ -501,6 +521,26 @@ function normalizeBoolHint(value: unknown): boolean | null { return null; } +function localizedMapFromFields( + enValue: unknown, + zhValue: unknown, +): Record | undefined { + const out: Record = {}; + if (typeof enValue === "string" && enValue.trim()) out.en = enValue.trim(); + if (typeof zhValue === "string" && zhValue.trim()) out["zh-CN"] = zhValue.trim(); + return Object.keys(out).length > 0 ? out : undefined; +} + +function localizedMapFromRecord(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined; + const out: Record = {}; + for (const [key, raw] of Object.entries(value)) { + if (typeof raw !== "string" || !raw.trim()) continue; + out[key] = raw.trim(); + } + return Object.keys(out).length > 0 ? out : undefined; +} + /** * Coerce `od.critique.policy` from SKILL.md frontmatter into the * three-value union the rollout resolver expects. Anything unrecognised diff --git a/apps/daemon/tests/skills.test.ts b/apps/daemon/tests/skills.test.ts index 7bea73bdb..ef819afc8 100644 --- a/apps/daemon/tests/skills.test.ts +++ b/apps/daemon/tests/skills.test.ts @@ -75,6 +75,53 @@ function writeSkill( } describe('listSkills', () => { + it('surfaces optional localized display metadata from SKILL.md frontmatter', async () => { + const root = fresh(); + try { + const dir = path.join(root, 'localized'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + path.join(dir, 'SKILL.md'), + [ + '---', + 'name: localized', + 'zh_name: "本地化技能"', + 'en_name: "Localized Skill"', + 'description: "English fallback description."', + 'zh_description: "中文描述。"', + 'en_description: "English localized description."', + 'od:', + ' example_prompt: "English fallback prompt."', + ' example_prompt_i18n:', + ' zh-CN: "中文 prompt。"', + '---', + '', + '# Localized skill body', + '', + ].join('\n'), + ); + + const skills = await listSkills(root); + expect(skills[0]).toMatchObject({ + id: 'localized', + displayName: { + en: 'Localized Skill', + 'zh-CN': '本地化技能', + }, + descriptionI18n: { + en: 'English localized description.', + 'zh-CN': '中文描述。', + }, + examplePrompt: 'English fallback prompt.', + examplePromptI18n: { + 'zh-CN': '中文 prompt。', + }, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('includes the built-in live-artifact skill catalog entry', async () => { const skills = await listSkills(designTemplatesRoot); const skill = skills.find((entry: { id: string }) => entry.id === 'live-artifact'); diff --git a/apps/web/src/components/ChatComposer.tsx b/apps/web/src/components/ChatComposer.tsx index d1bc57561..c86c51a89 100644 --- a/apps/web/src/components/ChatComposer.tsx +++ b/apps/web/src/components/ChatComposer.tsx @@ -8,8 +8,12 @@ import { useState, type ReactNode, } from "react"; -import { useT } from '../i18n'; +import { useI18n, useT } from '../i18n'; import type { Dict } from '../i18n/types'; +import { + localizeSkillDescription, + localizeSkillName, +} from '../i18n/content'; import { useAnalytics } from '../analytics/provider'; import { trackChatPanelClick, @@ -2136,6 +2140,7 @@ function ToolsSkillsPanel({ currentSkillId: string | null; onPick: (skill: SkillSummary) => void | Promise; }) { + const { locale } = useI18n(); const [query, setQuery] = useState(''); const [pendingId, setPendingId] = useState(null); const visibleSkills = useMemo( @@ -2176,11 +2181,11 @@ function ToolsSkillsPanel({ } }} disabled={pendingId !== null} - title={skill.description} + title={localizeSkillDescription(locale, skill)} > - {skill.name} + {localizeSkillName(locale, skill)} {skill.mode} {skill.surface ? ` · ${skill.surface}` : ''} @@ -2419,6 +2424,7 @@ function MentionPopover({ onPickMcp: (server: McpServerConfig) => void; onPickConnector: (connector: ConnectorDetail) => void; }) { + const { locale } = useI18n(); const ref = useRef(null); const [tab, setTab] = useState('all'); const tabs: Array<{ id: MentionTab; label: string }> = [ @@ -2506,13 +2512,13 @@ function MentionPopover({ type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => onPickSkill(skill)} - title={skill.description} + title={localizeSkillDescription(locale, skill)} > - {skill.name} + {localizeSkillName(locale, skill)} - {skill.description || skill.id} + {localizeSkillDescription(locale, skill) || skill.id} {active ? 'Active' : skill.mode} diff --git a/apps/web/src/components/ExamplesTab.tsx b/apps/web/src/components/ExamplesTab.tsx index 8cc641711..f80061f5b 100644 --- a/apps/web/src/components/ExamplesTab.tsx +++ b/apps/web/src/components/ExamplesTab.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useI18n } from '../i18n'; import { localizeSkillDescription, + localizeSkillName, localizeSkillPrompt, } from '../i18n/content'; import type { Dict } from '../i18n/types'; @@ -318,9 +319,10 @@ export function ExamplesTab({ skills: rawSkills, onUsePrompt }: Props) { if (!matchesSurface(s, surfaceFilter) || !matchesMode(s, modeFilter)) return false; if (scenarioFilter !== 'all' && (s.scenario || 'general') !== scenarioFilter) return false; if (!q) return true; + const name = localizeSkillName(locale, s); const desc = localizeSkillDescription(locale, s); const prompt = localizeSkillPrompt(locale, s) || ''; - const haystack = `${s.name} ${desc} ${prompt} ${s.scenario ?? ''}`.toLowerCase(); + const haystack = `${name} ${s.name} ${desc} ${prompt} ${s.scenario ?? ''}`.toLowerCase(); return haystack.includes(q); }); // Featured magazine-style examples float to the top (lower priority @@ -457,7 +459,7 @@ export function ExamplesTab({ skills: rawSkills, onUsePrompt }: Props) { const unavailableKind = previewUnavailable[previewSkill.id]; return ( previewSkill.name} + exportTitleFor={() => localizeSkillName(locale, previewSkill)} onClose={() => setPreviewSkillId(null)} /> ); @@ -568,7 +570,8 @@ function ExampleCard({ }; }, [shareOpen]); - const exportTitle = skill.name; + const displayName = localizeSkillName(locale, skill); + const exportTitle = displayName; const isMobile = skill.platform === 'mobile'; const isDeck = skill.mode === 'deck'; const displayPrompt = localizeSkillPrompt(locale, skill); @@ -601,7 +604,7 @@ function ExampleCard({ {html ? ( <>