个人 astro-koharu 主题修改记录

发布于 2026-03-07 15:40 更新于 2026-05-23 14:17 3692 字 19 min read

kissablecho avatar

kissablecho

kissablecho 的个人博客 / 记录生活,分享技术 / 喜欢二次元和白丝。

feat: 添加底部链接配置 version: <4.0.0 前面部分添加 在这部分中

feat: 添加底部链接配置

version: <4.0.0

src/components/layout/Footer.astro

前面部分添加

import { footerConfig, siteConfig } from '@constants/site-config';
const footerSocial = footerConfig?.social || [];

在这部分中

<footer class={cn('mt-auto pb-6', className)}> 
    // 其它代码
    <div class={cn('mx-auto flex flex-col items-center gap-3 px-6 md:pb-10 md:px-3', MAX_WIDTH.content)}>
    // 需要添加的代码
    </div>
    // 其它代码
</footer>

添加

    <!-- Social Links -->
    <div class="text-muted-foreground flex items-center gap-4 text-sm">
      {
        footerSocial.map((item) => (
          item.url ? (
            <a
              href={item.url}
              target="_blank"
              class="footer-link font-medium transition-all duration-300"
            >
              {item.icon ? ( 
                <Icon name={item.icon} class="h-4 w-4" />
              ) : item.img ? (
                <img src={item.img} alt={item.title} class="h-6 w-auto" />
              ) : (
                item.title
              )}
            </a>
          ) : (
            <span class="footer-link font-medium transition-all duration-300">
              {item.icon ? ( 
                <Icon name={item.icon} class="h-4 w-4" />
              ) : item.img ? (
                <img src={item.img} alt={item.title} class="h-6 w-auto" />
              ) : (
                item.title
              )}
            </span>
          )
        ))
      }
    </div>

src/constants/site-config.ts

最后一行添加


// =============================================================================
// Footer
// =============================================================================
export const footerConfig = yamlConfig.footer || {};

src/lib/config/types.ts

添加类型

// =============================================================================
// Footer Configuration
// =============================================================================

export interface FooterConfig {
  social?: {
    title: string;
    url?: string;
    icon?: string;
    img?: string;
  }[];
}

SiteYamlConfig 中添加 footer: FooterConfig

export interface SiteYamlConfig {
  /** Footer configuration */
  footer?: FooterConfig;
}

feat: 添加 Service Worker 支持

  • 添加 astrojs-service-worker 依赖
  • 在 site.yaml 中配置 Service Worker 开关
  • 在 astro.config.mjs 中根据配置启用 Service Worker

安装依赖

npm install astrojs-service-worker

修改 astro.config.mjs

添加

import serviceWorker from "astrojs-service-worker";
// Get Service Worker config from YAML
const serviceWorkerConfig = yamlConfig.serviceWorker;
const serviceWorkerEnabled = serviceWorkerConfig?.enabled ?? false;

修改 defineConfigintegrations 数组,添加 Service Worker 插件:

export default defineConfig({
     integrations: [
          ...(serviceWorkerEnabled ? [serviceWorker()] : []),
     ]
})

config/site.yaml 中添加 Service Worker 配置

serviceWorker:
  enabled: true

feat(配置): 添加注入配置功能

version: <4.0.0

添加了注入配置功能,允许通过 site.yaml 配置在页面头部和主体注入自定义内容。包括:

  • 在 site.yaml 中添加 inject 配置节
  • 新增 InjectConfig 类型定义
  • 在 Layout.astro 中实现内容注入逻辑

图片居中

version: <4.0.0

修改 src/styles/theme/markdown.css

.prose .markdown-image-wrapper {
  margin: 1.5rem auto;
}

移除追番导航自动添加到导航栏,改为手动添加

version: <4.0.0

修改 src/constants/site-config.ts 中的 routers 数组,移除追番导航路由。

export const routers: RouterItem[] = baseRouters;

添加 cdn 配置

version: <4.0.0

astro.config.mjs

在前面部分添加以下代码:

// CDN config from YAML
const cdnConfig = yamlConfig.site?.assetsCdn;

config/site.yaml 添加 site.assetsCdn 字段

