Personal astro-koharu Theme Modification Record

Published 2026-03-07 15:40 Updated 2026-05-10 17:57 2567 words 13 min read

feat: Add footer link configuration version: Add to the beginning part In this part Add

Translated by AI model Qwen/Qwen3-8B.

Source Language: Simplified Chinese, Target Language: english, Translation Time: 2026-05-11 01:12

.

AI translation is for reference only. Accuracy is not guaranteed, please refer to the original text.

version: <4.0.0>

src/components/layout/Footer.astro

Add to the beginning part

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

In this part

<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>

Add

    <!-- 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

Add to the last line


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

src/lib/config/types.ts

Add type

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

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

Add footer: FooterConfig to SiteYamlConfig

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

feat: Add Service Worker support

  • Add astrojs-service-worker dependency
  • Configure Service Worker switch in site.yaml
  • Enable Service Worker in astro.config.mjs based on configuration

Install dependencies

npm install astrojs-service-worker

Modify astro.config.mjs

Add

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

Modify defineConfig's integrations array, add Service Worker plugin:

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

Add Service Worker configuration in config/site.yaml

serviceWorker:
  enabled: true

feat(Configuration): Add inject configuration feature

version: <4.0.0>

Added inject configuration feature, allowing custom content injection in page header and body through site.yaml configuration. Includes:

  • Add inject configuration section in site.yaml
  • New InjectConfig type definition
  • Implement content injection logic in Layout.astro

Center images

version: <4.0.0>

Modify src/styles/theme/markdown.css

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

Remove anime navigation auto-add to navigation bar, change to manual addition

version: <4.0.0>

Modify src/constants/site-config.ts's routers array, remove anime navigation route.

export const routers: RouterItem[] = baseRouters;

Add CDN configuration

version: <4.0.0>

astro.config.mjs

Add the following code to the beginning part:

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

config/site.yaml add site.assetsCdn field

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

src/constants/site-config.ts add assetsCdn field to SiteConfig

/**
 * 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.ts add assetsCdn field to SiteBasicConfig

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

Add separate LogoText for mobile

version: <4.0.0>

Avoid LogoText being too long, causing two-line display on mobile

Add mobileLogoText field to site in config/site.yaml

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.ts add mobileLogoText field to SiteConfig

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

src/lib/config/types.ts add mobileLogoText field to SiteBasicConfig

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

feat: Add article donation button

version: 4.0.0

Display donation button above the comment section at the bottom of articles. Configure donation image link through config/site.yaml's content.donate field.

src/components/post/DonateButton.astro

New Astro component, full code as follows:

---
/**
 * 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>
  const btn = document.getElementById('donate-toggle');
  const popup = document.getElementById('donate-popup');
  const close = document.getElementById('donate-close');

  if (btn && popup) {
    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();
    });

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

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

Core logic:

  • If donateUrl is not configured, directly return without rendering
  • Use vanilla JS to control popup visibility/hiding, avoid extra dependencies
  • Clicking outside area automatically closes popup

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

Insert <DonateButton /> between <article> and <Comment />:

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 />

Add donation image configuration in config/site.yaml

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

Type definitions

Add to ContentConfig interface in src/lib/config/types.ts:

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

Internationalization

Add 2 translation keys in src/i18n/translations/{zh,en,ja}.ts:

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

src/constants/content-config.ts

Add donate: undefined to default values:

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`

```typescript
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';

/** Check if article should be hidden in given context */
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;
}

/** Filter hidden articles from array */
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 状态管理文件:

/**
 * 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>
  );
}

核心逻辑:

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

src/components/language/LanguageDetectPopup.tsx

新增 React 弹窗组件:

---
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>

关键设计:

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

src/components/language/LanguageDetectProvider.astro

新增 Astro 桥接组件:

import LanguageDetectProvider from '@components/language/LanguageDetectProvider.astro';
  • 通过 document.readyState 判断,避免 DOM 未就绪时执行
  • 监听 astro:page-load 事件确保 SPA 导航后重新检测

src/layouts/Layout.astro

在全局组件区域引入 LanguageDetectProvider

      <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': 'Language Switch',
  'languageDetect.message': 'Detected your browser language is {lang}, switch to {lang} version?',
  'languageDetect.switch': 'Switch to {lang}',
  'languageDetect.dismiss': 'Do not show again',

If you enjoyed this, leave a comment~