From 7ddfe3640e7d5e03117857d8e1cd77f1ce639074 Mon Sep 17 00:00:00 2001 From: Joey-nexu Date: Wed, 27 May 2026 10:11:27 +0800 Subject: [PATCH] feat(landing-page): read manifest title_i18n / description_i18n on bundled plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #3010's prior rounds localized chrome and chip rails but the catalog's most prominent text — each row's plugin name and blurb — stayed English on every non-English locale. The plugin manifest schema (`packages/contracts/src/plugins/manifest.ts`) has supported `title_i18n` and `description_i18n` (Record) on every manifest from spec v1; ~24 of the 401 first-party manifests already carry one for `zh-CN`. The reader was just never wired to use them. This change does the reader half: bundled-plugins.ts captures the two i18n maps off each `open-design.json`, plugin-row.astro and the detail page resolve them at render time via two new helpers (`resolveBundledTitle`, `resolveBundledDescription`) that mirror the short→long fallback chain documented in the manifest spec (`htmlLang` like `zh-CN` → short `LandingLocaleCode` like `zh` → primary tag → `en` → English baseline). The static-paths pass still runs once for all locales — it has to, since each manifest produces one URL — but the title/description shown on the rendered page now reads the locale off `Astro.url.pathname` and picks the right entry out of the maps. Verified locally: `/zh/plugins/example-card-twitter/` now reads "Twitter 分享卡 / 推特金句 / 数据卡, 适合配推文" from the manifest's existing `zh-CN` block instead of the English baseline. Plugin-data half follows in a separate commit. The 17 non-English locales × 401 manifests need backfilling so the reader has something to resolve to; that's data, not schema, and lands as a sequence of manifest patches rather than tangled with this code change. --- .../app/_components/plugin-row.astro | 10 ++- apps/landing-page/app/_lib/bundled-plugins.ts | 82 ++++++++++++++++++- .../app/pages/plugins/[slug]/index.astro | 30 ++++--- 3 files changed, 107 insertions(+), 15 deletions(-) diff --git a/apps/landing-page/app/_components/plugin-row.astro b/apps/landing-page/app/_components/plugin-row.astro index 9578205ace..48e9ea06c2 100644 --- a/apps/landing-page/app/_components/plugin-row.astro +++ b/apps/landing-page/app/_components/plugin-row.astro @@ -9,7 +9,11 @@ * forcing every callsite to write the same five-column markup twice. */ import type { SkillRecord, TemplateRecord } from '../_lib/catalog'; -import type { BundledPluginRecord } from '../_lib/bundled-plugins'; +import { + resolveBundledDescription, + resolveBundledTitle, + type BundledPluginRecord, +} from '../_lib/bundled-plugins'; import { localeFromPath, localizedHref } from '../i18n'; import { localizeTaxonomyValue } from '../content-i18n'; @@ -74,8 +78,8 @@ if (item.kind === 'skill') { // the row gets the diagonal-stripe placeholder and styling stays // consistent with the rest of the catalog. detailHref = item.record.detailHref; - name = item.record.title; - description = item.record.description; + name = resolveBundledTitle(item.record, locale); + description = resolveBundledDescription(item.record, locale); previewUrl = item.record.previewPoster ?? null; // Bundled-plugin records ship raw taxonomy slugs (e.g. `video`, `image`). // Run them through the shared TAXONOMY_TERMS map so the chip rail localizes diff --git a/apps/landing-page/app/_lib/bundled-plugins.ts b/apps/landing-page/app/_lib/bundled-plugins.ts index b0be336d18..abc99c2149 100644 --- a/apps/landing-page/app/_lib/bundled-plugins.ts +++ b/apps/landing-page/app/_lib/bundled-plugins.ts @@ -21,6 +21,11 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + DEFAULT_LOCALE, + getLocaleDefinition, + type LandingLocaleCode, +} from '../i18n'; const SOURCE_ROOTS = [ // Build run from monorepo root. @@ -54,10 +59,19 @@ export interface BundledPluginRecord { manifestId: string; /** Source bucket. */ bucket: BundledBucket; - /** Manifest `title`. */ + /** Manifest `title` (English baseline; pre-localization fallback). */ title: string; + /** + * Manifest `title_i18n` map keyed by locale (long code, e.g. `zh-CN`, + * `zh-TW`, `pt-BR`, `ja`). Authors fill this opportunistically; consumers + * should resolve via {@link resolveBundledTitle} so the lookup chain + * (long code → short code → English fallback) stays consistent. + */ + titleI18n?: Readonly>; /** Manifest `description`. */ description: string; + /** Manifest `description_i18n` map. See {@link titleI18n} comment. */ + descriptionI18n?: Readonly>; /** Manifest `tags`. */ tags: ReadonlyArray; /** Manifest `author.name`. */ @@ -100,7 +114,9 @@ export interface BundledPluginRecord { interface BundledManifestRaw { name?: unknown; title?: unknown; + title_i18n?: unknown; description?: unknown; + description_i18n?: unknown; tags?: unknown; author?: { name?: unknown; url?: unknown }; homepage?: unknown; @@ -150,6 +166,66 @@ function asStringArray(v: unknown): ReadonlyArray { return v.filter((x): x is string => typeof x === 'string'); } +/** + * Coerce a manifest's `title_i18n` / `description_i18n` payload to a plain + * `{ [locale]: string }` map. Anything that isn't a string-valued object is + * dropped — the schema permits one of two shapes (omitted or `Record`) and we don't want a malformed manifest to poison the loader. + */ +function asLocaleMap(v: unknown): Readonly> | undefined { + if (!v || typeof v !== 'object' || Array.isArray(v)) return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(v as Record)) { + if (typeof value === 'string' && value.length > 0) out[key] = value; + } + return Object.keys(out).length > 0 ? Object.freeze(out) : undefined; +} + +/** + * Resolve a localized field from a manifest's `title_i18n` / + * `description_i18n` map. Manifest authors store keys using the long codes + * preferred by the `LocalizedText` schema (`zh-CN`, `zh-TW`, `pt-BR`, `ja`), + * while landing pages thread the short `LandingLocaleCode` (`zh`, `zh-tw`, + * `pt-br`, `ja`). The lookup chain mirrors `resolveLocalizedText` from + * `packages/contracts/src/plugins/manifest.ts`: long code → short code → + * primary language tag → English → caller-supplied fallback. + */ +function resolveLocalized( + map: Readonly> | undefined, + fallback: string, + locale: LandingLocaleCode, +): string { + if (!map) return fallback; + const def = getLocaleDefinition(locale); + const candidates = [ + def?.htmlLang, + locale, + def?.htmlLang?.split('-')[0], + 'en', + ].filter((c): c is string => Boolean(c)); + for (const candidate of candidates) { + const value = map[candidate]; + if (typeof value === 'string' && value.length > 0) return value; + } + return fallback; +} + +/** Resolve a bundled plugin's title for a given locale, falling back to English. */ +export function resolveBundledTitle( + record: BundledPluginRecord, + locale: LandingLocaleCode = DEFAULT_LOCALE, +): string { + return resolveLocalized(record.titleI18n, record.title, locale); +} + +/** Resolve a bundled plugin's description for a given locale. */ +export function resolveBundledDescription( + record: BundledPluginRecord, + locale: LandingLocaleCode = DEFAULT_LOCALE, +): string { + return resolveLocalized(record.descriptionI18n, record.description, locale); +} + function REPO_FOR_BUCKET(bucket: BundledBucket): string { return `https://github.com/nexu-io/open-design/tree/main/plugins/_official/${bucket}`; } @@ -200,7 +276,9 @@ function loadOne( const manifestId = asString(raw.name) ?? slug; const title = asString(raw.title) ?? manifestId; + const titleI18n = asLocaleMap(raw.title_i18n); const description = asString(raw.description) ?? ''; + const descriptionI18n = asLocaleMap(raw.description_i18n); // Preference order: // 1. Manifest poster URL (R2/CDN, fastest, already bandwidth-paid). @@ -220,7 +298,9 @@ function loadOne( manifestId, bucket, title, + titleI18n, description, + descriptionI18n, tags: asStringArray(raw.tags), authorName: asString(raw.author?.name), authorUrl: asString(raw.author?.url), diff --git a/apps/landing-page/app/pages/plugins/[slug]/index.astro b/apps/landing-page/app/pages/plugins/[slug]/index.astro index 2e906eb33f..e9e7ed80ab 100644 --- a/apps/landing-page/app/pages/plugins/[slug]/index.astro +++ b/apps/landing-page/app/pages/plugins/[slug]/index.astro @@ -16,6 +16,8 @@ import Layout from '../../../_components/sub-page-layout.astro'; import { getBundledPlugins, + resolveBundledDescription, + resolveBundledTitle, type BundledPluginRecord, } from '../../../_lib/bundled-plugins'; import { getPluginsCopy } from '../../../_lib/plugins-i18n'; @@ -36,6 +38,12 @@ const { plugin } = Astro.props as Props; const locale = localeFromPath(Astro.url.pathname); const href = (path: string) => localizedHref(path, locale); const pcopy = getPluginsCopy(locale); +// Localized name + blurb resolved at render time via the `title_i18n` / +// `description_i18n` maps shipped alongside each manifest. The static-paths +// pass runs once for all locales, so the prop carries English baselines and +// the per-locale lookup happens here. +const pluginTitle = resolveBundledTitle(plugin, locale); +const pluginDescription = resolveBundledDescription(plugin, locale); /* * Author normalisation. First-party manifests authored by Open Design @@ -53,8 +61,8 @@ const authorUrl = plugin.authorUrl ? (ORG_TO_REPO[plugin.authorUrl.replace(/\/$/, '')] ?? plugin.authorUrl) : undefined; -const title = `${plugin.title} · Open Design plugin`; -const description = plugin.description; +const title = `${pluginTitle} · Open Design plugin`; +const description = pluginDescription; /* * Share-dialog copy. The English template stays as a fallback inside @@ -65,7 +73,7 @@ const description = plugin.description; * UX without a per-page script bundle. */ const pluginUrl = `https://open-design.ai${plugin.detailHref}`; -const shareCopy = pcopy.shareTemplate({ title: plugin.title, url: pluginUrl }); +const shareCopy = pcopy.shareTemplate({ title: pluginTitle, url: pluginUrl }); const jsonLd = [ { @@ -74,13 +82,13 @@ const jsonLd = [ itemListElement: [ { '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() }, { '@type': 'ListItem', position: 2, name: 'Plugins', item: new URL('/plugins/', Astro.site).toString() }, - { '@type': 'ListItem', position: 3, name: plugin.title, item: new URL(plugin.detailHref, Astro.site).toString() }, + { '@type': 'ListItem', position: 3, name: pluginTitle, item: new URL(plugin.detailHref, Astro.site).toString() }, ], }, { '@context': 'https://schema.org', '@type': 'SoftwareSourceCode', - name: plugin.title, + name: pluginTitle, description, codeRepository: plugin.sourceUrl, programmingLanguage: 'JSON', @@ -96,7 +104,7 @@ const jsonLd = [ / {pcopy.hubLabel} / - {plugin.title} + {pluginTitle}