site:
 assetsCdn: https://cdn.example.com

src/constants/site-config.tsSiteConfig 添加 assetsCdn 字段

/**
 * Runtime site configuration
 * Extends SiteBasicConfig with runtime-specific fields
 */
type SiteConfig = Omit<SiteBasicConfig, 'url'> & {
  /** Site URL (mapped from SiteBasicConfig.url) */
  site: string;
  featuredCategories?: FeaturedCategory[];
  /** Normalized array of featured series */
  featuredSeries: FeaturedSeriesItem[];
  /** CDN for static assets */
  assetsCdn?: string;
};

src/lib/config/types.tsSiteBasicConfig 添加 assetsCdn 字段

export interface SiteBasicConfig {
  /** CDN for static assets */
  assetsCdn?: string;
}

src/components/layout/SearchPortal.astro 配置 Pagefind 搜索组件的 baseUrl,使搜索结果链接指向正确的站点根路径:

import { siteConfig } from '@constants/site-config';
import Search from 'astro-pagefind/components/Search';
uiOptions={{
  baseUrl: siteConfig.site,
  showImages: false,
}}

字体 cdn 配置

src/layouts/Layout.astro

把下面的代码替换为:

    {/* Async load rounded fonts — Japanese pages use Gen Jyuu Gothic P, others use ChillRoundF (fix #113) */}
    {locale === 'ja' ? (
      <>
        <link rel="stylesheet" href="/fonts/GenJyuuGothic-P-Regular/result.css" media="print" onload="this.setAttribute('media','all')" />
        <link rel="stylesheet" href="/fonts/GenJyuuGothic-P-Bold/result.css" media="print" onload="this.setAttribute('media','all')" />
      </>
    ) : (
      <>
        <link rel="stylesheet" href="/fonts/ChillRoundFRegular/result.css" media="print" onload="this.setAttribute('media','all')" />
        <link rel="stylesheet" href="/fonts/ChillRoundFBold/result.css" media="print" onload="this.setAttribute('media','all')" />
      </>
    )}

替换为:

    <link rel="stylesheet" href={fonts_url_1} media="print" onload="this.setAttribute('media','all')" />
    <link rel="stylesheet" href={fonts_url_2} media="print" onload="this.setAttribute('media','all')" />

再在 Frontmatter 中添加:

// cdn 前缀
const cdn_prefix = (() => {
  if (siteConfig.assetsCdn) {
    const url = new URL(siteConfig.assetsCdn);
    return url.href;
  } else {
    return '/';
  }
})();

// Async load rounded fonts — Japanese pages use Gen Jyuu Gothic P, others use ChillRoundF (fix #113)
const fonts_url_1 =
  locale === 'ja'
    ? cdn_prefix + 'fonts/GenJyuuGothic-P-Regular/result.css'
    : cdn_prefix + 'fonts/ChillRoundFRegular/result.css';
const fonts_url_2 =
  locale === 'ja' ? cdn_prefix + 'fonts/GenJyuuGothic-P-Bold/result.css' : cdn_prefix + 'fonts/ChillRoundFBold/result.css';

为移动端添加单独的 LogoText

version: <4.0.0

避免 LogoText 太长,导致移动端两行显示

config/site.yamlsite 添加 mobileLogoText 字段

site:
 mobileLogoText: mobileLogoText

src/components/layout/Header.astro

// 添加 mobileLogoText 导入
const { alternate, title, showLogo, mobileLogoText } = siteConfig;

// ------

// 修改 MobilePostHeader 组件的 logoText 参数
<MobilePostHeader
    client:only="react"
    isPostPage={isPostPage}
    enableNumbering={tocNumbering}
    logoElement={showLogo ? 'svg' : 'text'}
    logoText={mobileLogoText || title}
    logoSrc={logoSrc}
/>

src/constants/site-config.tsSiteConfig 添加 mobileLogoText 字段

export interface SiteConfig {
  /** Mobile logo text */
  mobileLogoText?: string;
}

src/lib/config/types.tsSiteBasicConfig 添加 mobileLogoText 字段

export interface SiteBasicConfig {
  /** Mobile logo text */
  mobileLogoText?: string;
}

