diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f97be4..8d78614 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,8 +41,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10 - name: Setup Node uses: actions/setup-node@v4 @@ -79,8 +77,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10 - name: Setup Node uses: actions/setup-node@v4 @@ -114,8 +110,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10 - name: Setup Node uses: actions/setup-node@v4 @@ -149,8 +143,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10 - name: Setup Node uses: actions/setup-node@v4 diff --git a/package.json b/package.json index b2a11c4..b22ebd8 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dependencies": { "@astrojs/check": "^0.9.6", "@astrojs/react": "^4.4.2", + "@astrojs/rss": "^4.0.14", "@astrojs/ts-plugin": "^1.10.6", "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", @@ -80,5 +81,6 @@ "sharp": "^0.34.5", "vite-tsconfig-paths": "^6.0.3", "vitest": "^4.0.15" - } + }, + "packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 970d69a..7b9901e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@astrojs/react': specifier: ^4.4.2 version: 4.4.2(@types/node@25.0.3)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(yaml@2.8.2) + '@astrojs/rss': + specifier: ^4.0.14 + version: 4.0.14 '@astrojs/ts-plugin': specifier: ^1.10.6 version: 1.10.6 @@ -211,6 +214,9 @@ packages: react: ^17.0.2 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.2 || ^18.0.0 || ^19.0.0 + '@astrojs/rss@4.0.14': + resolution: {integrity: sha512-KCe1imDcADKOOuO/wtKOMDO/umsBD6DWF+94r5auna1jKl5fmlK9vzf+sjA3EyveXA/FoB3khtQ/u/tQgETmTw==} + '@astrojs/telemetry@3.3.0': resolution: {integrity: sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} @@ -2080,6 +2086,10 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-parser@5.3.3: + resolution: {integrity: sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -3377,6 +3387,9 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} + strnum@2.1.2: + resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + stylehacks@7.0.7: resolution: {integrity: sha512-bJkD0JkEtbRrMFtwgpJyBbFIwfDDONQ1Ov3sDLZQP8HuJ73kBOyx66H4bOcAbVWmnfLdvQ0AJwXxOMkpujcO6g==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} @@ -4096,6 +4109,11 @@ snapshots: - tsx - yaml + '@astrojs/rss@4.0.14': + dependencies: + fast-xml-parser: 5.3.3 + piccolore: 0.1.3 + '@astrojs/telemetry@3.3.0': dependencies: ci-info: 4.3.1 @@ -5930,6 +5948,10 @@ snapshots: fast-uri@3.1.0: {} + fast-xml-parser@5.3.3: + dependencies: + strnum: 2.1.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -7428,6 +7450,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strnum@2.1.2: {} + stylehacks@7.0.7(postcss@8.5.6): dependencies: browserslist: 4.28.1 diff --git a/src/components/common/RssSelector.tsx b/src/components/common/RssSelector.tsx new file mode 100644 index 0000000..e56c9fb --- /dev/null +++ b/src/components/common/RssSelector.tsx @@ -0,0 +1,180 @@ +import { useState, useRef, useEffect } from 'react'; +import { cn } from '@components/common/lib/utils'; + +// 支持的语言列表 +const LOCALES = ['zh', 'ja', 'en'] as const; +type Locale = (typeof LOCALES)[number]; + +// 语言名称映射 +const LOCALE_NAMES: Record = { + zh: '中文', + ja: '日本語', + en: 'English', +}; + +// 语言标志(使用 emoji) +const LOCALE_FLAGS: Record = { + zh: '🇨🇳', + ja: '🇯🇵', + en: '🇺🇸', +}; + +// 默认语言 +const DEFAULT_LOCALE: Locale = 'zh'; + +/** + * 从当前 URL 中提取语言代码 + */ +function getCurrentLocale(): Locale { + if (typeof window === 'undefined') return DEFAULT_LOCALE; + + const pathParts = window.location.pathname.split('/').filter(Boolean); + const firstPart = pathParts[0]; + + if (firstPart && LOCALES.includes(firstPart as Locale)) { + return firstPart as Locale; + } + + return DEFAULT_LOCALE; +} + +interface RssOption { + id: string; + label: string; + href: string; + flag: string; + isCurrent?: boolean; +} + +interface RssSelectorProps { + /** 自定义样式类名 */ + className?: string; + /** 是否显示文字标签 */ + showLabel?: boolean; +} + +export default function RssSelector({ + className, + showLabel = true, +}: RssSelectorProps) { + const [isOpen, setIsOpen] = useState(false); + const [currentLocale, setCurrentLocale] = useState(DEFAULT_LOCALE); + const dropdownRef = useRef(null); + + // 客户端初始化当前语言 + useEffect(() => { + setCurrentLocale(getCurrentLocale()); + }, []); + + // 点击外部关闭下拉菜单 + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => + document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // 键盘导航支持 + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setIsOpen(false); + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setIsOpen(!isOpen); + } + }; + + // 生成 RSS 选项列表 + const rssOptions: RssOption[] = [ + { + id: 'all', + label: '全部语言', + href: '/rss.xml', + flag: '📡', + }, + ...LOCALES.map((locale) => ({ + id: locale, + label: LOCALE_NAMES[locale], + href: `/${locale}/rss.xml`, + flag: LOCALE_FLAGS[locale], + isCurrent: locale === currentLocale, + })), + ]; + + const handleOptionClick = (href: string) => { + // 在新标签页打开 RSS 链接 + window.open(href, '_blank', 'noopener,noreferrer'); + setIsOpen(false); + }; + + return ( +
+ {/* 触发按钮 - 保持与其他导航元素一致的样式 */} + + + {/* 下拉菜单 - 往上出现 */} + {isOpen && ( +
+ {rssOptions.map((option) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/common/index.ts b/src/components/common/index.ts index c6889d7..a143c96 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -51,5 +51,8 @@ export { TooltipProvider, } from './tooltip'; +// RSS Selector +export { default as RssSelector } from './RssSelector'; + // Utils export { cn } from './lib/utils'; diff --git a/src/components/layout/dock/DockNav.astro b/src/components/layout/dock/DockNav.astro index ce3529e..b3d30b8 100644 --- a/src/components/layout/dock/DockNav.astro +++ b/src/components/layout/dock/DockNav.astro @@ -1,5 +1,6 @@ --- import DockNavMobile from './DockNavMobile.tsx'; +import RssSelector from '@components/common/RssSelector.tsx'; import { type Locale, DEFAULT_LOCALE, buildLocalePath } from '@lib/i18n'; interface Props { @@ -62,13 +63,9 @@ const privacyLink = buildLocalePath(lang, 'privacy-policy'); >Contact - - diff --git a/src/layouts/base/BaseLayout.astro b/src/layouts/base/BaseLayout.astro index c190f85..fd8846e 100644 --- a/src/layouts/base/BaseLayout.astro +++ b/src/layouts/base/BaseLayout.astro @@ -9,6 +9,8 @@ interface Props { lang?: string; canonicalUrl?: string; alternateLinks?: AlternateLink[]; + /** 当前页面的语言代码 (用于 RSS 发现) */ + locale?: 'zh' | 'ja' | 'en'; } const { @@ -17,8 +19,13 @@ const { lang = 'zh-CN', canonicalUrl, alternateLinks = [], + locale = 'zh', } = Astro.props; +// RSS Feed URLs +const rssCurrentLang = `/${locale}/rss.xml`; +const rssAll = '/rss.xml'; + const coverImageUrlStr = coverImageUrl ? `url(${coverImageUrl.toString()})` : 'none'; @@ -51,6 +58,21 @@ const coverImageUrlStr = coverImageUrl /> )) } + + + + + {siteTitle} diff --git a/src/pages/[lang]/rss.xml.ts b/src/pages/[lang]/rss.xml.ts new file mode 100644 index 0000000..a8cad62 --- /dev/null +++ b/src/pages/[lang]/rss.xml.ts @@ -0,0 +1,77 @@ +import rss from '@astrojs/rss'; +import type { APIContext } from 'astro'; +import { listPostsByLocale } from '@api/ghost/posts'; +import { getSiteInformation } from '@api/ghost/settings'; +import type { Post } from '@api/ghost/types'; +import { + type Locale, + LOCALES, + isLocale, + extractI18nKey, + LOCALE_NAMES, +} from '@lib/i18n'; +import { SITE_URL } from 'astro:env/server'; + +/** + * 生成多语言 RSS 静态路径 + */ +export function getStaticPaths() { + return LOCALES.map((lang) => ({ + params: { lang }, + })); +} + +/** + * 生成指定语言的 RSS Feed + */ +export async function GET(context: APIContext) { + const { lang } = context.params; + + // 验证语言参数 + if (!lang || !isLocale(lang)) { + return new Response('Invalid language', { status: 400 }); + } + + const locale = lang as Locale; + + // 获取站点设置和文章 + const [siteInfo, posts] = await Promise.all([ + getSiteInformation(), + listPostsByLocale(locale, { limit: 50 }), + ]); + + // 使用环境变量中的 SITE_URL,或者 context.site + const siteUrl = (SITE_URL || context.site?.toString() || '').replace( + /\/$/, + '', + ); + const languageName = LOCALE_NAMES[locale]; + + if (!siteUrl) { + return new Response('SITE_URL environment variable is required', { + status: 500, + }); + } + + return rss({ + title: `${siteInfo.title} (${languageName})`, + description: siteInfo.description || `${siteInfo.title} RSS Feed`, + site: siteUrl, + items: posts.map((post: Post) => { + // 提取 i18n key 作为文章路径 + const i18nKey = extractI18nKey(post.tags); + // 使用 i18n key 或者从 URL 提取 slug + const postSlug = + i18nKey || post.url.toString().split('/').filter(Boolean).pop(); + const postPath = `/${locale}/p/${postSlug}`; + + return { + title: post.title, + pubDate: new Date(post.published_at), + description: post.excerpt || '', + link: `${siteUrl}${postPath}`, + }; + }), + customData: `${locale}`, + }); +} diff --git a/src/pages/rss.xml.ts b/src/pages/rss.xml.ts new file mode 100644 index 0000000..d63a466 --- /dev/null +++ b/src/pages/rss.xml.ts @@ -0,0 +1,60 @@ +import rss from '@astrojs/rss'; +import type { APIContext } from 'astro'; +import { listAllPosts } from '@api/ghost/posts'; +import { getSiteInformation } from '@api/ghost/settings'; +import type { Post } from '@api/ghost/types'; +import { + extractI18nKey, + extractLocaleFromTags, + DEFAULT_LOCALE, +} from '@lib/i18n'; +import { SITE_URL } from 'astro:env/server'; + +/** + * 生成聚合 RSS Feed(包含所有语言的文章) + */ +export async function GET(context: APIContext) { + // 获取站点设置和所有文章 + const [siteInfo, posts] = await Promise.all([ + getSiteInformation(), + listAllPosts({ limit: 100 }), + ]); + + // 使用环境变量中的 SITE_URL,或者 context.site + const siteUrl = (SITE_URL || context.site?.toString() || '').replace( + /\/$/, + '', + ); + + if (!siteUrl) { + return new Response('SITE_URL environment variable is required', { + status: 500, + }); + } + + return rss({ + title: `${siteInfo.title} (All Languages)`, + description: + siteInfo.description || + `${siteInfo.title} RSS Feed - All Languages`, + site: siteUrl, + items: posts.map((post: Post) => { + // 提取文章的语言和 i18n key + const postLocale = + extractLocaleFromTags(post.tags) || DEFAULT_LOCALE; + const i18nKey = extractI18nKey(post.tags); + // 使用 i18n key 或者从 URL 提取 slug + const postSlug = + i18nKey || post.url.toString().split('/').filter(Boolean).pop(); + const postPath = `/${postLocale}/p/${postSlug}`; + + return { + title: post.title, + pubDate: new Date(post.published_at), + description: post.excerpt || '', + link: `${siteUrl}${postPath}`, + }; + }), + customData: `mul`, // mul = multiple languages + }); +}