feat(i18n): 全站接入 next-intl,删除 lib/i18n 自建系统#287
Conversation
- 新增 i18n/request.ts(getRequestConfig,读 locale cookie)
- 新增 messages/zh.json + messages/en.json(16+ 命名空间)
- next.config.mjs 切换到 withNextIntl("./i18n/request.ts")
- layout.tsx 换 NextIntlClientProvider,移除 LocaleProvider
- P0: Header/Hero/Footer → getTranslations
- P1: SettingsForm/LoginPage/NotFound → getTranslations/useTranslations
- P2: HotDocsPreview/PageFeedback/UserMenu/DocShareButton/DocHistoryPanel/EditorMetadataForm/Contribute
- Profile: page.tsx/ActivityHeatmap/ProfileCard/EditLinkIfOwner/FollowButton/GithubRepos/edit/page.tsx/EditProfileForm → next-intl
- 删除 lib/i18n/client.tsx、messages.ts、server.ts
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
profile 下的 follow/activity/card/repos/docs/edit 统一从独立顶层 namespace 改为 profile.xxx 嵌套路径,与 translator 发布的新字典结构一致。 pageFeedback → feedback namespace,sec3/sec4 titleField key 修正。
There was a problem hiding this comment.
Pull request overview
此 PR 将站点从自建 lib/i18n 翻译方案迁移到 next-intl(App Router 原生方案),并引入 JSON messages 与 request 级别的 locale/messages 解析。
Changes:
- 接入 next-intl(
createNextIntlPlugin+i18n/request.ts+NextIntlClientProvider),移除旧的lib/i18n/*实现 - 新增中英文 messages(
messages/zh.json、messages/en.json)并将多个页面/组件迁移到getTranslations()/useTranslations() - 对 settings/profile/editor/feedback 等 UI 文案进行本地化替换
Reviewed changes
Copilot reviewed 29 out of 29 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| next.config.mjs | next-intl 插件配置指向新的 request config 文件 |
| messages/zh.json | 新增中文 messages(多命名空间) |
| messages/en.json | 新增英文 messages(多命名空间) |
| lib/i18n/server.ts | 删除旧 i18n 服务端工具 |
| lib/i18n/messages.ts | 删除旧 i18n 字典与格式化逻辑 |
| lib/i18n/client.tsx | 删除旧 i18n 客户端 Provider/hook |
| i18n/request.ts | 新增 next-intl request 级别 locale/messages 装载逻辑 |
| app/u/[username]/page.tsx | Profile 页面改用 next-intl server translations |
| app/u/[username]/edit/page.tsx | Profile 编辑页改用 next-intl server translations |
| app/u/[username]/edit/EditProfileForm.tsx | Profile 编辑表单改用 next-intl client translations |
| app/u/[username]/ProfileCard.tsx | ProfileCard 改用 next-intl client translations |
| app/u/[username]/GithubRepos.tsx | GitHub repos 区块改用 next-intl server translations |
| app/u/[username]/FollowButton.tsx | FollowButton 改用 next-intl client translations |
| app/u/[username]/EditLinkIfOwner.tsx | 编辑入口文案改用 next-intl client translations |
| app/u/[username]/ActivityHeatmap.tsx | 活跃热力图改用 next-intl server translations |
| app/settings/SettingsForm.tsx | Settings toast/label 改用 next-intl client translations |
| app/not-found.tsx | 404 页面文案改用 next-intl |
| app/login/page.tsx | 登录页文案改用 next-intl |
| app/layout.tsx | 用 NextIntlClientProvider 替换旧 LocaleProvider,并注入 messages |
| app/components/UserMenu.tsx | 用户菜单文案改用 next-intl |
| app/components/PageFeedback.tsx | 反馈组件文案改用 next-intl |
| app/components/HotDocsPreview.tsx | 热门文档预览引入 next-intl 翻译 |
| app/components/Hero.tsx | Hero 组件改为 async 并引入 next-intl 翻译 |
| app/components/Header.tsx | Header 改为 async 并引入 next-intl 翻译 |
| app/components/Footer.tsx | Footer 改为 async 并引入 next-intl 翻译 |
| app/components/EditorMetadataForm.tsx | 编辑器元数据表单文案改用 next-intl |
| app/components/DocShareButton.tsx | 分享按钮文案改用 next-intl |
| app/components/DocHistoryPanel.tsx | 历史面板文案/相对时间改用 next-intl |
| app/components/Contribute.tsx | 投稿组件文案改用 next-intl |
Comments suppressed due to low confidence (2)
app/u/[username]/edit/EditProfileForm.tsx:112
- 这里使用
useTranslations("edit"),但messages/*中对应 key 实际在profile.edit.*下。按当前结构会导致表单内所有文案 missing key。建议将 namespace 改为profile.edit(或同步把消息结构改成顶层edit)。
export function EditProfileForm({ targetIdentifier }: Props) {
const { user, status } = useAuth();
const router = useRouter();
const t = useTranslations("profile.edit");
const [prefs, setPrefs] = useState<Preferences>(EMPTY_PREFS);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
app/components/HotDocsPreview.tsx:56
- 这里仍然硬编码了英文标题/按钮文案(
Hot This Week、MORE),导致在中文 locale 下也会显示英文,且与下方{t("subtitle")}产生重复/混搭。PR 描述与测试计划强调按 locale 切换文案,建议将这些硬编码文本也改为使用hotDocs命名空间(例如subtitle/more),或调整布局避免同一含义渲染两次。
<div>
<div className="font-serif text-lg font-black uppercase text-[var(--foreground)]">
Hot This Week
</div>
<div className="font-mono text-[10px] uppercase tracking-widest text-neutral-500">
{t("subtitle")}
</div>
</div>
<Link
href="/rank?tab=hot&window=7d"
className="font-mono text-[10px] uppercase tracking-widest font-bold text-[var(--foreground)] hover:text-[#CC0000] transition-colors flex items-center gap-1 group"
data-umami-event="navigation_click"
data-umami-event-region="hot_docs_preview"
data-umami-event-label="MORE"
>
MORE
<span className="transform group-hover:translate-x-0.5 transition-transform">
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export default async function EditProfilePage({ params }: Param) { | ||
| const { username } = await params; | ||
| const t = await getServerT(); | ||
| const t = await getTranslations("profile.edit"); | ||
| return ( |
There was a problem hiding this comment.
这里使用 getTranslations("edit"),但 messages/* 中编辑页文案位于 profile.edit.*(edit 不是顶层 namespace)。这会触发 missing key。建议把 namespace 改为 profile.edit(或调整消息文件结构把 edit 提升到顶层)。
| */ | ||
| export function FollowButton({ identifier, targetUserId }: Props) { | ||
| const { user, status } = useAuth(); | ||
| const t = useT(); | ||
| const t = useTranslations("profile.follow"); | ||
| const [followerCount, setFollowerCount] = useState<number | null>(null); | ||
| const [followingCount, setFollowingCount] = useState<number | null>(null); | ||
| const [isFollowing, setIsFollowing] = useState(false); |
There was a problem hiding this comment.
这里使用 useTranslations("follow"),但 messages/* 中 follow 文案位于 profile.follow.*(非顶层 follow)。按当前写法会导致按钮/统计文案 missing key。建议将 namespace 改为 profile.follow(或把消息文件调整为顶层 follow)。
| export function PageFeedback() { | ||
| const pathname = usePathname(); | ||
| const t = useTranslations("feedback"); | ||
| const [voted, setVoted] = useState<"helpful" | "not_helpful" | null>(null); |
There was a problem hiding this comment.
useTranslations("pageFeedback") 对应的命名空间在 messages/zh.json/en.json 中不存在(当前是 feedback.question / feedback.thanks)。这会触发 next-intl missing key。建议将 namespace 改为 feedback(或同步新增 pageFeedback 命名空间)。
|
|
||
| const t = await getServerT(); | ||
| const t = await getTranslations("profile"); | ||
| const tDocs = await getTranslations("profile.docs"); |
There was a problem hiding this comment.
tDocs 通过 getTranslations("docs") 获取,但 messages/* 中 docs 并不是顶层命名空间,而是在 profile.docs 下(见 messages/zh.json 的 profile.docs.*)。这会导致 docs 区块翻译 missing key。建议改为 getTranslations("profile.docs")(或复用 getTranslations("profile") 并用 t("docs.*"))。
| export async function GithubRepos({ identifier }: Props) { | ||
| const repos = await fetchRepos(identifier); | ||
| if (repos.length === 0) return null; | ||
| const t = await getServerT(); | ||
| const t = await getTranslations("profile.repos"); | ||
|
|
There was a problem hiding this comment.
这里使用 getTranslations("repos"),但 messages/* 中 repos 文案位于 profile.repos.*(非顶层 repos)。当前会触发 missing key。建议改为 getTranslations("profile.repos") 或改消息文件结构与代码对齐。
| {/* | ||
| LocaleProvider 把服务端读出的 locale 注入客户端 Context, | ||
| 客户端组件通过 useT() 拿到翻译函数,保持 SSR/CSR 一致, | ||
| 不在客户端重新读 cookie 避免水合抖动。 | ||
| */} | ||
| <LocaleProvider locale={locale}> | ||
| <NextIntlClientProvider locale={locale} messages={messages}> |
There was a problem hiding this comment.
RootLayout 里这段注释仍在描述已删除的 LocaleProvider / useT()(现在改成了 NextIntlClientProvider + useTranslations)。建议更新注释以匹配当前实现,避免后续维护者误解 i18n 注入链路。
| const searchApi = `/search.${locale}.json`; | ||
| const messages = await getMessages(); | ||
| return ( | ||
| <html lang="zh-CN" suppressHydrationWarning> |
There was a problem hiding this comment.
当前 <html lang="zh-CN"> 是硬编码的,但这里已经根据 cookie/next-intl 设置了 locale 并注入 NextIntlClientProvider。为无障碍与 SEO(屏幕阅读器语言、拼写检查等),建议让 lang 随 locale 切换(例如 zh 用 zh-CN,en 用 en/en-US),并避免出现 provider locale 与 lang 不一致的情况。
| const searchApi = `/search.${locale}.json`; | |
| const messages = await getMessages(); | |
| return ( | |
| <html lang="zh-CN" suppressHydrationWarning> | |
| const htmlLang = locale === "en" ? "en" : "zh-CN"; | |
| const searchApi = `/search.${locale}.json`; | |
| const messages = await getMessages(); | |
| return ( | |
| <html lang={htmlLang} suppressHydrationWarning> |
| <p className="font-body text-neutral-600 dark:text-neutral-400 mb-8 max-w-md text-justify leading-relaxed"> | ||
| 一个由开发者自发组织的、完全免费且开放的学习社区。我们相信通过集体协作与开放共享,可以打破技术垄断,创造一个更公平的学习环境。 | ||
| {t("tagline")} | ||
| </p> | ||
| <div className="flex space-x-2"> | ||
| <a | ||
| href="https://github.com/involutionhell" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| aria-label="访问 GitHub" | ||
| title="访问 GitHub" | ||
| aria-label={t("github.ariaLabel")} | ||
| title={t("github.ariaLabel")} | ||
| data-umami-event="social_click" | ||
| data-umami-event-platform="github" | ||
| data-umami-event-location="footer" | ||
| className="w-12 h-12 flex items-center justify-center border border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-all text-[var(--foreground)]" | ||
| > | ||
| <Github className="h-5 w-5" /> | ||
| <span className="sr-only">访问 GitHub</span> | ||
| <span className="sr-only">{t("github.srOnly")}</span> | ||
| </a> | ||
| <a | ||
| href="https://discord.com/invite/6CGP73ZWbD" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| aria-label="加入 Discord 社区" | ||
| title="加入 Discord 社区" | ||
| aria-label={t("discord.ariaLabel")} | ||
| title={t("discord.ariaLabel")} |
There was a problem hiding this comment.
Footer 里取的翻译 key 与 messages/* 不匹配:当前使用了 tagline、github.ariaLabel/github.srOnly、discord.ariaLabel/discord.srOnly,但 messages/zh.json/en.json 的 footer 下是 brand 和 a11y.github/a11y.discord。这会触发 next-intl missing message key(并在生产环境显示 key 文本)。建议统一 key:要么把代码改成读 brand + a11y.*,要么把消息文件改成与代码一致的结构。
| @@ -79,21 +82,19 @@ export function Hero() { | |||
| </div> | |||
|
|
|||
| <div className="border border-[var(--foreground)] p-6 bg-[var(--foreground)] text-[var(--background)]"> | |||
| <h3 className="font-serif text-2xl mb-4">Join the Resistance</h3> | |||
| <h3 className="font-serif text-2xl mb-4">{t("join.title")}</h3> | |||
| <p className="font-body text-sm mb-6 opacity-80"> | |||
| Connect with thousands of developers who are reclaiming their | |||
| passion for technology. | |||
| {t("join.body")} | |||
| </p> | |||
| <Link | |||
| href="/docs/ai" | |||
| className="block w-full" | |||
| // Umami 埋点: Hero CTA 按钮点击 | |||
| data-umami-event="navigation_click" | |||
| data-umami-event-region="hero_cta" | |||
| data-umami-event-label="Access Articles" | |||
| > | |||
| <button className="w-full py-3 border border-[var(--background)] font-sans text-xs uppercase tracking-widest hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all cursor-pointer"> | |||
| Access Articles / 访问文章 | |||
| {t("cta")} | |||
| </button> | |||
| </Link> | |||
There was a problem hiding this comment.
Hero 里使用了 t("tagline") 和 t("cta"),但 messages/* 中对应 key 是 hero.mission 与 hero.cta.access(cta 是对象不是字符串)。这会导致 missing key / 渲染出错。建议改为读取现有 key(例如 mission、cta.access),或同步调整消息文件结构。
| {/* 跳转的投稿指南 */} | ||
| <a | ||
| href="https://github.com/InvolutionHell/involutionhell?tab=contributing-ov-file#%E6%8A%95%E7%A8%BF%E6%8C%87%E5%8D%97" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| aria-label="查看投稿指南" | ||
| title="查看投稿指南" | ||
| aria-label={t("guideAriaLabel")} | ||
| title={t("guideAriaLabel")} | ||
| className="absolute top-0 right-0 flex h-10 w-10 translate-x-1/2 -translate-y-1/2 items-center justify-center border border-[var(--foreground)] bg-[var(--background)] text-[var(--foreground)] font-mono hover:bg-[#CC0000] hover:text-white transition-colors z-20" | ||
| > | ||
| <span className="text-sm font-bold">?</span> | ||
| <span className="sr-only">查看投稿指南</span> | ||
| <span className="sr-only">{t("guideAriaLabel")}</span> | ||
| </a> |
There was a problem hiding this comment.
Contribute 使用了 t("guideAriaLabel") 作为投稿指南链接的 aria-label/title,但 messages/* 的 contribute 命名空间下没有这个 key(当前在 hero.cta.guideAriaLabel 才存在)。这会触发 missing key。建议:把该文案移动/复制到 contribute 命名空间(例如 contribute.guideAriaLabel),或把这里的 namespace/key 改为实际存在的位置。
Footer: tagline→brand, github/discord.ariaLabel/srOnly→a11y.github/a11y.discord Hero: tagline→mission, cta→cta.access Contribute: 字典补 guideAriaLabel key(zh+en),不依赖 hero namespace layout: html lang 硬编码改为 locale 驱动,注释更新为 next-intl 描述
Summary
i18n/request.ts(读 locale cookie → 选 zh/en messages)+messages/zh.json+messages/en.json,覆盖 16+ 命名空间getServerT()/useT()迁至getTranslations()/useTranslations(),范围涵盖 Header/Hero/Footer、Settings、Login、NotFound、HotDocsPreview、PageFeedback、UserMenu、DocShareButton、DocHistoryPanel、EditorMetadataForm、Contribute、以及全部 Profile(page.tsx/ActivityHeatmap/ProfileCard/EditLinkIfOwner/FollowButton/GithubRepos/edit)lib/i18n/client.tsx、messages.ts、server.ts;layout.tsx 移除 LocaleProviderTest plan
/u/{username}热力图月份标签随 locale 正确渲染(zh: "1月",en: "Jan")