feat: 添加文章赞赏按钮

version: 4.0.0

在文章底部评论区上方显示赞赏按钮,点击弹出赞赏二维码图片。通过 config/site.yamlcontent.donate 字段配置赞赏图片链接。

src/components/post/DonateButton.astro

新增 Astro 组件,完整代码如下:

---
/**
 * Donate Button Component
 *
 * Displays a pink donation button below posts when a donate image URL is configured.
 * Clicking the button shows a popup with the donation QR code / image.
 */
import { defaultContentConfig } from '@constants/content-config';
import { getLocaleFromUrl, t } from '@/i18n';

const donateUrl = defaultContentConfig.donate;
if (!donateUrl) return;

const locale = getLocaleFromUrl(Astro.url.pathname);
---

<div class="donate-wrapper relative flex flex-col items-center pt-6 pb-4">
  <button
    id="donate-toggle"
    class="donate-btn inline-flex items-center gap-1.5 rounded-xl px-5 py-2 text-sm font-medium text-white transition-all duration-300 hover:scale-105 hover:shadow-lg active:scale-95"
    style="background: linear-gradient(135deg, #f9a8d4, #ec4899); box-shadow: 0 2px 8px rgba(236, 72, 153, 0.35);"
    aria-label={t(locale, 'donate.button')}
  >
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
      <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
    </svg>
    {t(locale, 'donate.button')}
  </button>

  <div
    id="donate-popup"
    class="donate-popup invisible absolute bottom-full left-1/2 z-50 mb-3 -translate-x-1/2 opacity-0 transition-all duration-300 ease-out"
  >
    <div class="rounded-xl bg-white p-4 shadow-2xl ring-1 ring-black/5 dark:bg-gray-800 dark:ring-white/10">
      <div class="mb-2 flex items-center justify-between">
        <span class="text-sm font-medium text-gray-700 dark:text-gray-300">{t(locale, 'donate.button')}</span>
        <button
          id="donate-close"
          class="ml-4 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
          aria-label={t(locale, 'donate.close')}
        >
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <line x1="18" y1="6" x2="6" y2="18"></line>
            <line x1="6" y1="6" x2="18" y2="18"></line>
          </svg>
        </button>
      </div>
      <img
        src={donateUrl}
        alt={t(locale, 'donate.button')}
        class="h-auto w-56 rounded-lg object-contain"
        loading="lazy"
        decoding="async"
      />
    </div>
    <!-- Arrow -->
    <div class="absolute left-1/2 top-full -translate-x-1/2 border-8 border-transparent border-t-white dark:border-t-gray-800"></div>
  </div>
</div>

<script>
  let globalClickHandler: ((e: Event) => void) | null = null;

  function initDonate() {
    const btn = document.getElementById('donate-toggle');
    const popup = document.getElementById('donate-popup');
    const close = document.getElementById('donate-close');

    if (!btn || !popup) return;

    const show = () => {
      popup.classList.remove('invisible', 'opacity-0');
      popup.classList.add('visible', 'opacity-100');
    };
    const hide = () => {
      popup.classList.add('invisible', 'opacity-0');
      popup.classList.remove('visible', 'opacity-100');
    };

    btn.addEventListener('click', (e) => {
      e.stopPropagation();
      if (popup.classList.contains('opacity-100')) {
        hide();
      } else {
        show();
      }
    });

    close?.addEventListener('click', (e) => {
      e.stopPropagation();
      hide();
    });

    if (globalClickHandler) {
      document.removeEventListener('click', globalClickHandler);
    }
    globalClickHandler = (e) => {
      if (!popup.contains(e.target as Node) && e.target !== btn) {
        hide();
      }
    };
    document.addEventListener('click', globalClickHandler);
  }

  initDonate();
  document.addEventListener('astro:after-swap', initDonate);
</script>

<style>
  .donate-btn:hover {
    box-shadow: 0 4px 16px rgba(236, 72, 153, 0.5) !important;
  }
</style>

