fix(landing-page): drop CJK template wrap when source name is still English

The Chinese / Japanese / Korean fallback templates for craft, skill,
template, system, plugin, and blog text splice the source `name` /
`title` into a CJK sentence frame: ``${name}工艺规则``,
``Open Design 指南:${topic}``, ``${name} は…のスキルです``. When the
underlying SKILL.md / craft markdown / blog frontmatter still ships
an English name (true for ~95% of the catalog today), that produces
mid-sentence script straddling on `/zh/...`, `/zh-tw/...`, `/ja/...`,
`/ko/...` like:

  H1   : "Editorial typography hierarchy工艺规则"
  Lead : "这条 Open Design 工艺规则定义 Editorial typography hierarchy
          的执行标准…"
  Plug : "video 插件 · 3D Animated Boy Building Lego"

That reads worse than the all-English fallback, because the visitor
parses the page in two scripts at once.

Adds a `nameNeedsEnglishFallback` guard that fires for the four CJK
locales whenever the spliced-in name has no CJK characters of its
own, and threads it through every `localizeXxxText` helper:
craft, template, system, plugin, skill, blog. When it fires the
helper returns the raw English content untouched, so the section
renders end-to-end in one language. Chrome (header, footer, breadcrumb,
buttons, share dialog) keeps its CJK rendering — only the
title-and-lead block falls back.

Side benefit: the same guard kicks in on the long tail of plugin
manifests still pending `title_i18n` / `description_i18n` backfill
(tracked in #3028), so `/zh/plugins/<bundled>/` no longer pairs a
"video 插件 · 3D Animated Boy Building Lego" title with a Chinese
breadcrumb. The page reads "3D Animated Boy Building Lego" + the
English manifest description, while header / footer / breadcrumbs
stay localized. Once a manifest ships its i18n maps, the chrome and
body re-converge automatically.

Non-CJK non-Latin scripts (ar, vi, ...) keep the previous behavior —
their templates already read tolerably with English names. If that
turns out to be wrong on a real audit, the same guard generalizes by
adding the matching Unicode range and locale set.
This commit is contained in:
Joey-nexu
2026-05-27 11:00:19 +08:00
parent cf85dbafbc
commit 002d457cd4

View File

@@ -711,6 +711,32 @@ export function localizeContentTag(
return localizeTaxonomyValue(value, locale) ?? copyFor(locale)?.unknownTag;
}
/*
* Mixed-language guard used by every `localizeXxxText` helper below.
*
* The legacy fallback templates for craft / template / system / plugin /
* blog are Chinese / Japanese / Korean sentences that splice an English
* `name` into themselves: ``${name}工艺规则`` produces "Editorial
* typography hierarchy 工艺规则" when the source material is still in
* English. That mid-sentence script switch reads as broken on
* `/zh/...`, `/zh-tw/...`, `/ja/...`, `/ko/...` even when chrome around
* it is fully localized.
*
* Until the source-of-truth (SKILL.md frontmatter, design-system /
* craft markdown) ships per-locale `name` fields, the cleaner UX is to
* render the section in English on a CJK locale: chrome stays in the
* visitor's language, the body reads like an untranslated source
* snippet (which is what it actually is), and the awkward script
* straddling goes away.
*/
const CJK_CHAR_RE = /[぀-ゟ゠-ヿㇰ-ㇿ가-힯一-鿿豈-﫿]/;
const CJK_LOCALES = new Set<LandingLocaleCode>(['zh', 'zh-tw', 'ja', 'ko']);
function nameNeedsEnglishFallback(name: string, locale: LandingLocaleCode): boolean {
if (!CJK_LOCALES.has(locale)) return false;
return !CJK_CHAR_RE.test(name);
}
export function localizeSkillDescription(args: {
name: string;
mode?: string;
@@ -721,6 +747,7 @@ export function localizeSkillDescription(args: {
}): string {
const copy = copyFor(args.locale);
if (!copy) return args.fallback;
if (nameNeedsEnglishFallback(args.name, args.locale)) return args.fallback;
const labels = [args.mode, args.scenario, args.category]
.map((value) => localizeTaxonomyValue(value, args.locale))
.filter((value): value is string => Boolean(value));
@@ -743,6 +770,13 @@ export function localizeSystemText(args: {
atmosphere: args.fallbackAtmosphere,
};
}
if (nameNeedsEnglishFallback(args.name, args.locale)) {
return {
category: args.category,
tagline: args.fallbackTagline,
atmosphere: args.fallbackAtmosphere,
};
}
const category = localizeTaxonomyValue(args.category, args.locale) ?? copy.systemNoun;
return {
category,
@@ -760,6 +794,9 @@ export function localizeCraftText(args: {
const copy = copyFor(args.locale);
if (!copy) return { name: args.name, summary: args.summary };
const baseName = CRAFT_LABELS[args.slug]?.[args.locale] ?? args.name;
if (nameNeedsEnglishFallback(baseName, args.locale)) {
return { name: args.name, summary: args.summary };
}
return {
name: copy.craftName(baseName),
summary: copy.craftSummary(baseName),
@@ -773,6 +810,9 @@ export function localizeTemplateText(args: {
}): { name: string; summary: string } {
const copy = copyFor(args.locale);
if (!copy) return { name: args.name, summary: args.summary };
if (nameNeedsEnglishFallback(args.name, args.locale)) {
return { name: args.name, summary: args.summary };
}
return {
name: copy.templateName(args.name),
summary: copy.templateSummary(args.name),
@@ -798,6 +838,13 @@ export function localizePluginText(args: {
exampleQuery: undefined,
};
}
if (nameNeedsEnglishFallback(args.title, args.locale)) {
return {
title: args.title,
description: args.description,
exampleQuery: undefined,
};
}
const kind =
localizeTaxonomyValue(args.mode ?? args.surface ?? args.visualKind, args.locale) ??
copy.pluginNoun;
@@ -827,7 +874,20 @@ export function localizeBlogPostText(args: {
bodyHtml: undefined,
};
}
// Blog posts go through `localizedBlogTopic`, which has its own per-id
// translation table; if the topic isn't there the helper returns the raw
// English title — wrapping that in a Chinese sentence template ("Open
// Design 指南BYOK reality check") would mix scripts the same way craft
// / template / system do. Same guard applies.
const topic = localizedBlogTopic(args.id, args.locale);
if (nameNeedsEnglishFallback(topic, args.locale)) {
return {
title: args.title,
summary: args.summary,
category: args.category,
bodyHtml: undefined,
};
}
const title = copy.blogTitle(topic);
const summary = copy.blogSummary(topic);
return {