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:
@@ -9,6 +9,15 @@ export default defineConfig({
|
|||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
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()]
|
integrations: [react()]
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
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 as i18nLanguages, defaultLang } from "@/i18n/ui";
|
||||||
import { Languages, Check } from "lucide-react";
|
import { Languages, Check } from "lucide-react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
@@ -30,12 +30,41 @@ export default function LanguageSwitcher({ lang: initialLang }: LanguageSwitcher
|
|||||||
|
|
||||||
const toggleOpen = () => setIsOpen(!isOpen);
|
const toggleOpen = () => setIsOpen(!isOpen);
|
||||||
|
|
||||||
const handleSelectLanguage = (lang: typeof availableLanguages[0]) => {
|
const handleSelectLanguage = (languageOpt: typeof availableLanguages[0]) => {
|
||||||
setSelectedLanguage(lang);
|
setSelectedLanguage(languageOpt);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const currentPath = window.location.pathname.replace(/\/en|\/zh/, ''); // Remove lang prefix
|
const currentPathname = window.location.pathname;
|
||||||
const newPath = getLocalizedPath(currentPath || '/', lang.code);
|
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;
|
window.location.href = newPath;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,25 +2,11 @@ import React from "react";
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import type { MotionProps } from "framer-motion";
|
import type { MotionProps } from "framer-motion";
|
||||||
|
|
||||||
interface MotionWrapperProps extends MotionProps {
|
interface MotionWrapperProps extends Omit<MotionProps, 'custom'> {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
delay?: number;
|
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({
|
export default function MotionWrapper({
|
||||||
children,
|
children,
|
||||||
delay = 0,
|
delay = 0,
|
||||||
@@ -31,7 +17,18 @@ export default function MotionWrapper({
|
|||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="visible"
|
whileInView="visible"
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
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}
|
custom={delay}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const ui = {
|
|||||||
'nav.education': 'Education',
|
'nav.education': 'Education',
|
||||||
'footer.rights': 'All rights reserved.',
|
'footer.rights': 'All rights reserved.',
|
||||||
'site.title': 'My Portfolio',
|
'site.title': 'My Portfolio',
|
||||||
|
'page.home.title': 'Home',
|
||||||
|
|
||||||
// Personal Info
|
// Personal Info
|
||||||
'personal.name': 'Rishikesh S',
|
'personal.name': 'Rishikesh S',
|
||||||
@@ -158,6 +159,7 @@ export const ui = {
|
|||||||
'nav.education': '教育背景',
|
'nav.education': '教育背景',
|
||||||
'footer.rights': '版权所有。',
|
'footer.rights': '版权所有。',
|
||||||
'site.title': '我的作品集',
|
'site.title': '我的作品集',
|
||||||
|
'page.home.title': '首页',
|
||||||
|
|
||||||
// Personal Info
|
// Personal Info
|
||||||
'personal.name': 'Rishikesh S',
|
'personal.name': 'Rishikesh S',
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import { ui, defaultLang, languages } from './ui';
|
|||||||
export type Lang = keyof typeof languages;
|
export type Lang = keyof typeof languages;
|
||||||
export type UiKeys = keyof typeof ui[typeof defaultLang];
|
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) {
|
export function useTranslations(lang: Lang | undefined) {
|
||||||
const currentLang = lang || defaultLang;
|
const currentLang = lang || defaultLang;
|
||||||
@@ -27,15 +23,30 @@ export function getLocalizedPath(path: string, lang: Lang | undefined): string {
|
|||||||
const currentLang = lang || defaultLang;
|
const currentLang = lang || defaultLang;
|
||||||
const basePath = import.meta.env.BASE_URL === '/' ? '' : import.meta.env.BASE_URL;
|
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.
|
// Astro's i18n routing handles prefixing automatically based on astro.config.mjs settings.
|
||||||
if (currentLang === defaultLang) {
|
// We just need to ensure the path is correctly formed relative to the base.
|
||||||
const fullPath = `${basePath}${path.startsWith('/') ? path : `/${path}`}`;
|
// If a language is explicitly provided and it's not the default,
|
||||||
return fullPath.replace(/\/\/+/g, '/');
|
// 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.
|
// If prefixDefaultLocale is false (default in our config) and currentLang is not defaultLang,
|
||||||
const langPrefix = `/${currentLang}`;
|
// Astro will expect /zh/path. If currentLang is defaultLang, it expects /path.
|
||||||
const fullPath = `${basePath}${langPrefix}${path.startsWith('/') ? path : `/${path}`}`;
|
// If prefixDefaultLocale is true, all locales get a prefix: /en/path, /zh/path.
|
||||||
// Remove any double slashes that might occur.
|
|
||||||
return fullPath.replace(/\/\/+/g, '/');
|
// 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
|
||||||
}
|
}
|
||||||
@@ -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";
|
import "../styles/global.css";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: 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;
|
Astro.props;
|
||||||
const t = useTranslations(lang);
|
const t = useTranslations(lang);
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
---
|
---
|
||||||
import { getLangFromUrl } from "@/i18n/utils";
|
|
||||||
import Layout from "@/layouts/Layout.astro";
|
import Layout from "@/layouts/Layout.astro";
|
||||||
import GlassHeader from "@/components/GlassHeader";
|
import GlassHeader from "@/components/GlassHeader";
|
||||||
import HeroSection from "@/components/HeroSection";
|
import HeroSection from "@/components/HeroSection";
|
||||||
@@ -7,11 +6,15 @@ import ExperienceSection from "@/components/ExperienceSection";
|
|||||||
import SkillsSection from "@/components/SkillsSection";
|
import SkillsSection from "@/components/SkillsSection";
|
||||||
import ProjectsSection from "@/components/ProjectsSection";
|
import ProjectsSection from "@/components/ProjectsSection";
|
||||||
import Footer from "@/components/Footer";
|
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" />
|
<GlassHeader lang={lang} client:only="react" />
|
||||||
<main class="min-h-screen">
|
<main class="min-h-screen">
|
||||||
<HeroSection lang={lang} client:only="react" />
|
<HeroSection lang={lang} client:only="react" />
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
---
|
---
|
||||||
import { getLangFromUrl } from "@/i18n/utils";
|
|
||||||
import Layout from "@/layouts/Layout.astro";
|
import Layout from "@/layouts/Layout.astro";
|
||||||
import GlassHeader from "@/components/GlassHeader";
|
import GlassHeader from "@/components/GlassHeader.tsx";
|
||||||
import HeroSection from "@/components/HeroSection";
|
import HeroSection from "@/components/HeroSection.tsx";
|
||||||
import ExperienceSection from "@/components/ExperienceSection";
|
import ExperienceSection from "@/components/ExperienceSection.tsx";
|
||||||
import SkillsSection from "@/components/SkillsSection";
|
import SkillsSection from "@/components/SkillsSection.tsx";
|
||||||
import ProjectsSection from "@/components/ProjectsSection";
|
import ProjectsSection from "@/components/ProjectsSection.tsx";
|
||||||
import Footer from "@/components/Footer";
|
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" />
|
<GlassHeader lang={lang} client:only="react" />
|
||||||
<main class="min-h-screen">
|
<main class="min-h-screen">
|
||||||
<HeroSection lang={lang} client:only="react" />
|
<HeroSection lang={lang} client:only="react" />
|
||||||
@@ -19,5 +23,5 @@ const lang = getLangFromUrl(Astro.url);
|
|||||||
<SkillsSection lang={lang} client:only="react" />
|
<SkillsSection lang={lang} client:only="react" />
|
||||||
<ProjectsSection lang={lang} client:only="react" />
|
<ProjectsSection lang={lang} client:only="react" />
|
||||||
</main>
|
</main>
|
||||||
<Footer lang={lang} client:only="react" />
|
<Footer lang={lang} client:load />
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
"extends": "astro/tsconfigs/strict",
|
"extends": "astro/tsconfigs/strict",
|
||||||
"include": [
|
"include": [
|
||||||
".astro/types.d.ts",
|
".astro/types.d.ts",
|
||||||
"**/*"
|
"src/**/*.ts",
|
||||||
|
"src/**/*.astro",
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
Reference in New Issue
Block a user