核心逻辑:

  • donateUrl 未配置则直接 return 不渲染
  • 使用 vanilla JS 控制弹窗显示/隐藏,避免引入额外依赖
  • 点击外部区域自动关闭弹窗
  • 监听 astro:after-swap 事件,确保 SPA 无刷新切换页面后重新绑定事件
  • 通过 globalClickHandler 引用去重 document 全局点击监听器,避免 SPA 导航累积

src/pages/post/[...slug].astro

<article><Comment /> 之间插入 <DonateButton />

import DonateButton from '@/components/post/DonateButton.astro';
      <article class="prose md:prose-sm dark:prose-invert" data-pagefind-body>
        <CustomContent Content={Content} />
      </article>
      <DonateButton />
      <Comment />

config/site.yaml 中添加赞赏图片配置

content:
  donate: "https://ik.imagekit.io/ziw9wtigz/me/WeChat-Appreciation-code-3.png?updatedAt=1778394979458"

类型定义

src/lib/config/types.tsContentConfig 接口添加:

  /** 赞赏图片链接,设置后文章下方显示赞赏按钮 */
  donate?: string;

国际化

src/i18n/translations/{zh,en,ja}.ts 各添加 2 个翻译键:

zhenja
donate.button赞赏Donate応援する
donate.close关闭Close閉じる
  // ── Donate ───────────────────────────────────────────────────
  'donate.button': '赞赏',
  'donate.close': '关闭',

src/constants/content-config.ts

默认值中添加 donate: undefined

export const defaultContentConfig: ContentConfig = yamlConfig.content ?? {
  // ...
  donate: undefined,
};

feat: 添加 hideFrom frontmatter 字段控制文章可见性

version: 4.0.0

在文章 frontmatter 中添加 hideFrom 字段,控制文章在各类聚合列表页的可见性,支持四种隐藏级别:

隐藏范围
home仅在首页隐藏
rss仅在 RSS 隐藏
home_rss首页 + RSS 隐藏
all所有聚合列表页隐藏(首页、RSS、分类页、标签页、归档页、系列文章列表、相关文章推荐)

文章详情页不受影响,直接 URL 始终可访问。

src/types/blog.ts

hideFrom?: 'home' | 'home_rss' | 'rss' | 'all';

src/content/config.ts

hideFrom: z.enum(['home', 'home_rss', 'rss', 'all']).optional(),

src/lib/content/posts.ts

新增工具函数和类型:

export type PostListContext = 'home' | 'rss' | 'anyList';

/** 检查文章是否在给定上下文中隐藏 */
export function isHiddenFromList(post: BlogPost, context: PostListContext): boolean {
  const ef = post.data.hideFrom;
  if (!ef) return false;
  if (ef === 'all') return true;
  if (ef === 'home') return context === 'home';
  if (ef === 'rss') return context === 'rss';
  if (ef === 'home_rss') return context === 'home' || context === 'rss';
  return false;
}

/** 过滤数组中隐藏的文章 */
export function filterHiddenPosts(posts: BlogPost[], context: PostListContext): BlogPost[] {
  return posts.filter((p) => !isHiddenFromList(p, context));
}

在以下函数中应用过滤:

  • getHomePagePosts() — 遍历时 isHiddenFromList(post, 'home') 跳过
  • getNonFeaturedPosts() — 返回前 filterHiddenPosts(result, 'anyList')
  • getPostsByCategory() — 返回前 filterHiddenPosts(matched, 'anyList')

src/lib/content/categories.ts

getCategoryList() 计数前过滤不可见文章:

const visiblePosts = filterHiddenPosts(allBlogPosts, 'anyList');

RSS 页面过滤

src/pages/rss.xml.tssrc/pages/[lang]/rss.xml.ts

const posts = filterHiddenPosts(allPosts, 'rss');

标签页过滤

src/pages/tags/[tag].astrosrc/pages/tags/index.astro

const visiblePosts = filterHiddenPosts(allPosts, 'anyList');

归档页过滤

src/pages/archives.astro

const posts = filterHiddenPosts(allPosts, 'anyList');

相关文章过滤

src/components/post/PostFooter.astro

const allPosts = filterHiddenPosts(allPostsRaw, 'anyList');

src/lib/content.ts

导出新增的函数和类型:

