feat(landing-page): read manifest title_i18n / description_i18n on bundled plugins

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<locale, string>) 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.
This commit is contained in:
Joey-nexu
2026-05-27 10:11:27 +08:00
parent a47f6f533d
commit 7ddfe3640e
3 changed files with 107 additions and 15 deletions

View File

@@ -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

View File

@@ -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<Record<string, string>>;
/** Manifest `description`. */
description: string;
/** Manifest `description_i18n` map. See {@link titleI18n} comment. */
descriptionI18n?: Readonly<Record<string, string>>;
/** Manifest `tags`. */
tags: ReadonlyArray<string>;
/** 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<string> {
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<string,
* string>`) and we don't want a malformed manifest to poison the loader.
*/
function asLocaleMap(v: unknown): Readonly<Record<string, string>> | undefined {
if (!v || typeof v !== 'object' || Array.isArray(v)) return undefined;
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(v as Record<string, unknown>)) {
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<Record<string, string>> | 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),

View File

@@ -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 = [
<span>/</span>
<a href={href('/plugins/')}>{pcopy.hubLabel}</a>
<span>/</span>
<span aria-current="page">{plugin.title}</span>
<span aria-current="page">{pluginTitle}</span>
</nav>
<article class="detail">
@@ -104,7 +112,7 @@ const jsonLd = [
<span class="label">
{pcopy.hubLabel} · {pcopy.detailBucketLabel[plugin.bucket]}
</span>
<h1 class="display">{plugin.title}<span class="dot">.</span></h1>
<h1 class="display">{pluginTitle}<span class="dot">.</span></h1>
<p class="lead">{description}</p>
<div class="detail-actions">
<a class="btn btn-primary" href="https://github.com/nexu-io/open-design/releases" target="_blank" rel="noopener">
@@ -163,12 +171,12 @@ const jsonLd = [
<details class="detail-preview-live">
<summary
class="detail-preview-thumb-trigger"
aria-label={`Open interactive preview for ${plugin.title}`}
aria-label={`Open interactive preview for ${pluginTitle}`}
>
<img
class="detail-preview-static"
src={plugin.previewPoster}
alt={`${plugin.title} preview`}
alt={`${pluginTitle} preview`}
loading="eager"
fetchpriority="high"
/>
@@ -179,7 +187,7 @@ const jsonLd = [
<div class="detail-preview-frame-wrap">
<iframe
src={plugin.previewEntryUrl}
title={`${plugin.title} interactive preview`}
title={`${pluginTitle} interactive preview`}
loading="lazy"
sandbox="allow-scripts allow-same-origin"
class="detail-preview-frame"
@@ -199,7 +207,7 @@ const jsonLd = [
<img
class="detail-preview-static"
src={plugin.previewPoster}
alt={`${plugin.title} preview`}
alt={`${pluginTitle} preview`}
loading="eager"
fetchpriority="high"
/>