mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-04 05:00:20 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user