fix: 语言切换全面修复 —— 品牌层/主题首页/详情区块/404 全量 i18n(zh 矩阵 13/13)
- 站点品牌双语:每站 brandZh(品牌名/标语/hero/导航/页脚/搜索占位)由生成器写入 站点 messages 的 zh 覆盖;SiteChrome 改为 async getTranslations 按语言渲染 - 五套主题首页接线 siteHome/hero 命名空间(新增 wire-theme-i18n.py 一次性接线脚本, 仅处理组件函数体、hook 自动插入、hero 用 th 命名空间) - 详情页 Use cases/Good for/FAQ/reviewCta、用例与合集索引与详情、提交页、 对比页、Alternatives 表头与 FAQ 全部走 next-intl - 404 双语言:locale 内走 next-intl,根级按 Accept-Language - 修复 zh.json 中 84 处 zhXxx 旁挂键归位 + en.json 残留清理(i18n 694 键一致) - 回归:/zh 下 13 页全中文无英文残留、无横向溢出;五站 /zh lang=zh 全部正确
This commit is contained in:
@@ -23,6 +23,15 @@ const SITES_ROOT = path.join(REPO_ROOT, "sites");
|
||||
// ====================================================================
|
||||
const SITES = {
|
||||
"ai-stack": {
|
||||
brandZh: {
|
||||
name: "AI Stack",
|
||||
tagline: "由真实用户排名的专业 AI 工具目录。",
|
||||
headline: "找到最好的 AI 工具,由真实用户排名",
|
||||
subheadline: "14 个分类、1000+ AI 工具。对比投票、评论与定价,选出最适合你的。",
|
||||
nav: ["发现", "用例", "最佳清单", "排行榜", "博客", "提交工具"],
|
||||
footer: ["发现", "社区", "法律"],
|
||||
searchPlaceholder: "搜索 1000+ AI 工具...",
|
||||
},
|
||||
accent: "222 47% 11%", // 近黑(专业)
|
||||
port: 3001,
|
||||
theme: "classic",
|
||||
@@ -82,6 +91,15 @@ const SITES = {
|
||||
},
|
||||
},
|
||||
launchai: {
|
||||
brandZh: {
|
||||
name: "LaunchAI",
|
||||
tagline: "发现并投票今日的 AI 新品。",
|
||||
headline: "今日 AI 产品发布",
|
||||
subheadline: "每天上新。为最好的产品投票,冲上排行榜。",
|
||||
nav: ["今日", "排行榜", "分类", "合集", "博客", "提交产品"],
|
||||
footer: ["社区", "探索", "关于"],
|
||||
searchPlaceholder: "搜索 AI 产品...",
|
||||
},
|
||||
accent: "16 85% 55%", // 橙红(产品社区)
|
||||
port: 3002,
|
||||
theme: "product",
|
||||
@@ -139,6 +157,15 @@ const SITES = {
|
||||
},
|
||||
},
|
||||
"the-ai-ledger": {
|
||||
brandZh: {
|
||||
name: "The AI Ledger",
|
||||
tagline: "独立 AI 报道,附社区排名目录。",
|
||||
headline: "AI 工具,评测与排名",
|
||||
subheadline: "指南、对比,以及由社区驱动的最佳 AI 工具目录。",
|
||||
nav: ["文章", "指南", "最佳推荐", "目录", "排行榜", "提交"],
|
||||
footer: ["阅读", "目录", "公司"],
|
||||
searchPlaceholder: "搜索目录...",
|
||||
},
|
||||
accent: "36 70% 40%", // 纸墨棕(编辑部)
|
||||
port: 3003,
|
||||
theme: "editorial",
|
||||
@@ -197,6 +224,15 @@ const SITES = {
|
||||
},
|
||||
},
|
||||
"devai-tools": {
|
||||
brandZh: {
|
||||
name: "DevAI.tools",
|
||||
tagline: "面向开发者的 AI 工具——精选、实测、社区排名。",
|
||||
headline: "开发者的 AI 工具",
|
||||
subheadline: "代码助手、运维自动化、测试副驾驶,你上手的一切。",
|
||||
nav: ["工具", "工作流", "用例", "排行榜", "提交"],
|
||||
footer: ["目录", "构建", "法律"],
|
||||
searchPlaceholder: "搜索开发者工具...",
|
||||
},
|
||||
accent: "150 60% 30%", // 终端绿(开发者)
|
||||
port: 3004,
|
||||
theme: "niche",
|
||||
@@ -253,6 +289,15 @@ const SITES = {
|
||||
},
|
||||
},
|
||||
findai: {
|
||||
brandZh: {
|
||||
name: "findai",
|
||||
tagline: "极简 AI 工具搜索引擎。",
|
||||
headline: "你想完成什么?",
|
||||
subheadline: "",
|
||||
nav: ["搜索", "用例", "排行榜", "提交"],
|
||||
footer: ["搜索", "关于"],
|
||||
searchPlaceholder: "你需要什么?",
|
||||
},
|
||||
accent: "260 70% 52%", // 紫(极简搜索)
|
||||
port: 3005,
|
||||
theme: "minimal",
|
||||
@@ -501,13 +546,25 @@ import { locales, defaultLocale, type Locale } from "${corePkg}/i18n.config";
|
||||
const brandMessages = {
|
||||
en: {
|
||||
seo: { home: ${JSON.stringify({ title: site.seo.en.title, description: site.seo.en.description, ogImage: "/og-image.png" })} },
|
||||
hero: { title: ${JSON.stringify(site.seo.en.title)}, description: ${JSON.stringify(site.seo.en.description)} },
|
||||
hero: { title: ${JSON.stringify(site.brand.headline)}, description: ${JSON.stringify(site.brand.subheadline || site.seo.en.description)} },
|
||||
common: { brand: { name: ${JSON.stringify(site.brand.name)}, tagline: ${JSON.stringify(site.brand.tagline)} } },
|
||||
navigation: {
|
||||
main: { tools: ${JSON.stringify(site.navItems[0]?.label ?? "Discover")}, blog: ${JSON.stringify(site.navItems.find((n) => n.href === "/blog")?.label ?? "Blog")} },
|
||||
site: { items: ${JSON.stringify(site.navItems.map((n) => n.label))}, hrefs: ${JSON.stringify(site.navItems.map((n) => n.href))} },
|
||||
},
|
||||
siteFooter: { groups: ${JSON.stringify(site.footerGroups)} },
|
||||
siteHeader: { searchPlaceholder: ${JSON.stringify(site.searchPlaceholder)} },
|
||||
},
|
||||
zh: {
|
||||
seo: { home: ${JSON.stringify({ title: site.seo.zh.title, description: site.seo.zh.description, ogImage: "/og-image.png" })} },
|
||||
hero: { title: ${JSON.stringify(site.seo.zh.title)}, description: ${JSON.stringify(site.seo.zh.description)} },
|
||||
common: { brand: { name: ${JSON.stringify(site.brand.name)}, tagline: ${JSON.stringify(site.brand.tagline)} } },
|
||||
hero: { title: ${JSON.stringify(site.brandZh?.headline ?? site.seo.zh.title)}, description: ${JSON.stringify(site.brandZh?.subheadline || site.seo.zh.description)} },
|
||||
common: { brand: { name: ${JSON.stringify(site.brandZh?.name ?? site.brand.name)}, tagline: ${JSON.stringify(site.brandZh?.tagline ?? site.brand.tagline)} } },
|
||||
navigation: {
|
||||
main: { tools: ${JSON.stringify(site.brandZh?.nav?.[0] ?? site.navItems[0]?.label ?? "发现")}, blog: ${JSON.stringify(site.brandZh?.nav?.[site.navItems.findIndex((n) => n.href === "/blog")] ?? "博客")} },
|
||||
site: { items: ${JSON.stringify(site.brandZh?.nav ?? site.navItems.map((n) => n.label))}, hrefs: ${JSON.stringify(site.navItems.map((n) => n.href))} },
|
||||
},
|
||||
siteFooter: { groups: ${JSON.stringify(site.footerGroups.map((g, gi) => ({ ...g, title: (site.brandZh?.footer ?? [])[gi] ?? g.title })))} },
|
||||
siteHeader: { searchPlaceholder: ${JSON.stringify(site.brandZh?.searchPlaceholder ?? site.searchPlaceholder)} },
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -649,7 +706,30 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
base.common.brand = { ...(base.common.brand ?? {}), name: site.brand.name, tagline: site.brand.tagline };
|
||||
base.seo = base.seo ?? {};
|
||||
base.seo.home = { ...(base.seo.home ?? {}), ...site.seo[locale] };
|
||||
base.hero = { ...(base.hero ?? {}), title: site.seo[locale].title, description: site.seo[locale].description };
|
||||
base.hero = {
|
||||
...(base.hero ?? {}),
|
||||
title: locale === "zh" ? (site.brandZh?.headline ?? site.seo.zh.title) : site.brand.headline,
|
||||
description: locale === "zh" ? (site.brandZh?.subheadline || site.seo.zh.description) : (site.brand.subheadline || site.seo.en.description),
|
||||
badge: locale === "zh" ? "" : base.hero?.badge ?? "",
|
||||
};
|
||||
if (locale === "zh") delete base.hero.badge;
|
||||
// 站点导航/页脚/搜索文案(供 ThemeChrome 消费)
|
||||
base.navigation = {
|
||||
...(base.navigation ?? {}),
|
||||
site: {
|
||||
items: locale === "zh" ? (site.brandZh?.nav ?? site.navItems.map((n) => n.label)) : site.navItems.map((n) => n.label),
|
||||
hrefs: site.navItems.map((n) => n.href),
|
||||
},
|
||||
};
|
||||
base.siteFooter = {
|
||||
groups: site.footerGroups.map((g, gi) => ({
|
||||
...g,
|
||||
title: locale === "zh" ? ((site.brandZh?.footer ?? [])[gi] ?? g.title) : g.title,
|
||||
})),
|
||||
};
|
||||
base.siteHeader = {
|
||||
searchPlaceholder: locale === "zh" ? (site.brandZh?.searchPlaceholder ?? site.searchPlaceholder) : site.searchPlaceholder,
|
||||
};
|
||||
fs.mkdirSync(path.join(siteRoot, "messages"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(siteRoot, "messages", `${locale}.json`),
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
# 脚本 wire-theme-i18n
|
||||
# 说明:一次性把五个主题模板首页的硬编码文案接线到 next-intl(siteHome/hero 命名空间)。
|
||||
# 规则:
|
||||
# - import 加 useTranslations
|
||||
# - 每个含 t( 调用的函数组件体开头插入 hook 声明
|
||||
# - 文案字符串替换为 t("key") / {t("key")}(JSX 子节点带花括号,属性值带花括号)
|
||||
# - 顶层常量(navItems/footerGroups)不改(导航/页脚由 SiteChrome i18n 提供,这些常量是死代码但无害)
|
||||
# 运行:python3 scripts/wire-theme-i18n.py
|
||||
"""
|
||||
import re
|
||||
|
||||
# 每个模板的替换表:(原文案, i18n key, 是否 JSX 子节点)
|
||||
COMMON = [
|
||||
# classic
|
||||
('>Find the best AI tools, ranked by real users<', 'heroTitle', True),
|
||||
('>1,000+ AI tools across 14 categories. Compare votes, reviews and pricing to pick the right\n one.<', 'heroSubtitle', True),
|
||||
('placeholder="Search 1,000+ AI tools..."', 'searchPlaceholder', False),
|
||||
('<span>Popular:</span>', 'popularLabel', True),
|
||||
('>\n Search\n </button>', 'searchButton', True),
|
||||
('"Trending this week"', 'trendingTitle', False),
|
||||
('"Ranked by votes, reviews and engagement over the last 7 days"', 'trendingSubtitle', False),
|
||||
('"All rankings"', 'allRankings', False),
|
||||
('"Browse by category"', 'categoriesTitle', False),
|
||||
('"All tools"', 'categoriesViewAll', False),
|
||||
('{category.toolCount} tools', 'toolsCount', 'ctx:category.toolCount'),
|
||||
('"New AI tools"', 'newTitle', False),
|
||||
('"Editor\'s Choice"', 'editorsTitle', False),
|
||||
('"Tools by use case"', 'useCasesTitle', False),
|
||||
('"What do you want to get done?"', 'useCasesSubtitle', False),
|
||||
('"All use cases"', 'useCasesViewAll', False),
|
||||
('"Best AI tool lists"', 'bestListsTitle', False),
|
||||
('"Curated rankings by our editors"', 'bestListsSubtitle', False),
|
||||
('"All lists"', 'bestListsViewAll', False),
|
||||
('"{col.toolCount} tools"', 'toolsCount', 'ctx:col.toolCount'),
|
||||
('"Latest community reviews"', 'reviewsTitle', False),
|
||||
# product
|
||||
('placeholder="Search AI products..."', 'searchPlaceholder', False),
|
||||
('>Today\'s AI product launches<', 'heroTitle', True),
|
||||
('>Fresh AI products, every day. Upvote the best ones to the top.<', 'heroSubtitle', True),
|
||||
('Launch your product', 'launchButton', False),
|
||||
('"Today\'s feed"', 'feedTitle', False),
|
||||
('No launches yet — be the first to submit.', 'noLaunches', False),
|
||||
('Browse all products', 'feedBrowse', False),
|
||||
('"Fresh launches"', 'newTitle', False),
|
||||
('"Leaderboard"', 'leaderboardTitle', False),
|
||||
('Full leaderboard', 'leaderboardFull', False),
|
||||
('"{tool.reviewCount} reviews"', 'reviewsCount', 'ctx:tool.reviewCount'),
|
||||
('"Collections"', 'collectionsTitle', False),
|
||||
# editorial
|
||||
('Independent AI Coverage', 'mastheadLabel', False),
|
||||
('Featured story', 'featuredLabel', False),
|
||||
('Read the story', 'featuredRead', False),
|
||||
('Editor\'s picks', 'picksEditorialLabel', False),
|
||||
('"Buying guides"', 'guidesTitle', False),
|
||||
('"Deep dives that help you choose"', 'guidesSubtitle', False),
|
||||
('"All guides"', 'guidesViewAll', False),
|
||||
('tools compared', 'toolsCompared', False),
|
||||
('"Latest stories"', 'storiesTitle', False),
|
||||
('"All stories"', 'storiesViewAll', False),
|
||||
('"From the community"', 'communityTitle', False),
|
||||
('"Browse directory"', 'communityViewAll', False),
|
||||
# niche
|
||||
('placeholder="Search dev tools..."', 'searchPlaceholder', False),
|
||||
('>AI tools for developers<', 'heroTitle', True),
|
||||
('>Code assistants, DevOps automation, testing copilots and everything else you ship with.<', 'heroSubtitle', True),
|
||||
('Browse tools\n', 'browseButton', True),
|
||||
('"Ship faster with AI"', 'shipTitle', False),
|
||||
('"Pick your workflow — we ranked the tools for it"', 'shipSubtitle', False),
|
||||
('"Most upvoted this week"', 'upvotedTitle', False),
|
||||
('"View all"', 'upvotedViewAll', False),
|
||||
('"Workflows & stacks"', 'workflowsTitle', False),
|
||||
('"Batteries-included tool stacks for common dev tasks"', 'workflowsSubtitle', False),
|
||||
('"All workflows"', 'workflowsViewAll', False),
|
||||
# minimal
|
||||
('AI TOOL SEARCH', 'minimalTagline', False),
|
||||
('placeholder="I need to create marketing videos..."', 'minimalPlaceholder', False),
|
||||
('"Trending now"', 'trendingNowTitle', False),
|
||||
('See full rankings', 'seeRankings', False),
|
||||
]
|
||||
|
||||
NEEDS = {
|
||||
"heroTitle": ("hero", "title"), "heroSubtitle": ("hero", "description"),
|
||||
"searchPlaceholder": ("siteHome", "searchPlaceholder"),
|
||||
"popularLabel": ("siteHome", "popularLabel"),
|
||||
"trendingTitle": ("siteHome", "trendingTitle"), "trendingSubtitle": ("siteHome", "trendingSubtitle"),
|
||||
"allRankings": ("siteHome", "allRankings"),
|
||||
"categoriesTitle": ("siteHome", "categoriesTitle"), "categoriesViewAll": ("siteHome", "categoriesViewAll"),
|
||||
"newTitle": ("siteHome", "newTitle"), "editorsTitle": ("siteHome", "editorsTitle"),
|
||||
"useCasesTitle": ("siteHome", "useCasesTitle"), "useCasesSubtitle": ("siteHome", "useCasesSubtitle"),
|
||||
"useCasesViewAll": ("siteHome", "useCasesViewAll"),
|
||||
"bestListsTitle": ("siteHome", "bestListsTitle"), "bestListsSubtitle": ("siteHome", "bestListsSubtitle"),
|
||||
"bestListsViewAll": ("siteHome", "bestListsViewAll"),
|
||||
"reviewsTitle": ("siteHome", "reviewsTitle"),
|
||||
"ctaTitle": ("siteHome", "ctaTitle"), "ctaBody": ("siteHome", "ctaBody"), "ctaButton": ("siteHome", "ctaButton"),
|
||||
"launchButton": ("siteHome", "launchButton"),
|
||||
"feedTitle": ("siteHome", "feedTitle"), "noLaunches": ("siteHome", "noLaunches"),
|
||||
"feedBrowse": ("siteHome", "feedBrowse"),
|
||||
"leaderboardTitle": ("siteHome", "leaderboardTitle"), "leaderboardFull": ("siteHome", "leaderboardFull"),
|
||||
"picksTitle": ("siteHome", "picksTitle"), "collectionsTitle": ("siteHome", "collectionsTitle"),
|
||||
"mastheadLabel": ("siteHome", "mastheadLabel"),
|
||||
"featuredLabel": ("siteHome", "featuredLabel"), "featuredRead": ("siteHome", "featuredRead"),
|
||||
"picksEditorialLabel": ("siteHome", "picksEditorialLabel"),
|
||||
"guidesTitle": ("siteHome", "guidesTitle"), "guidesSubtitle": ("siteHome", "guidesSubtitle"),
|
||||
"guidesViewAll": ("siteHome", "guidesViewAll"),
|
||||
"storiesTitle": ("siteHome", "storiesTitle"), "storiesViewAll": ("siteHome", "storiesViewAll"),
|
||||
"communityTitle": ("siteHome", "communityTitle"), "communityViewAll": ("siteHome", "communityViewAll"),
|
||||
"shipTitle": ("siteHome", "shipTitle"), "shipSubtitle": ("siteHome", "shipSubtitle"),
|
||||
"upvotedTitle": ("siteHome", "upvotedTitle"), "upvotedViewAll": ("siteHome", "upvotedViewAll"),
|
||||
"workflowsTitle": ("siteHome", "workflowsTitle"), "workflowsSubtitle": ("siteHome", "workflowsSubtitle"),
|
||||
"workflowsViewAll": ("siteHome", "workflowsViewAll"),
|
||||
"browseButton": ("siteHome", "browseButton"),
|
||||
"browseCategoryTitle": ("siteHome", "browseCategoryTitle"),
|
||||
"minimalTagline": ("siteHome", "minimalTagline"),
|
||||
"minimalPlaceholder": ("siteHome", "minimalPlaceholder"),
|
||||
"trendingNowTitle": ("siteHome", "trendingNowTitle"),
|
||||
"seeRankings": ("siteHome", "seeRankings"),
|
||||
"searchButton": ("siteHome", "searchButton"),
|
||||
"toolsCount": ("siteHome", "toolsCount"),
|
||||
"toolsCompared": ("siteHome", "toolsCompared"),
|
||||
"reviewsCount": ("siteHome", "reviewsCount"),
|
||||
}
|
||||
|
||||
TEMPLATES = [
|
||||
"src/kit/site-themes/classic/index.tsx",
|
||||
"src/kit/site-themes/product/index.tsx",
|
||||
"src/kit/site-themes/editorial/index.tsx",
|
||||
"src/kit/site-themes/niche/index.tsx",
|
||||
"src/kit/site-themes/minimal/index.tsx",
|
||||
]
|
||||
|
||||
def add_import(src):
|
||||
if "useTranslations" not in src:
|
||||
m = re.search(r'import \{ ([^}]+) \} from "lucide-react";\n', src)
|
||||
assert m, "lucide import not found"
|
||||
src = src.replace(m.group(0), m.group(0) + 'import { useTranslations } from "next-intl";\n', 1)
|
||||
return src
|
||||
|
||||
def add_hooks(src):
|
||||
"""为每个使用了 t( 的函数组件体开头插入 hook 声明"""
|
||||
out = []
|
||||
# 找所有 function 块开头
|
||||
for m in re.finditer(r'((?:async )?function (\w+)\(\{[^}]*locale[^}]*\}(?:[^)]*)\) \{\n)', src):
|
||||
header, fname = m.group(1), m.group(2)
|
||||
start = m.end()
|
||||
# 找函数结束(下一个顶层 function 或文件尾)
|
||||
nxt = re.search(r'\n(?:async )?function ', src[start:])
|
||||
end = start + (nxt.start() if nxt else len(src) - start)
|
||||
body = src[start:end]
|
||||
if 't("' not in body and 't(\'"' not in body:
|
||||
continue
|
||||
if 'const t = useTranslations' in body:
|
||||
continue
|
||||
hooks = ' const t = useTranslations("siteHome");\n'
|
||||
if any(k in body for k in ("heroTitle", "heroSubtitle")):
|
||||
hooks += ' const th = useTranslations("hero");\n'
|
||||
src = src[:start] + hooks + src[start:]
|
||||
# 重新计算(插入后偏移变化),直接 break 重跑
|
||||
return add_hooks(src)
|
||||
return src
|
||||
|
||||
def apply_replacements(src, fname):
|
||||
used = set()
|
||||
# 只在组件函数体内部替换(跳过顶层常量——导航/页脚由 SiteChrome i18n 提供)
|
||||
first_fn = re.search(r'(?:async )?function \w+', src)
|
||||
body_start = first_fn.start() if first_fn else len(src)
|
||||
head, body = src[:body_start], src[body_start:]
|
||||
for original, key, mode in COMMON:
|
||||
ns, k = NEEDS[key] if key in NEEDS else (None, None)
|
||||
tref = f'{ns}["{k}"]' if ns else key # hero 用 th
|
||||
fn = "th" if ns == "hero" else "t"
|
||||
tref = f'{fn}("{k}")'
|
||||
if original.startswith('>'):
|
||||
# JSX 子节点(含换行缩进的两段式原文用正则容忍空白)
|
||||
pat = re.compile(re.escape(original).replace(r'\ ', r'\s+'))
|
||||
if pat.search(body):
|
||||
body = pat.sub(f'>{tref}<', body, count=1)
|
||||
used.add(key)
|
||||
elif mode == 'ctx':
|
||||
ctx_expr = original.replace('{', '').replace('}', '').split()[0]
|
||||
pat = re.compile(re.escape(original).replace(r'\ ', r'\s+'))
|
||||
if pat.search(body):
|
||||
body = pat.sub(f'{{{tref}({ctx_expr})}}', body, count=1)
|
||||
used.add(key)
|
||||
elif mode is False or mode is None:
|
||||
# 引号内字符串 → 属性 {t("key")} 或 JSX 子节点 {t("key")}
|
||||
if original in body:
|
||||
body = body.replace(original, f'{{{tref}}}')
|
||||
used.add(key)
|
||||
return head + body, used
|
||||
|
||||
def main():
|
||||
for p in TEMPLATES:
|
||||
src = open(p).read()
|
||||
src = add_import(src)
|
||||
src, used = apply_replacements(src, p)
|
||||
src = add_hooks(src)
|
||||
open(p, 'w').write(src)
|
||||
# 报告未使用的 key(该模板不需要)
|
||||
unused = [k for k in NEEDS if k not in used]
|
||||
print(f"{p}: wired {len(used)} keys")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user