feat(i18n): implement internationalization support for en and zh

Add i18n infrastructure with translation files and utility functions
Update components to use translations and language switching
Create localized pages for en and zh languages
Add language detection and path localization utilities
This commit is contained in:
joyzhao
2025-06-15 09:08:41 +08:00
parent 4ab809ed94
commit ee0fbcceb2
9 changed files with 470 additions and 450 deletions

View File

@@ -1,455 +1,335 @@
# Astro + React 项目国际化 (i18n) 指南
# Astro + React 项目国际化 (i18n) 实施步骤
本文档旨在指导如何在基于 Astro React 项目中实现国际化功能,特别是针对 Astro 4.0+ 版本内置的 i18n 支持
本文档将严格按照 Astro 官方文档的推荐实践,指导您一步步为您的 Astro + React 项目添加国际化功能。请在完成每个主要步骤后,与指导者确认,然后再进行下一步
## 1. Astro i18n 配置
## 基本配置与约定
首先,需要在 `astro.config.mjs` 文件中配置国际化选项。
- **默认语言**: `en` (英语)
- **支持语言**: `en` (英语), `zh` (简体中文)
- **URL 结构**: 不同语言的内容将通过 URL 子路径区分,例如 `example.com/en/``example.com/zh/`
- **翻译文件格式**: JSON
```javascript
// astro.config.mjs
import { defineConfig } from 'astro/config';
import react from "@astrojs/react";
import tailwind from "@tailwindcss/vite";
## 基本术语
export default defineConfig({
integrations: [react(), tailwind()],
i18n: {
defaultLocale: "en", // 默认语言
locales: ["en", "zh"], // 支持的语言列表
routing: {
prefixDefaultLocale: true, // 是否为默认语言添加前缀 (例如 /en/blog)
// 其他路由配置,例如 fallback
}
}
});
```
- **i18n**: Internationalization 的缩写,意为国际化。
- **L10n**: Localization 的缩写,意为本地化。
- **UI Strings**: 用户界面中需要翻译的文本,例如按钮标签、导航链接文本等。
**关键点**
- `defaultLocale`: 设置网站的默认语言。
- `locales`: 定义所有支持的语言。
- `routing.prefixDefaultLocale`: 决定默认语言的 URL 是否也带有语言前缀。设置为 `true` (例如 `/en/about`) 通常能提供更一致的 URL 结构,但如果希望默认语言的 URL 更简洁 (例如 `/about`),可以设置为 `false`
---
## 2. 组织多语言内容 (页面)
## 步骤 1配置 Astro i18n 并组织页面
Astro 的 i18n 路由策略是基于文件系统的。你需要根据语言在 `src/pages/` 目录下组织的页面文件。
此步骤的目标是配置 Astro 以支持多种语言,并相应地组织的页面文件结构
创建对应语言的子目录,例如:
### 1.1 配置 `astro.config.mjs`
```
src/
└── pages/
您需要在 `astro.config.mjs` 文件中定义国际化相关的配置。虽然 Astro 的核心 i18n 功能不直接在 `defineConfig` 中指定 `locales``defaultLocale` (这些通常是集成特定 i18n 库或中间件时的配置),但我们将遵循一种通用的模式,即通过目录结构来管理多语言内容。
目前,我们不需要修改 `astro.config.mjs` 来显式声明语言Astro 会根据 `src/pages/` 下的目录结构自动处理多语言路由。
### 1.2 组织多语言页面目录
1.`src/pages/` 目录下,为项目支持的每种语言创建一个子目录。根据我们的约定,您需要创建以下目录:
* `src/pages/en/`
* `src/pages/zh/`
2. 将您现有的页面文件(例如 `index.astro`, `about.astro` 等)复制到每个语言目录下。例如:
```tree
src/
└── pages/
├── en/
│ ├── index.astro
│ └── about.astro
│ └── about.astro (如果存在)
├── zh/
│ ├── index.astro
│ └── about.astro
└── index.astro // (可选) 如果 defaultLocale 不加前缀,这个作为根目录的默认语言页面
```
│ └── about.astro (如果存在)
└── index.astro (根目录的索引页,将用于重定向)
```
- 如果 `prefixDefaultLocale``true`,则所有语言(包括默认语言)的页面都应放在各自的语言子目录中 (例如 `src/pages/en/index.astro`, `src/pages/zh/index.astro`)。
- 如果 `prefixDefaultLocale``false`,默认语言的页面可以直接放在 `src/pages/` 根目录下 (例如 `src/pages/index.astro` 对应默认语言),其他语言的页面放在各自的子目录中 (例如 `src/pages/zh/index.astro`)。
### 1.3 设置默认语言重定向
## 3. 存储 UI 翻译文本
对于 UI 界面中需要翻译的文本(例如按钮文字、标签、提示信息等),建议使用 JSON 文件来管理。
`public/locales/` 目录下为每种语言创建一个 JSON 文件:
```
public/
└── locales/
├── en/
│ └── ui.json
└── zh/
└── ui.json
```
**示例 `public/locales/en/ui.json`**:
```json
{
"site.title": "My Portfolio",
"nav.home": "Home",
"nav.about": "About",
"nav.projects": "Projects",
"button.viewDetails": "View Details"
}
```
**示例 `public/locales/zh/ui.json`**:
```json
{
"site.title": "我的作品集",
"nav.home": "首页",
"nav.about": "关于",
"nav.projects": "项目",
"button.viewDetails": "查看详情"
}
```
## 4. 创建翻译辅助函数 (可选但推荐)
为了方便在 Astro 组件和 React 组件中获取翻译文本,可以创建一个辅助函数。
`src/i18n/utils.ts` (如果目录不存在则创建) 中:
```typescript
// src/i18n/utils.ts
import { ui } from './ui'; // 假设 ui.ts 用于加载 JSON
// 这是一个简化的示例,实际项目中可能需要更完善的加载和错误处理机制
// 或者直接在组件中 fetch JSON 文件
export function useTranslations(lang: keyof typeof ui) {
return function t(key: keyof typeof ui[typeof lang]) {
return ui[lang][key] || key; // 如果翻译不存在,返回 key 本身
}
}
```
你还需要一个 `src/i18n/ui.ts` 来实际加载这些 JSON 文件。Astro 官方文档提供了一种方式,但对于客户端 React 组件,可能需要调整。
一个更通用的方法是在组件中根据当前语言动态 `fetch` 对应的 `ui.json` 文件。
**或者,更简单的方式是直接在 Astro 页面/布局中获取当前语言,然后将翻译对象传递给 React 组件。**
## 5. 更新 LanguageSwitcher 组件
`LanguageSwitcher.tsx` 组件需要能够检测当前语言、列出可选语言,并在用户选择新语言时导航到对应的本地化 URL。
```typescript
// src/components/LanguageSwitcher.tsx
import { useState, useEffect } from 'react';
// 假设我们从 Astro.locals.lang 获取当前语言,并通过 props 传入
// 或者通过 window.location.pathname 解析
interface LanguageSwitcherProps {
currentLang: string;
availableLangs: Array<{ code: string; name: string }>; // 例如 [{ code: 'en', name: 'English' }, { code: 'zh', name: '中文' }]
}
const LanguageSwitcher = ({ currentLang, availableLangs }: LanguageSwitcherProps) => {
const [selectedLang, setSelectedLang] = useState(currentLang);
useEffect(() => {
setSelectedLang(currentLang);
}, [currentLang]);
const handleChangeLanguage = (langCode: string) => {
if (langCode !== selectedLang) {
// 获取当前路径,但不包含语言前缀
const currentPathname = window.location.pathname;
let basePath = '';
// 移除已有的语言前缀 (如果存在)
const langRegex = new RegExp(`^/(${availableLangs.map(l => l.code).join('|')})`);
basePath = currentPathname.replace(langRegex, '');
if (!basePath.startsWith('/')) {
basePath = '/' + basePath;
}
// Astro 的 i18n 路由会自动处理
// 如果 prefixDefaultLocale 为 true则所有语言都有前缀
// 如果为 false默认语言没有前缀
// 这里我们假设 Astro 的配置会处理好目标 URL
// 对于 Astro 4.0+,可以直接使用 astro:i18n 的 getRelativeLocaleUrl
// 但在 React 组件中,我们通常通过 window.location.href 来跳转
// 简单的跳转逻辑,实际项目中可能需要更精确的 URL 构建
// 考虑 Astro.config.mjs 中的 prefixDefaultLocale 设置
const astroConfig = {
defaultLocale: 'en', // 需要与 astro.config.mjs 一致
locales: ['en', 'zh'], // 需要与 astro.config.mjs 一致
prefixDefaultLocale: true // 需要与 astro.config.mjs 一致
};
let newPath;
if (langCode === astroConfig.defaultLocale && !astroConfig.prefixDefaultLocale) {
newPath = basePath;
} else {
newPath = `/${langCode}${basePath}`;
}
// 确保路径是干净的,例如移除双斜杠
newPath = newPath.replace(/\/\/+/g, '/');
if (newPath === '') newPath = '/';
window.location.href = newPath;
}
};
return (
<div className="language-switcher">
<select
value={selectedLang}
onChange={(e) => handleChangeLanguage(e.target.value)}
className="bg-gray-800 text-white p-2 rounded"
>
{availableLangs.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.name}
</option>
))}
</select>
</div>
);
};
export default LanguageSwitcher;
```
**在 Astro 布局或页面中使用 `LanguageSwitcher`**:
为了让访问网站根路径 (`/`) 的用户自动跳转到默认语言(我们约定为英语 `en`)的首页,请修改位于 `src/pages/index.astro` 的文件,内容如下 <mcreference index="0" link="https://docs.astro.build/zh-cn/recipes/i18n/">0</mcreference>
```astro
---
// src/layouts/Layout.astro
import LanguageSwitcher from '../components/LanguageSwitcher.tsx';
import { getRelativeLocaleUrl } from 'astro:i18n'; // Astro 4.0+ i18n helper
const { currentLocale } = Astro;
const availableLangs = [
{ code: 'en', name: 'English', url: getRelativeLocaleUrl('en') },
{ code: 'zh', name: '中文', url: getRelativeLocaleUrl('zh') },
// ... 其他语言
];
// 传递给 React 组件的 props
const langSwitcherProps = {
currentLang: currentLocale || 'en', // Astro.currentLocale 可能为 undefined
availableLangs: [
{ code: 'en', name: 'English' },
{ code: 'zh', name: '中文' },
]
};
// src/pages/index.astro
// This page will redirect to the default language's home page.
---
<html lang={currentLocale}>
<head>
<!-- ... -->
</head>
<body>
<header>
<LanguageSwitcher {...langSwitcherProps} client:load />
</header>
<slot />
</body>
</html>
<meta http-equiv="refresh" content="0;url=/en/" />
```
**注意**: `getRelativeLocaleUrl` 是在 Astro 文件中使用的。在 React 组件内部进行路由切换时,通常是直接修改 `window.location.href`。确保生成的 URL 与 Astro 的路由配置(特别是 `prefixDefaultLocale`)相匹配。
**关键点** <mcreference index="0" link="https://docs.astro.build/zh-cn/recipes/i18n/">0</mcreference>
## 6. 国际化静态数据 (例如 `src/lib/data.ts`)
* 这种 `meta refresh` 重定向方法适用于各种部署环境。
* 确保您的默认语言首页(例如 `src/pages/en/index.astro`)已创建并包含实际内容。
对于 `src/lib/data.ts` 中的静态数据,有两种主要的处理方式:
**完成后,请与指导者确认,然后再进行下一步。**
**方式一:为每种语言创建单独的数据文件**
```
src/
└── lib/
├── data.en.ts
├── data.zh.ts
└── data.ts // (可选) 作为默认或共享数据
```
然后在你的 Astro 页面或组件中,根据当前语言动态导入对应的数据文件:
```astro
---
// src/pages/en/index.astro (或任何需要数据的页面)
import { personalData, experiences, projects } from '../../lib/data.en';
// ...
---
```
```astro
---
// src/pages/zh/index.astro
import { personalData, experiences, projects } from '../../lib/data.zh';
// ...
---
```
## 步骤 2创建翻译文件 (UI Strings)
**方式二:在同一个数据文件中使用嵌套结构**
此步骤的目标是为网站用户界面 (UI) 元素创建翻译字典。这些字典将存储不同语言的文本字符串。
```typescript
// src/lib/data.ts
export const siteTitle = {
en: "My Awesome Portfolio",
zh: "我的炫酷作品集"
};
### 2.1 创建 `ui.ts` 文件
export const personalData = {
根据 Astro 官方文档的建议,我们将在 `src/i18n/` 目录下创建一个 `ui.ts` 文件来存储翻译字符串 <mcreference index="0" link="https://docs.astro.build/zh-cn/recipes/i18n/">0</mcreference>。
1. 在 `src/` 目录下创建一个名为 `i18n` 的新文件夹。
2. 在 `src/i18n/` 文件夹中创建一个名为 `ui.ts` 的文件。
```tree
src/
├── i18n/
│ └── ui.ts
└── pages/
...
```
3. 将以下内容添加到 `src/i18n/ui.ts` 文件中 <mcreference index="0" link="https://docs.astro.build/zh-cn/recipes/i18n/">0</mcreference>
```typescript
// src/i18n/ui.ts
export const languages = {
en: 'English',
zh: '简体中文',
} as const;
export const defaultLang = 'en';
export const ui = {
en: {
name: "Your Name",
tagline: "Full Stack Developer | Tech Enthusiast",
bio: "A short bio about yourself..."
'nav.home': 'Home',
'nav.projects': 'Projects',
'nav.experience': 'Experience',
'nav.skills': 'Skills',
'nav.awards': 'Awards',
'nav.education': 'Education',
'footer.rights': 'All rights reserved.',
// 根据您的项目实际情况添加更多翻译键值对
},
zh: {
name: "你的名字",
tagline: "全栈开发者 | 技术爱好者",
bio: "一段关于你自己的简短介绍..."
'nav.home': '首页',
'nav.projects': '项目经历',
'nav.experience': '工作经历',
'nav.skills': '专业技能',
'nav.awards': '奖项荣誉',
'nav.education': '教育背景',
'footer.rights': '版权所有。',
// 根据您的项目实际情况添加更多翻译键值对
},
} as const;
```
**关键点** <mcreference index="0" link="https://docs.astro.build/zh-cn/recipes/i18n/">0</mcreference>
* `languages`: 一个对象,键是语言代码,值是该语言的人类可读名称。这对于构建语言切换器非常有用。
* `defaultLang`: 指定项目的默认语言代码。
* `ui`: 一个对象,其键是语言代码。每个语言代码下又是一个对象,包含该语言的翻译键和对应的翻译文本。
* 我们使用点号 (`.`) 来组织翻译键的层级,例如 `nav.home`。
* `as const` 用于确保 TypeScript 将这些对象视为常量,提供更好的类型推断和自动完成。
* **请根据您项目中的实际 UI 文本,在 `ui.en` 和 `ui.zh` 对象中添加或修改相应的翻译键值对。** 例如,导航栏链接、页脚文本、按钮文本等。
**完成后,请与指导者确认,然后再进行下一步。**
---
## 步骤 3创建翻译辅助函数
此步骤的目标是创建一些辅助函数,以便在您的 Astro 页面和组件中更轻松地获取和使用翻译字符串。
### 3.1 创建 `utils.ts` 文件
我们将在 `src/i18n/` 目录下创建一个 `utils.ts` 文件来存放这些辅助函数 <mcreference index="0" link="https://docs.astro.build/zh-cn/recipes/i18n/">0</mcreference>。
1. 在 `src/i18n/` 文件夹中创建一个名为 `utils.ts` 的新文件。
```tree
src/
├── i18n/
│ ├── ui.ts
│ └── utils.ts
└── pages/
...
```
2. 将以下内容添加到 `src/i18n/utils.ts` 文件中 <mcreference index="0" link="https://docs.astro.build/zh-cn/recipes/i18n/">0</mcreference>
```typescript
// src/i18n/utils.ts
import { ui, defaultLang, languages } from './ui';
export type Lang = keyof typeof ui;
export type UiKeys = keyof typeof ui[typeof defaultLang];
export function getLangFromUrl(url: URL): Lang {
const [, lang] = url.pathname.split('/');
if (lang in ui) return lang as Lang;
return defaultLang;
}
};
export const experiences = {
en: [
{ title: "Software Engineer at Tech Corp", duration: "2020 - Present", description: "Developed amazing things..." },
],
zh: [
{ title: "软件工程师 @ 科技公司", duration: "2020 -至今", description: "开发了很棒的东西..." },
]
};
// ... 其他数据类似结构
```
export function useTranslations(lang: Lang | undefined) {
const currentLang = lang || defaultLang;
return function t(key: UiKeys, params?: Record<string, string | number>): string {
let translation = ui[currentLang][key] || ui[defaultLang][key];
if (params) {
Object.keys(params).forEach(paramKey => {
const regex = new RegExp(`\\{\\s*${paramKey}\\s*\\}`, 'g');
translation = translation.replace(regex, String(params[paramKey]));
});
}
return translation;
}
}
然后在组件中根据当前语言选择对应的数据:
export function getLocalizedPath(path: string, lang: Lang | undefined, base: string | URL = import.meta.env.BASE_URL): string {
const currentLang = lang || defaultLang;
const baseWithSlash = typeof base === 'string' && !base.endsWith('/') ? `${base}/` : String(base);
const langPath = currentLang === defaultLang ? '' : `${currentLang}/`;
const normalizedPath = path.startsWith('/') ? path.substring(1) : path;
return `${baseWithSlash}${langPath}${normalizedPath}`.replace(/\/\/+$/, '/'); // Ensure no double slashes at the end
}
```typescript
// 在 React 组件中 (假设 lang 作为 prop 传入)
// const currentData = experiences[lang] || experiences.en;
export { languages, defaultLang };
```
// 在 Astro 页面中
// ---
// import { experiences } from '../lib/data';
// const lang = Astro.currentLocale || 'en';
// const currentExperiences = experiences[lang] || experiences.en;
// ---
```
**关键点** <mcreference index="0" link="https://docs.astro.build/zh-cn/recipes/i18n/">0</mcreference>
选择哪种方式取决于你的偏好和数据复杂度。分离文件更清晰,但可能需要更多导入语句。嵌套结构更集中,但文件可能变得较大
* `Lang` 和 `UiKeys`: TypeScript 类型定义,用于增强代码的类型安全
* `Lang`: 表示支持的语言代码的类型 (例如 `'en' | 'zh'`)。
* `UiKeys`: 表示翻译字典中所有键的类型 (例如 `'nav.home' | 'nav.projects'`)。
* `getLangFromUrl(url: URL)`: 一个函数,用于从当前页面的 URL 中提取语言代码。如果 URL 中没有指定语言或者指定的语言无效,则返回默认语言。
* `useTranslations(lang: Lang | undefined)`: 一个高阶函数,它接收一个语言代码作为参数,并返回另一个函数 `t`。
* 返回的 `t(key: UiKeys, params?: Record<string, string | number>)` 函数用于获取特定键的翻译文本。
* 它会首先尝试获取当前语言的翻译,如果找不到,则回退到默认语言的翻译。
* 支持可选的 `params` 参数,用于在翻译字符串中插入动态值。例如,如果翻译字符串是 `Hello {name}`,你可以通过 `t('greeting', { name: 'World' })` 来得到 `Hello World`。
* `getLocalizedPath(path: string, lang: Lang | undefined, base: string | URL = import.meta.env.BASE_URL)`: 一个函数,用于生成本地化的 URL 路径。它会根据传入的语言和基础路径,自动添加语言子路径(如果不是默认语言)。
* `export { languages, defaultLang };`: 重新导出了 `ui.ts` 中的 `languages` 和 `defaultLang`,方便在其他地方统一从 `utils.ts` 导入。
## 7. 在 Astro 页面和 React 组件中使用翻译
**完成后,请与指导者确认,然后再进行下一步。**
**Astro 页面/布局 (.astro)**
---
你可以直接在 Astro 的 frontmatter 中获取当前语言,并加载对应的翻译文本。
## 步骤 4在 Astro 页面和组件中使用翻译
此步骤将演示如何在您的 Astro 页面 (`.astro` 文件) 和 React 组件 (`.tsx` 文件) 中使用我们创建的翻译辅助函数来显示本地化文本。
### 4.1 在 Astro 页面中使用翻译
在您的 Astro 页面中,您可以导入并使用 `getLangFromUrl` 和 `useTranslations` 函数来获取当前语言并翻译文本。
**示例:修改 `src/pages/en/index.astro` 和 `src/pages/zh/index.astro`**
假设您的首页需要显示一个欢迎标题。您可以像这样修改您的语言特定首页文件:
**`src/pages/en/index.astro`**
```astro
---
// src/pages/en/index.astro
import Layout from '../../layouts/Layout.astro';
import HeroSection from '../../components/HeroSection.tsx';
// 假设我们有一个方法来加载翻译
import { getTranslations } from '../../i18n/server'; // 这是一个假设的服务器端翻译加载函数
import { getLangFromUrl, useTranslations } from '../../i18n/utils';
const lang = Astro.currentLocale || 'en';
const t = await getTranslations(lang, 'ui'); // 加载 public/locales/en/ui.json
const heroSectionProps = {
title: t['hero.title'], // 假设 ui.json 中有 hero.title
subtitle: t['hero.subtitle']
// ... 其他需要翻译的 props
};
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
---
<Layout title={t['site.title']}>
<HeroSection {...heroSectionProps} client:load />
{/* ...其他内容... */}
<Layout title={t('nav.home')} lang={lang}>
<h1>{t('nav.home')}</h1>
<p>Welcome to the English version of the site!</p>
<p>{t('footer.rights')}</p>
</Layout>
```
**React 组件 (.tsx)**
**`src/pages/zh/index.astro`**
```astro
---
import Layout from '../../layouts/Layout.astro';
import { getLangFromUrl, useTranslations } from '../../i18n/utils';
将翻译函数 `t` 或翻译好的文本作为 props 传递给 React 组件。
```typescript
// src/components/HeroSection.tsx
interface HeroSectionProps {
title: string;
subtitle: string;
// ...其他 props
}
const HeroSection = ({ title, subtitle }: HeroSectionProps) => {
return (
<section>
<h1>{title}</h1>
<p>{subtitle}</p>
</section>
);
};
export default HeroSection;
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
---
<Layout title={t('nav.home')} lang={lang}>
<h1>{t('nav.home')}</h1>
<p>欢迎来到本站的中文版本!</p>
<p>{t('footer.rights')}</p>
</Layout>
```
或者,如果 React 组件需要在客户端自行获取翻译(例如,不通过 Astro 预渲染的部分),可以 `fetch` 对应的 `ui.json` 文件。
**关键点** <mcreference index="0" link="https://docs.astro.build/zh-cn/recipes/i18n/">0</mcreference>
```typescript
// src/components/SomeClientComponent.tsx
import { useState, useEffect } from 'react';
* 我们从 `../../i18n/utils` 导入了 `getLangFromUrl` 和 `useTranslations`。
* `getLangFromUrl(Astro.url)` 用于从当前页面的 URL 获取语言代码。
* `useTranslations(lang)` 返回了翻译函数 `t`。
* 我们使用 `t('key_name')` 来获取翻译后的字符串,例如 `t('nav.home')` 和 `t('footer.rights')`。
* 确保您的 `Layout.astro` 组件能够接收并使用 `lang` prop 来设置 HTML 的 `lang` 属性,例如 `<html lang={Astro.props.lang}>`。
interface Translations {
[key: string]: string;
}
### 4.2 在 React 组件中使用翻译
const SomeClientComponent = () => {
const [t, setT] = useState< (key: string) => string >(() => (key: string) => key);
const [lang, setLang] = useState('en'); // 需要确定当前语言
要在 React 组件中使用翻译,您需要将翻译函数 `t` 和当前语言 `lang` 作为 props 传递给组件。
useEffect(() => {
// 确定当前语言,例如从 URL 或 localStorage
const currentPathLang = window.location.pathname.split('/')[1];
// 假设 'en', 'zh' 是支持的语言
if (['en', 'zh'].includes(currentPathLang)) {
setLang(currentPathLang);
**示例:创建一个简单的 React 页脚组件 `src/components/Footer.tsx`**
1. 创建文件 `src/components/Footer.tsx`
```tree
src/
├── components/
│ └── Footer.tsx
├── i18n/
│ ...
└── pages/
...
```
2. 将以下内容添加到 `src/components/Footer.tsx`
```tsx
// src/components/Footer.tsx
import type { Lang, UiKeys } from '../i18n/utils'; // 导入类型
interface FooterProps {
lang: Lang;
t: (key: UiKeys, params?: Record<string, string | number>) => string;
}
}, []);
useEffect(() => {
async function fetchTranslations() {
try {
const response = await fetch(`/locales/${lang}/ui.json`);
if (!response.ok) throw new Error('Failed to load translations');
const translations: Translations = await response.json();
setT(() => (key: string) => translations[key] || key);
} catch (error) {
console.error("Error fetching translations:", error);
// 保留默认的 t 函数 (返回 key)
}
}
fetchTranslations();
}, [lang]);
export default function Footer({ lang, t }: FooterProps) {
return (
<div>
<p>{t('some.dynamic.text')}</p>
</div>
<footer>
<p>{t('footer.rights')}</p>
<p>Current language: {lang}</p>
</footer>
);
};
}
```
export default SomeClientComponent;
```
3. 在您的 Astro 页面中使用此组件,例如在 `src/pages/en/index.astro` 中:
## 8. 重要注意事项和目标
```astro
---
import Layout from '../../layouts/Layout.astro';
import { getLangFromUrl, useTranslations } from '../../i18n/utils';
import Footer from '../../components/Footer.tsx'; // 导入 React 组件
在开始实施国际化之前,请务必考虑以下几点,以确保项目质量和未来的可维护性:
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
---
<Layout title={t('nav.home')} lang={lang}>
<h1>{t('nav.home')}</h1>
<p>Welcome to the English version of the site!</p>
* **当前支持语言**: 初期主要支持 **英语 (en)****中文 (zh)**
* **保护现有文案**: 在迁移和翻译过程中,必须确保当前网站上已经正确展示的文案内容不被意外修改或破坏。所有现有的、在原始语言中准确无误的文本应保持原样,直到为其创建对应语言的翻译版本。
* **专业翻译**: 对于非目标语言的文案(例如,如果现有内容主要是中文,需要提供英文版本),应进行专业且准确的翻译,避免机器翻译带来的生硬和不准确问题。确保翻译质量符合专业标准。
* **清晰的目录结构**: 按照 Astro i18n 的推荐,使用 `src/pages/[locale]/` 结构组织页面,使用 `public/locales/[locale]/` 结构组织 UI 翻译文件。这有助于保持项目的整洁和直观。
* **规范的数据结构**: 对于 `src/lib/data.ts` 中的静态数据,无论是采用分离文件(如 `data.en.ts`, `data.zh.ts`)还是嵌套结构,都应保持一致性和清晰性。数据结构的设计应考虑到未来可能新增语言或数据项的扩展需求。
* **代码注释**: 在关键的国际化逻辑和数据结构处添加清晰的英文注释,方便团队协作和后续维护。
* **逐步实施与测试**: 建议分阶段进行国际化改造,每完成一部分(例如一个组件或一个页面)后进行充分测试,确保在不同语言环境下显示和功能均正常。
<Footer lang={lang} t={t} client:visible />
</Layout>
```
遵循这些原则将有助于您顺利完成国际化,并为项目未来的发展打下坚实的基础。
**关键点**:
## 9. 实施步骤建议
* 我们为 `FooterProps` 定义了接口,明确了 `lang` 和 `t` 的类型。
* `lang` 和 `t` 函数从 Astro 页面作为 props 传递给 `Footer` 组件。
* 在 React 组件内部,我们直接使用传递进来的 `t` 函数进行翻译。
* `client:visible` (或其他 Astro 客户端指令) 是必需的,以使 React 组件在客户端进行交互式渲染。
1. **配置 Astro i18n**: 更新 `astro.config.mjs`
2. **创建翻译文件**: 在 `public/locales/` 下为每种语言创建 `ui.json` 并添加一些基础翻译。
3. **调整页面结构**: 根据 `prefixDefaultLocale` 的设置,在 `src/pages/` 下创建语言子目录,并将现有页面(如 `index.astro`)移动到对应的语言目录中(例如 `src/pages/en/index.astro``src/pages/zh/index.astro`)。如果 `prefixDefaultLocale``false`,则默认语言页面保留在 `src/pages/`
4. **更新 `LanguageSwitcher.tsx`**: 实现语言切换逻辑,确保它能正确导航到本地化 URL。
5. **国际化 `Layout.astro`**: 在布局中获取当前语言,加载基础翻译(如网站标题),并将语言信息和 `LanguageSwitcher` 集成进去。
6. **国际化 `src/lib/data.ts`**: 选择一种策略(分离文件或嵌套结构)来国际化静态数据。
7. **逐个国际化 React 组件**:
* 确定哪些 props 需要翻译。
* 从 Astro 页面将翻译后的文本或翻译函数传递给这些组件。
* 更新组件以使用这些 props。
* 例如,先从 `HeroSection.tsx` 开始。
8. **国际化 Astro 页面内容**: 对于直接在 `.astro` 文件中定义的文本内容,使用加载的翻译函数进行替换。
9. **测试**: 彻底测试所有语言的页面和功能,确保翻译正确显示,链接正常工作。
**请根据您的项目结构和需求,在相应的 Astro 页面和 React 组件中应用这些翻译方法。**
通过以上步骤,你可以为你的 Astro + React 项目构建一个完整的国际化解决方案。
**完成后,请与指导者确认,然后再进行下一步。**
---

View File

@@ -1,7 +1,13 @@
import { personalInfo } from "@/lib/data";
import { useTranslations, type Lang } from "@/i18n/utils";
import { motion } from "framer-motion";
export default function Footer() {
interface FooterProps {
lang: Lang;
}
export default function Footer({ lang }: FooterProps) {
const t = useTranslations(lang);
return (
<footer className="border-t border-purple-500/10 py-6 bg-gradient-to-b from-background to-muted/20 backdrop-blur-sm">
<div className="container max-w-4xl mx-auto px-6 md:px-4">
@@ -16,8 +22,7 @@ export default function Footer() {
className="text-sm text-muted-foreground text-center md:text-left"
whileHover={{ scale: 1.01 }}
>
&copy; {new Date().getFullYear()} {personalInfo.name}. All rights
reserved.
&copy; {new Date().getFullYear()} {personalInfo.name}. {t('footer.rights')}
</motion.p>
<motion.p
className="text-sm text-muted-foreground mt-2 md:mt-0 text-center md:text-left"

View File

@@ -1,11 +1,17 @@
import ThemeToggle from "./ui/theme-toggle";
import LanguageSwitcher from "./LanguageSwitcher"; // Added import for LanguageSwitcher
import LanguageSwitcher from "./LanguageSwitcher";
import { useTranslations, getLocalizedPath, type Lang } from "@/i18n/utils";
import { personalInfo } from "@/lib/data";
import { useState } from "react";
import { Menu, X } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
export default function GlassHeader() {
interface GlassHeaderProps {
lang: Lang;
}
export default function GlassHeader({ lang }: GlassHeaderProps) {
const t = useTranslations(lang);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const toggleMenu = () => setIsMenuOpen(!isMenuOpen);
@@ -15,7 +21,7 @@ export default function GlassHeader() {
<div className="container max-w-4xl mx-auto p-4 flex justify-between items-center">
<motion.a
className="flex items-center text-lg font-medium"
href="/"
href={getLocalizedPath('/', lang)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
@@ -24,23 +30,25 @@ export default function GlassHeader() {
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-6 text-sm font-medium">
{["experience", "skills", "projects", "awards", "education"].map(
{[
{ key: 'nav.experience', icon: '💼 ', sectionId: 'experience' },
{ key: 'nav.skills', icon: '🛠️ ', sectionId: 'skills' },
{ key: 'nav.projects', icon: '🚀 ', sectionId: 'projects' },
{ key: 'nav.awards', icon: '🏆 ', sectionId: 'awards' },
{ key: 'nav.education', icon: '🎓 ', sectionId: 'education' },
].map(
(item, index) => (
<motion.a
key={item}
href={`#${item}`}
key={item.key} // Changed from item to item.key
href={`#${item.sectionId}`}
className="transition-colors hover:text-foreground/80 text-foreground/60"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.1 }}
whileHover={{ y: -2 }}
>
{item === "experience" && "💼 "}
{item === "skills" && "🛠️ "}
{item === "projects" && "🚀 "}
{item === "awards" && "🏆 "}
{item === "education" && "🎓 "}
{item.charAt(0).toUpperCase() + item.slice(1)}
{item.icon}
{t(item.key as any) /* Type assertion needed if UiKeys is strict */}
</motion.a>
)
)}
@@ -48,7 +56,7 @@ export default function GlassHeader() {
<div className="flex items-center space-x-2">
{/* Language Switcher added here */}
<LanguageSwitcher />
<LanguageSwitcher lang={lang} />
<ThemeToggle />
{/* Mobile Menu Button */}
@@ -74,23 +82,25 @@ export default function GlassHeader() {
transition={{ duration: 0.3 }}
>
<nav className="flex flex-col space-y-4 text-sm font-medium">
{["experience", "skills", "projects", "awards", "education"].map(
{[
{ key: 'nav.experience', icon: '💼 ', sectionId: 'experience' },
{ key: 'nav.skills', icon: '🛠️ ', sectionId: 'skills' },
{ key: 'nav.projects', icon: '🚀 ', sectionId: 'projects' },
{ key: 'nav.awards', icon: '🏆 ', sectionId: 'awards' },
{ key: 'nav.education', icon: '🎓 ', sectionId: 'education' },
].map(
(item, index) => (
<motion.a
key={item}
href={`#${item}`}
key={item.key} // Changed from item to item.key
href={`#${item.sectionId}`}
className="transition-colors hover:text-foreground/80 text-foreground/60 py-2"
onClick={toggleMenu}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: index * 0.1 }}
>
{item === "experience" && "💼 "}
{item === "skills" && "🛠️ "}
{item === "projects" && "🚀 "}
{item === "awards" && "🏆 "}
{item === "education" && "🎓 "}
{item.charAt(0).toUpperCase() + item.slice(1)}
{item.icon}
{t(item.key as any) /* Type assertion needed if UiKeys is strict */}
</motion.a>
)
)}

View File

@@ -1,24 +1,43 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { getLangFromUrl, getLocalizedPath, type Lang } from "@/i18n/utils";
import { languages as i18nLanguages, defaultLang } from "@/i18n/ui";
import { Languages, Check } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
const languages = [
{ code: "en", name: "English", icon: "🇬🇧" }, // Added icon for English
{ code: "zh", name: "中文", icon: "🇨🇳" }, // Added icon for Chinese
];
const availableLanguages = Object.entries(i18nLanguages).map(([code, name]) => ({
code: code as Lang,
name,
// You can add icons here if you have a mapping or a more complex structure in ui.ts
icon: code === 'en' ? '🇬🇧' : code === 'zh' ? '🇨🇳' : '🌐'
}));
export default function LanguageSwitcher() {
interface LanguageSwitcherProps {
lang: Lang;
}
export default function LanguageSwitcher({ lang: initialLang }: LanguageSwitcherProps) {
const [isOpen, setIsOpen] = useState(false);
// TODO: Implement actual language switching logic, e.g., using a context or a library
const [selectedLanguage, setSelectedLanguage] = useState(languages[0]);
const [selectedLanguage, setSelectedLanguage] = useState(() => {
return availableLanguages.find(l => l.code === initialLang) || availableLanguages.find(l => l.code === defaultLang) || availableLanguages[0];
});
useEffect(() => {
const currentLangObject = availableLanguages.find(l => l.code === initialLang);
if (currentLangObject && currentLangObject.code !== selectedLanguage.code) {
setSelectedLanguage(currentLangObject);
}
}, [initialLang, selectedLanguage.code]);
const toggleOpen = () => setIsOpen(!isOpen);
const handleSelectLanguage = (lang: typeof languages[0]) => {
const handleSelectLanguage = (lang: typeof availableLanguages[0]) => {
setSelectedLanguage(lang);
setIsOpen(false);
// Here you would typically call a function to change the language of the application
console.log(`Language changed to: ${lang.name}`);
if (typeof window !== 'undefined') {
const currentPath = window.location.pathname.replace(/\/en|\/zh/, ''); // Remove lang prefix
const newPath = getLocalizedPath(currentPath || '/', lang.code);
window.location.href = newPath;
}
};
return (
@@ -41,7 +60,7 @@ export default function LanguageSwitcher() {
className="absolute right-0 mt-2 w-40 origin-top-right rounded-md bg-popover text-popover-foreground shadow-md focus:outline-none z-50 border border-border/20"
>
<div className="p-1" role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
{languages.map((lang) => (
{availableLanguages.map((lang) => (
<button
key={lang.code}
onClick={() => handleSelectLanguage(lang)}

32
src/i18n/ui.ts Normal file
View File

@@ -0,0 +1,32 @@
// src/i18n/ui.ts
export const languages = {
en: 'English',
zh: '简体中文',
} as const;
export const defaultLang = 'en';
export const ui = {
en: {
'nav.home': 'Home',
'nav.projects': 'Projects',
'nav.experience': 'Experience',
'nav.skills': 'Skills',
'nav.awards': 'Awards',
'nav.education': 'Education',
'footer.rights': 'All rights reserved.',
'site.title': 'My Portfolio',
// 根据您的项目实际情况添加更多翻译键值对
},
zh: {
'nav.home': '首页',
'nav.projects': '项目经历',
'nav.experience': '工作经历',
'nav.skills': '专业技能',
'nav.awards': '奖项荣誉',
'nav.education': '教育背景',
'footer.rights': '版权所有。',
'site.title': '我的作品集',
// 根据您的项目实际情况添加更多翻译键值对
},
} as const;

41
src/i18n/utils.ts Normal file
View File

@@ -0,0 +1,41 @@
// src/i18n/utils.ts
import { ui, defaultLang, languages } from './ui';
export type Lang = keyof typeof languages;
export type UiKeys = keyof typeof ui[typeof defaultLang];
export function getLangFromUrl(url: URL): Lang {
const [, lang] = url.pathname.split('/');
if (lang in languages) return lang as Lang;
return defaultLang;
}
export function useTranslations(lang: Lang | undefined) {
const currentLang = lang || defaultLang;
return function t(key: UiKeys, ...args: any[]): string {
let translation: string = ui[currentLang][key] || ui[defaultLang][key];
if (args.length > 0) {
args.forEach((arg, index) => {
translation = translation.replace(`{${index}}`, arg);
});
}
return translation;
};
}
export function getLocalizedPath(path: string, lang: Lang | undefined): string {
const currentLang = lang || defaultLang;
const basePath = import.meta.env.BASE_URL === '/' ? '' : import.meta.env.BASE_URL;
// If the current language is the default language, do not add a language prefix.
if (currentLang === defaultLang) {
const fullPath = `${basePath}${path.startsWith('/') ? path : `/${path}`}`;
return fullPath.replace(/\/\/+/g, '/');
}
// Otherwise, add the language prefix.
const langPrefix = `/${currentLang}`;
const fullPath = `${basePath}${langPrefix}${path.startsWith('/') ? path : `/${path}`}`;
// Remove any double slashes that might occur.
return fullPath.replace(/\/\/+/g, '/');
}

View File

@@ -1,24 +1,27 @@
---
import { type Lang, useTranslations } from "@/i18n/utils";
import "../styles/global.css";
interface Props {
title?: string;
description?: string;
lang: Lang;
}
const { title = "Rishikesh S - Portfolio", description = "My Portfolio" } =
const { title = "Rishikesh S - Portfolio", description = "My Portfolio", lang } =
Astro.props;
const t = useTranslations(lang);
---
<!doctype html>
<html lang="en">
<html lang={lang}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<meta name="description" content={description} />
<title>{title}</title>
<title>{title}{t('site.title') ? ` | ${t('site.title')}` : ''}</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link

View File

@@ -1,4 +1,5 @@
---
import { getLangFromUrl } from "@/i18n/utils";
import Layout from "@/layouts/Layout.astro";
import GlassHeader from "@/components/GlassHeader";
import HeroSection from "@/components/HeroSection";
@@ -8,10 +9,12 @@ import ProjectsSection from "@/components/ProjectsSection";
import AwardsSection from "@/components/AwardsSection";
import EducationSection from "@/components/EducationSection";
import Footer from "@/components/Footer";
const lang = getLangFromUrl(Astro.url);
---
<Layout>
<GlassHeader client:only="react" />
<Layout title="Home" lang={lang}>
<GlassHeader lang={lang} client:only="react" />
<main class="min-h-screen">
<HeroSection client:only="react" />
<ExperienceSection client:only="react" />
@@ -20,5 +23,5 @@ import Footer from "@/components/Footer";
<AwardsSection client:only="react" />
<EducationSection client:only="react" />
</main>
<Footer client:only="react" />
<Footer lang={lang} client:only="react" />
</Layout>

27
src/pages/zh/index.astro Normal file
View File

@@ -0,0 +1,27 @@
---
import { getLangFromUrl } from "@/i18n/utils";
import Layout from "@/layouts/Layout.astro";
import GlassHeader from "@/components/GlassHeader";
import HeroSection from "@/components/HeroSection";
import ExperienceSection from "@/components/ExperienceSection";
import SkillsSection from "@/components/SkillsSection";
import ProjectsSection from "@/components/ProjectsSection";
import AwardsSection from "@/components/AwardsSection";
import EducationSection from "@/components/EducationSection";
import Footer from "@/components/Footer";
const lang = getLangFromUrl(Astro.url);
---
<Layout title="首页" lang={lang}>
<GlassHeader lang={lang} client:only="react" />
<main class="min-h-screen">
<HeroSection client:only="react" />
<ExperienceSection client:only="react" />
<SkillsSection client:only="react" />
<ProjectsSection client:only="react" />
<AwardsSection client:only="react" />
<EducationSection client:only="react" />
</main>
<Footer lang={lang} client:only="react" />
</Layout>