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.
feat: Add footer link configuration
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
donateUrlis not configured, directlyreturnwithout 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:
| Key | zh | en | ja |
|---|---|---|---|
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.ts 和 src/pages/[lang]/rss.xml.ts:
const posts = filterHiddenPosts(allPosts, 'rss');
标签页过滤
src/pages/tags/[tag].astro 和 src/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()— 写入localStorage的language-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/react的AnimatePresence实现进入/退出动画
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 个翻译键:
| 键 | zh | en | ja |
|---|---|---|---|
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~