export { filterHiddenPosts, isHiddenFromList } from './content/posts';
export type { PostListContext } from './content/posts';

feat(i18n): 添加浏览器语言检测及切换提示功能

version: 4.0.0

实现浏览器语言检测功能,当检测到用户浏览器语言与当前页面语言不一致时,显示切换提示弹窗。弹窗提供切换至浏览器语言的选项和不再提醒功能。

src/store/language-detect.ts

新增 Nanostores 状态管理文件:

/**
 * Language Detection State Management
 *
 * Detects browser language and compares with current site locale.
 * Shows a popup suggesting the user switch to their preferred language.
 * Persists dismissal in localStorage.
 */

import { atom } from 'nanostores';
import { defaultLocale, isI18nEnabled, localeList } from '@/i18n/config';

const STORAGE_KEY = 'language-detect-dismissed';

/** Whether the language suggestion popup is visible */
export const languagePopupOpen = atom<boolean>(false);

/** The target locale code to suggest switching to */
export const suggestedLocale = atom<string>('');

/** The display label for the suggested language */
export const suggestedLabel = atom<string>('');

/**
 * Map a browser language code (e.g. "en-US", "zh-CN") to a site locale code.
 * Returns null if the browser language doesn't match any supported locale.
 */
function mapBrowserLang(lang: string): string | null {
  const primary = lang.split('-')[0].toLowerCase();
  if (localeList.includes(primary)) return primary;
  // Full match (e.g. "zh-CN" → "zh" is already handled above, "zh-TW" → "zh")
  if (primary === 'zh' && localeList.includes('zh')) return 'zh';
  if (primary === 'ja' && localeList.includes('ja')) return 'ja';
  if (primary === 'en' && localeList.includes('en')) return 'en';
  return null;
}

/**
 * Get a human-readable label for a locale code in the locale's own language.
 */
function getLocaleSelfLabel(code: string): string {
  const labels: Record<string, string> = {
    zh: '中文',
    en: 'English',
    ja: '日本語',
  };
  return labels[code] ?? code;
}

/**
 * Initialize language detection.
 * Compares browser language with current site locale and opens popup if they differ.
 * Does nothing if i18n is disabled or the user has dismissed before.
 */
export function initLanguageDetect(): void {
  if (typeof window === 'undefined') return;
  if (!isI18nEnabled) return;

  // Check if user has dismissed
  try {
    if (localStorage.getItem(STORAGE_KEY) === 'true') return;
  } catch {
    // localStorage unavailable, skip
    return;
  }

  const browserLang = navigator.language || (navigator as { userLanguage?: string }).userLanguage || '';
  const targetLocale = mapBrowserLang(browserLang);

  if (!targetLocale) return;

  // Get current locale from URL
  const pathname = window.location.pathname;
  const segments = pathname.split('/').filter(Boolean);
  const firstSegment = segments[0];
  const currentLocale = firstSegment && localeList.includes(firstSegment) ? firstSegment : defaultLocale;

  if (targetLocale === currentLocale) return;

  suggestedLocale.set(targetLocale);
  suggestedLabel.set(getLocaleSelfLabel(targetLocale));
  languagePopupOpen.set(true);
}

/**
 * Dismiss the popup permanently (save to localStorage)
 */
export function dismissLanguagePopup(): void {
  languagePopupOpen.set(false);
  try {
    localStorage.setItem(STORAGE_KEY, 'true');
  } catch {
    // localStorage unavailable
  }
}

/**
 * Close the popup without dismissing permanently
 */
export function closeLanguagePopup(): void {
  languagePopupOpen.set(false);
}

