feat(i18n): implement astro i18n integration and refactor locale handling

- Add i18n configuration to astro.config.mjs with default locale and routing
- Refactor language handling to use Astro.currentLocale instead of URL parsing
- Update tsconfig to include only necessary files for better type checking
- Improve LanguageSwitcher to handle routing based on astro i18n config
- Add new translation keys and update components to use dynamic titles
- Simplify MotionWrapper component by removing unused default animations
This commit is contained in:
joyzhao
2025-06-15 17:20:29 +08:00
parent 22799c9d8a
commit 1476f4eeec
9 changed files with 110 additions and 53 deletions

View File

@@ -9,6 +9,15 @@ export default defineConfig({
vite: {
plugins: [tailwindcss()],
},
i18n: {
// The default locale to fall back to if a page isn't available in the active locale
defaultLocale: "en",
// A list of all locales supported by the site
locales: ["en", "zh"],
routing: {
// URLs for the defaultLocale (en) will not have a /en/ prefix
prefixDefaultLocale: false,
}
},
integrations: [react()]
});

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { getLangFromUrl, getLocalizedPath, type Lang } from "@/i18n/utils";
import { getLocalizedPath, type Lang } from "@/i18n/utils"; // getLangFromUrl is removed as Astro.currentLocale is used now.
import { languages as i18nLanguages, defaultLang } from "@/i18n/ui";
import { Languages, Check } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
@@ -30,12 +30,41 @@ export default function LanguageSwitcher({ lang: initialLang }: LanguageSwitcher
const toggleOpen = () => setIsOpen(!isOpen);
const handleSelectLanguage = (lang: typeof availableLanguages[0]) => {
setSelectedLanguage(lang);
const handleSelectLanguage = (languageOpt: typeof availableLanguages[0]) => {
setSelectedLanguage(languageOpt);
setIsOpen(false);
if (typeof window !== 'undefined') {
const currentPath = window.location.pathname.replace(/\/en|\/zh/, ''); // Remove lang prefix
const newPath = getLocalizedPath(currentPath || '/', lang.code);
const currentPathname = window.location.pathname;
const currentPathParts = currentPathname.split('/').filter(p => p);
let basePath = '';
// Check if the first part of the path is a known language code
if (currentPathParts.length > 0 && Object.keys(i18nLanguages).includes(currentPathParts[0])) {
basePath = '/' + currentPathParts.slice(1).join('/');
} else {
basePath = currentPathname;
}
// Ensure basePath always starts with a slash, or is just a slash for the root
if (!basePath.startsWith('/')) {
basePath = '/' + basePath;
}
if (basePath === '//') basePath = '/';
let newPath;
// If the target language is the default language and prefixDefaultLocale is false (as per our astro.config.mjs)
// then no language prefix is needed.
if (languageOpt.code === defaultLang) {
newPath = basePath;
} else {
// For non-default languages, prefix with the language code.
newPath = `/${languageOpt.code}${basePath}`;
}
// Clean up double slashes, just in case
newPath = newPath.replace(/\/\/+/g, '/');
if (newPath === '') newPath = '/'; // Handle case where basePath might be empty resulting in just /zh or /en
window.location.href = newPath;
}
};

View File

@@ -2,25 +2,11 @@ import React from "react";
import { motion } from "framer-motion";
import type { MotionProps } from "framer-motion";
interface MotionWrapperProps extends MotionProps {
interface MotionWrapperProps extends Omit<MotionProps, 'custom'> {
children: React.ReactNode;
delay?: number;
}
// Default animations for sections
const defaultAnimations = {
hidden: { opacity: 0, y: 20 },
visible: (delay: number = 0) => ({
opacity: 1,
y: 0,
transition: {
duration: 0.6,
delay: delay,
ease: "easeOut",
},
}),
};
export default function MotionWrapper({
children,
delay = 0,
@@ -31,7 +17,18 @@ export default function MotionWrapper({
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-100px" }}
variants={defaultAnimations}
variants={{
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.6,
delay,
ease: [0.43, 0.13, 0.23, 0.96]
}
}
}}
custom={delay}
{...props}
>

View File

@@ -16,6 +16,7 @@ export const ui = {
'nav.education': 'Education',
'footer.rights': 'All rights reserved.',
'site.title': 'My Portfolio',
'page.home.title': 'Home',
// Personal Info
'personal.name': 'Rishikesh S',
@@ -158,6 +159,7 @@ export const ui = {
'nav.education': '教育背景',
'footer.rights': '版权所有。',
'site.title': '我的作品集',
'page.home.title': '首页',
// Personal Info
'personal.name': 'Rishikesh S',

View File

@@ -4,11 +4,7 @@ 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;
@@ -27,15 +23,30 @@ 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, '/');
// Astro's i18n routing handles prefixing automatically based on astro.config.mjs settings.
// We just need to ensure the path is correctly formed relative to the base.
// If a language is explicitly provided and it's not the default,
// and prefixDefaultLocale is false (meaning default lang has no prefix),
// we might need to add it. However, Astro typically handles this.
// For now, let's assume Astro's routing takes care of the prefix.
// This function might become simpler or even unnecessary depending on how LanguageSwitcher is used.
let newPath = path;
// Ensure path starts with a slash if it's not an external URL
if (!newPath.startsWith('/') && !newPath.match(/^https?:\/\//)) {
newPath = `/${newPath}`;
}
// 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, '/');
// If prefixDefaultLocale is false (default in our config) and currentLang is not defaultLang,
// Astro will expect /zh/path. If currentLang is defaultLang, it expects /path.
// If prefixDefaultLocale is true, all locales get a prefix: /en/path, /zh/path.
// Given our astro.config.mjs: prefixDefaultLocale: false
// If lang is 'zh', the path should be /zh/your-path
// If lang is 'en' (default), the path should be /your-path
// Astro's <a href> or Astro.redirect should handle this correctly when given a root-relative path.
// This function's main job is to ensure the base path and the target path are combined correctly.
const fullPath = `${basePath}${newPath}`;
return fullPath.replace(/\/\/+/g, '/'); // Clean up any double slashes
}

View File

@@ -1,14 +1,15 @@
---
import { type Lang, useTranslations } from "@/i18n/utils";
import { useTranslations, type Lang } from "@/i18n/utils";
import { defaultLang } from "@/i18n/ui";
import "../styles/global.css";
interface Props {
title?: string;
description?: string;
lang: Lang;
}
const { title = "Rishikesh S - Portfolio", description = "My Portfolio", lang } =
const lang = Astro.currentLocale as Lang || defaultLang;
const { title = "Rishikesh S - Portfolio", description = "My Portfolio" } =
Astro.props;
const t = useTranslations(lang);
---

View File

@@ -1,5 +1,4 @@
---
import { getLangFromUrl } from "@/i18n/utils";
import Layout from "@/layouts/Layout.astro";
import GlassHeader from "@/components/GlassHeader";
import HeroSection from "@/components/HeroSection";
@@ -7,11 +6,15 @@ import ExperienceSection from "@/components/ExperienceSection";
import SkillsSection from "@/components/SkillsSection";
import ProjectsSection from "@/components/ProjectsSection";
import Footer from "@/components/Footer";
import { useTranslations, type Lang } from "@/i18n/utils";
import { defaultLang } from "@/i18n/ui";
const lang = getLangFromUrl(Astro.url);
const lang: Lang = defaultLang;
const t = useTranslations(lang);
const pageTitle = t('page.home.title');
---
<Layout title="Home" lang={lang}>
<Layout title={pageTitle}>
<GlassHeader lang={lang} client:only="react" />
<main class="min-h-screen">
<HeroSection lang={lang} client:only="react" />

View File

@@ -1,17 +1,21 @@
---
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 Footer from "@/components/Footer";
import GlassHeader from "@/components/GlassHeader.tsx";
import HeroSection from "@/components/HeroSection.tsx";
import ExperienceSection from "@/components/ExperienceSection.tsx";
import SkillsSection from "@/components/SkillsSection.tsx";
import ProjectsSection from "@/components/ProjectsSection.tsx";
import Footer from "@/components/Footer.tsx";
import { useTranslations } from "@/i18n/utils";
const lang = getLangFromUrl(Astro.url);
// For /zh/ pages, Astro.currentLocale should be 'zh'.
// We explicitly set lang to 'zh' to ensure type correctness and intent.
const lang: "en" | "zh" = 'zh';
const t = useTranslations(lang);
const pageTitle = t('page.home.title');
---
<Layout title="首页" lang={lang}>
<Layout title={pageTitle}>
<GlassHeader lang={lang} client:only="react" />
<main class="min-h-screen">
<HeroSection lang={lang} client:only="react" />
@@ -19,5 +23,5 @@ const lang = getLangFromUrl(Astro.url);
<SkillsSection lang={lang} client:only="react" />
<ProjectsSection lang={lang} client:only="react" />
</main>
<Footer lang={lang} client:only="react" />
<Footer lang={lang} client:load />
</Layout>

View File

@@ -2,7 +2,8 @@
"extends": "astro/tsconfigs/strict",
"include": [
".astro/types.d.ts",
"**/*"
"src/**/*.ts",
"src/**/*.astro",
],
"exclude": [
"dist"