核心逻辑:

  • mapBrowserLang() — 将 navigator.language(如 zh-CN)映射到站点 locale(zh
  • initLanguageDetect() — 检测三条件:i18n 已启用 / 未被永久关闭 / 浏览器语言与当前 locale 不同
  • dismissLanguagePopup() — 写入 localStoragelanguage-detect-dismissed 键,永久不再提醒
  • closeLanguagePopup() — 仅关闭弹窗,下次页面加载会重新检测

src/components/language/LanguageDetectPopup.tsx

新增 React 弹窗组件:

/**
 * LanguageDetectPopup Component
 *
 * Detects browser language and shows a popup suggesting the user
 * switch to their preferred language. Styled to match the announcement popup.
 *
 * Popup text is displayed in the user's browser language first,
 * falling back to the current page language.
 */

import { animation, zIndex } from '@constants/design-tokens';
import { Icon } from '@iconify/react';
import { useStore } from '@nanostores/react';
import {
  closeLanguagePopup,
  dismissLanguagePopup,
  languagePopupOpen,
  suggestedLabel,
  suggestedLocale,
} from '@store/language-detect';
import { $locale } from '@store/locale';
import { AnimatePresence, motion } from 'motion/react';
import { getAlternateUrl, t as translate } from '@/i18n';
import type { TranslationKey } from '@/i18n/types';

export default function LanguageDetectPopup() {
  const isOpen = useStore(languagePopupOpen);
  const targetLocale = useStore(suggestedLocale);
  const label = useStore(suggestedLabel);
  const pageLocale = useStore($locale);

  const targetUrl = typeof window !== 'undefined' ? getAlternateUrl(window.location.pathname, targetLocale) : '/';

  /** Translate using browser language first, fall back to page language */
  const t = (key: TranslationKey, params?: Record<string, string>) => {
    const result = translate(targetLocale, key, params);
    if (result === key) {
      return translate(pageLocale, key, params);
    }
    return result;
  };

  return (
    <AnimatePresence>
      {isOpen && (
        <>
          {/* Backdrop */}
          <motion.div
            className="fixed inset-0 bg-black/50 backdrop-blur-sm"
            style={{ zIndex: zIndex.modalBackdrop }}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={closeLanguagePopup}
          />

          {/* Popup */}
          <motion.div
            className="fixed inset-x-3 top-1/2 mx-auto max-w-md overflow-hidden rounded-xl bg-card shadow-2xl md:inset-x-4 md:rounded-2xl"
            style={{ zIndex: zIndex.modal }}
            initial={{ opacity: 0, y: '-45%', scale: 0.95 }}
            animate={{ opacity: 1, y: '-50%', scale: 1 }}
            exit={{ opacity: 0, y: '-45%', scale: 0.95 }}
            transition={animation.spring.default}
          >
            {/* Header */}
            <div className="flex items-center justify-between border-border border-b bg-linear-to-r from-primary/5 to-transparent p-3 md:p-4">
              <div className="flex items-center gap-2 md:gap-3">
                <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 md:h-10 md:w-10 md:rounded-xl">
                  <Icon icon="ri:translate-2" className="h-4 w-4 text-primary md:h-5 md:w-5" />
                </div>
                <h3 className="font-semibold text-sm md:text-base">{t('languageDetect.title')}</h3>
              </div>
              <button
                onClick={closeLanguagePopup}
                className="rounded-md p-1.5 transition-colors hover:bg-black/5 md:rounded-lg md:p-2 dark:hover:bg-white/10"
                aria-label={t('common.close')}
                type="button"
              >
                <Icon icon="ri:close-line" className="h-4 w-4 md:h-5 md:w-5" />
              </button>
            </div>

            {/* Content */}
            <div className="p-4 md:p-6">
              <p className="text-muted-foreground text-sm leading-relaxed md:text-base">
                {t('languageDetect.message', { lang: label })}
              </p>

              <div className="mt-5 flex items-center gap-3 md:mt-6">
                <a
                  href={targetUrl}
                  onClick={closeLanguagePopup}
                  className="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-primary px-4 py-2.5 font-medium text-primary-foreground text-sm transition-all hover:opacity-90 md:rounded-xl md:px-5 md:py-3 md:text-base"
                >
                  <Icon icon="ri:arrow-right-line" className="h-4 w-4 md:h-5 md:w-5" />
                  {t('languageDetect.switch', { lang: label })}
                </a>
                <button
                  onClick={dismissLanguagePopup}
                  className="rounded-lg px-4 py-2.5 text-muted-foreground text-sm transition-colors hover:bg-muted md:rounded-xl md:px-5 md:py-3 md:text-base"
                  type="button"
                >
                  {t('languageDetect.dismiss')}
                </button>
              </div>
            </div>
          </motion.div>
        </>
      )}
    </AnimatePresence>
  );
}

关键设计:

  • 翻译文本优先使用浏览器语言显示,若缺失则回退到页面语言
  • 遮罩层点击关闭(不永久),弹窗内"不再提醒"按钮才写入 localStorage
  • 使用 motion/reactAnimatePresence 实现进入/退出动画

src/components/language/LanguageDetectProvider.astro

新增 Astro 桥接组件:

---
import LanguageDetectPopup from './LanguageDetectPopup';
/**
 * LanguageDetectProvider Component
 *
 * Initializes browser language detection and mounts the React popup component.
 * Should be placed in Layout.astro alongside other global components.
 */
---

<LanguageDetectPopup client:load />

<script>
  import { initLanguageDetect } from '@store/language-detect';

  function init() {
    initLanguageDetect();
  }

  if (document.readyState !== 'loading') {
    init();
  }

  document.addEventListener('astro:page-load', init);
</script>
  • 通过 document.readyState 判断,避免 DOM 未就绪时执行
  • 监听 astro:page-load 事件确保 SPA 导航后重新检测

src/layouts/Layout.astro

在全局组件区域引入 LanguageDetectProvider

import LanguageDetectProvider from '@components/language/LanguageDetectProvider.astro';
      <ImageLightbox client:idle />
      <Toaster client:idle />
      <AnnouncementProvider />
      <LanguageDetectProvider />

国际化

src/i18n/translations/{zh,en,ja}.ts 各添加 4 个翻译键:

zhenja
languageDetect.title语言切换Language Switch言語切替
languageDetect.message检测到您的浏览器语言是{lang},是否切换到{lang}版?Your browser language is {lang}. Switch to {lang}?お使いのブラウザ言語は{lang}です。{lang}版に切り替えますか?
languageDetect.switch切换到{lang}Switch to {lang}{lang}に切り替える
languageDetect.dismiss不再提醒Don't show again今後表示しない

zh.ts 代码块:

  // ── Language Detect ─────────────────────────────────────────
  'languageDetect.title': '语言切换',
  'languageDetect.message': '检测到您的浏览器语言是{lang},是否切换到{lang}版?',
  'languageDetect.switch': '切换到{lang}',
  'languageDetect.dismiss': '不再提醒',

mdx 支持

目前不完善,部分主题语法不支持,构建时还会出现以下警告

14:34:50 [200] /post/test 1626ms
Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app
See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.
  1. 安装 @astrojs/mdx 插件
pnpm install @astrojs/mdx
  1. 配置 astro.config.mjs 启用 MDX 支持
// astro.config.mjs
import mdx from '@astrojs/mdx';

// ...
integrations: [
  react(),
  ...(mdxSupport ? [mdx()] : []),
// ...
],

移除 @yeskunall/astro-umami

pnpm uninstall @yeskunall/astro-umami

astro.config.mjs 中移除 @yeskunall/astro-umami 插件

// ...

// import umami from '@yeskunall/astro-umami';

// ...

// // Bundle analysis mode: ANALYZE=true pnpm build
// // Use loadEnv to read .env file (astro.config.mjs runs before Vite loads .env)
// const { ANALYZE } = loadEnv(process.env.NODE_ENV || 'production', process.cwd(), '');
// const isAnalyze = ANALYZE === 'true';
// // Get Umami analytics config from YAML
// const umamiConfig = yamlConfig.analytics?.umami;
// const umamiEnabled = umamiConfig?.enabled ?? false;
// const umamiId = umamiConfig?.id;
// // Normalize endpoint URL to remove trailing slashes
// const umamiEndpoint = normalizeUrl(umamiConfig?.endpoint);

// ...

    // Umami analytics - configured via config/site.yaml
    // ...(umamiEnabled && umamiId
    //   ? [
    //       umami({
    //         id: umamiId,
    //         endpointUrl: umamiEndpoint,
    //         hostUrl: umamiEndpoint,
    //       }),
    //     ]
    //   : []),

// ...

喜欢的话,留下你的评论吧~