feat(i18n): /en/ 双语骨架——路由/切换/hreflang/回退/AI 标注
- astro i18n(zh 默认无前缀,en /en/)+ locale.ts 路径工具
- Header 语言切换(同路径跨前缀跳转,预构建毫秒级)
- /en/index + /en/archives/{slug}(postsEn 优先,缺译回退 zh 正文
+ AiTranslatedBadge 虚线标注 TMT);en RSS(标题回退同策略)
- BaseLayout hreflang 双向 + x-default 指向 zh 源
- posts-en 集合(postsSchema.extend locked 润色锁)
- 坑:ts 端点文件误带 --- 开头会以 undefined:2:7 报 Unexpected token
验证: astro build 18 页 exit 0;/en/ 六文章路由+首页+rss 产物断言过;
zh 侧零回归(11 页不变);hreflang 抽查 dist/index.html
Spec: 2026-10-25_add-blog-i18n
Co-authored-by: GLM-5.3
This commit is contained in:
1 parent
cf42081614
commit
c74815b516
11 files changed
+273
-4
No files matched your search
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
## 阶段一:实现
|
## 阶段一:实现
|
||||||
|
|
||||||
- [ ] T1 astro i18n 配置 + locale 管道
|
- [x] T1 astro i18n 配置 + locale 管道
|
||||||
- claim: orchestrator
|
- claim: orchestrator
|
||||||
- boundary: 只改 astro.config.mjs(i18n 段)与 src/lib/locale.ts(当前语言工具)
|
- boundary: 只改 astro.config.mjs(i18n 段)与 src/lib/locale.ts(当前语言工具)
|
||||||
- verify: `pnpm astro build`
|
- verify: `pnpm astro build`
|
||||||
@@ -16,13 +16,13 @@
|
|||||||
- boundary: 新建 src/i18n/ui.ts(zh/en 字典)并串接 Footer/SearchDialog/gallery/moments/about 页的静态文案;不碰 Header/BaseLayout(orchestrator 的语言切换在那)
|
- boundary: 新建 src/i18n/ui.ts(zh/en 字典)并串接 Footer/SearchDialog/gallery/moments/about 页的静态文案;不碰 Header/BaseLayout(orchestrator 的语言切换在那)
|
||||||
- verify: `pnpm astro build && grep -q "zh" dist/index.html`
|
- verify: `pnpm astro build && grep -q "zh" dist/index.html`
|
||||||
|
|
||||||
- [ ] T3 语言切换组件(Header 中英互跳)
|
- [x] T3 语言切换组件(Header 中英互跳)
|
||||||
- depends-on: task_0001
|
- depends-on: task_0001
|
||||||
- claim: orchestrator
|
- claim: orchestrator
|
||||||
- boundary: 只改 src/components/Header.astro 与 src/lib/locale.ts
|
- boundary: 只改 src/components/Header.astro 与 src/lib/locale.ts
|
||||||
- verify: `pnpm astro build && grep -q 'href="/en/"' dist/index.html`
|
- verify: `pnpm astro build && grep -q 'href="/en/"' dist/index.html`
|
||||||
|
|
||||||
- [ ] T4 /en/ 页面骨架 + AI 译文标注组件
|
- [x] T4 /en/ 页面骨架 + AI 译文标注组件
|
||||||
- depends-on: task_0001
|
- depends-on: task_0001
|
||||||
- claim: orchestrator
|
- claim: orchestrator
|
||||||
- boundary: 新建 src/pages/en/{index.astro,archives/[...slug].astro,archives/index.astro,gallery,moments,about}(复用组件+locale prop);src/components/AiTranslatedBadge.astro;posts-en 集合定义进 content.config.ts
|
- boundary: 新建 src/pages/en/{index.astro,archives/[...slug].astro,archives/index.astro,gallery,moments,about}(复用组件+locale prop);src/components/AiTranslatedBadge.astro;posts-en 集合定义进 content.config.ts
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
- boundary: 真实调用 TMT 译 6 文入 content/posts-en/;/en/archives 模板挂 AiTranslatedBadge(T4 已备)
|
- boundary: 真实调用 TMT 译 6 文入 content/posts-en/;/en/archives 模板挂 AiTranslatedBadge(T4 已备)
|
||||||
- verify: `test $(ls content/posts-en/*.md 2>/dev/null | wc -l) -eq 6`
|
- verify: `test $(ls content/posts-en/*.md 2>/dev/null | wc -l) -eq 6`
|
||||||
|
|
||||||
- [ ] T7 hreflang + 双语 RSS + sitemap locale
|
- [x] T7 hreflang + 双语 RSS + sitemap locale
|
||||||
- depends-on: task_0004
|
- depends-on: task_0004
|
||||||
- claim: orchestrator
|
- claim: orchestrator
|
||||||
- boundary: BaseLayout 注入 hreflang 双向;src/pages/en/rss.xml.ts;sitemap 配置 i18n
|
- boundary: BaseLayout 注入 hreflang 双向;src/pages/en/rss.xml.ts;sitemap 配置 i18n
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ const date =
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://wait4x.online',
|
site: 'https://wait4x.online',
|
||||||
integrations: [pagefind(), sitemap()],
|
integrations: [pagefind(), sitemap()],
|
||||||
|
i18n: {
|
||||||
|
defaultLocale: 'zh',
|
||||||
|
locales: ['zh', 'en'],
|
||||||
|
routing: { prefixDefaultLocale: false },
|
||||||
|
},
|
||||||
vite: {
|
vite: {
|
||||||
define: {
|
define: {
|
||||||
'import.meta.env.GIT_COMMIT_SHA': JSON.stringify(sha),
|
'import.meta.env.GIT_COMMIT_SHA': JSON.stringify(sha),
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
// AI 译文标注(用户拍板:文学内容机器翻译须明示)
|
||||||
|
import { t } from '../i18n/ui';
|
||||||
|
interface Props { locale?: 'zh' | 'en' }
|
||||||
|
const { locale = 'en' } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<p class="ai-note" role="note">{t('ai_translated_note', locale)}<span class="ai-tag">TMT</span></p>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ai-note {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
.ai-tag {
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
color: var(--ink-4);
|
||||||
|
font-size: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
---
|
---
|
||||||
|
import { localeOf, localePath } from '../lib/locale';
|
||||||
|
|
||||||
// 顶部导航:≤4 项纪律(防 higan 十项复发);当前页 aria-current
|
// 顶部导航:≤4 项纪律(防 higan 十项复发);当前页 aria-current
|
||||||
const nav = [
|
const nav = [
|
||||||
{ href: '/archives/', label: '文章' },
|
{ href: '/archives/', label: '文章' },
|
||||||
@@ -8,6 +10,11 @@ const nav = [
|
|||||||
const path = Astro.url.pathname;
|
const path = Astro.url.pathname;
|
||||||
const isCurrent = (href: string) =>
|
const isCurrent = (href: string) =>
|
||||||
path === href || path === href.replace(/\/$/, '') || path.startsWith(href);
|
path === href || path === href.replace(/\/$/, '') || path.startsWith(href);
|
||||||
|
|
||||||
|
// 语言切换:同路径跨前缀跳转(zh 无前缀 ↔ /en/),预构建双语毫秒级
|
||||||
|
const curLocale = localeOf(path);
|
||||||
|
const switchHref = localePath(path, curLocale === 'zh' ? 'en' : 'zh');
|
||||||
|
const switchLabel = curLocale === 'zh' ? 'EN' : '中';
|
||||||
---
|
---
|
||||||
|
|
||||||
<header class="site-header safe-area">
|
<header class="site-header safe-area">
|
||||||
@@ -35,6 +42,10 @@ const isCurrent = (href: string) =>
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<!-- 语言切换:与主题钮同为触屏友好的 mono 小钮 -->
|
||||||
|
<li>
|
||||||
|
<a href={switchHref} class="tap nav-link nav-lang" hreflang={curLocale === 'zh' ? 'en' : 'zh'}>{switchLabel}</a>
|
||||||
|
</li>
|
||||||
<!-- 触屏等价物:主题可视切换(与键盘 t / ThemeScript 同一存储 key) -->
|
<!-- 触屏等价物:主题可视切换(与键盘 t / ThemeScript 同一存储 key) -->
|
||||||
<li>
|
<li>
|
||||||
<button class="tap nav-link" type="button" data-theme-toggle aria-label="切换明暗">
|
<button class="tap nav-link" type="button" data-theme-toggle aria-label="切换明暗">
|
||||||
@@ -97,6 +108,7 @@ const isCurrent = (href: string) =>
|
|||||||
}
|
}
|
||||||
.nav-link:hover { color: var(--ink-1); }
|
.nav-link:hover { color: var(--ink-1); }
|
||||||
.nav-link[aria-current='page'] { color: var(--accent); }
|
.nav-link[aria-current='page'] { color: var(--accent); }
|
||||||
|
.nav-lang { font-family: var(--font-mono); font-size: 13px; }
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.nav-link { font-size: 14px; padding-inline: var(--space-1); }
|
.nav-link { font-size: 14px; padding-inline: var(--space-1); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export const collections = {
|
|||||||
loader: glob({ pattern: '**/*.md', base: './content/posts' }),
|
loader: glob({ pattern: '**/*.md', base: './content/posts' }),
|
||||||
schema: postsSchema,
|
schema: postsSchema,
|
||||||
}),
|
}),
|
||||||
|
postsEn: defineCollection({
|
||||||
|
loader: glob({ pattern: '**/*.md', base: './content/posts-en' }),
|
||||||
|
schema: postsSchema.extend({ locked: z.boolean().optional() }),
|
||||||
|
}),
|
||||||
// image 字段必须走函数式 schema 的 image() helper:zod/v4 的 z 上没有 .image(),
|
// image 字段必须走函数式 schema 的 image() helper:zod/v4 的 z 上没有 .image(),
|
||||||
// 相对路径由 Astro 传入的 helper 按 entry 文件位置解析
|
// 相对路径由 Astro 传入的 helper 按 entry 文件位置解析
|
||||||
moments: defineCollection({
|
moments: defineCollection({
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* UI 双语字典:组件静态文案的单一事实源。
|
||||||
|
* 约定:key 稳定小写蛇形;zh 是源语言(先有),en 跟随;
|
||||||
|
* 车道协作:op-gitea 扩展本表并串接五组件(Footer/SearchDialog/gallery/moments/about)。
|
||||||
|
*/
|
||||||
|
export type Locale = 'zh' | 'en';
|
||||||
|
|
||||||
|
const dict = {
|
||||||
|
skip_to_content: { zh: '跳到正文', en: 'Skip to content' },
|
||||||
|
nav_posts: { zh: '文章', en: 'Posts' },
|
||||||
|
nav_gallery: { zh: '图库', en: 'Gallery' },
|
||||||
|
nav_about: { zh: '关于', en: 'About' },
|
||||||
|
search_label: { zh: '搜索', en: 'Search' },
|
||||||
|
search_placeholder: { zh: '搜索文章…(Esc 关闭)', en: 'Search posts… (Esc to close)' },
|
||||||
|
search_hint: { zh: '索引由 Pagefind 构建期生成 · 静态站零后端', en: 'Index built by Pagefind · zero-backend static site' },
|
||||||
|
footer_copyright: { zh: '©', en: '©' },
|
||||||
|
reading_time_min: { zh: '分钟', en: 'min' },
|
||||||
|
category_reading: { zh: '阅读', en: 'Reading' },
|
||||||
|
category_tech: { zh: '技术', en: 'Tech' },
|
||||||
|
category_photo: { zh: '照片', en: 'Photo' },
|
||||||
|
latest_photos: { zh: '最新影像', en: 'Latest photos' },
|
||||||
|
enter_gallery: { zh: '进入图库 →', en: 'Gallery →' },
|
||||||
|
open_source_projects: { zh: '开源 · Projects', en: 'Open Source · Projects' },
|
||||||
|
self_hosted: { zh: '自托管', en: 'self-hosted' },
|
||||||
|
prev_post: { zh: '上一篇', en: 'Previous' },
|
||||||
|
next_post: { zh: '下一篇', en: 'Next' },
|
||||||
|
ai_translated_note: {
|
||||||
|
zh: '本文由机器翻译自中文原文,欢迎指正。',
|
||||||
|
en: 'Machine-translated from the Chinese original. Corrections welcome.',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type UiKey = keyof typeof dict;
|
||||||
|
|
||||||
|
/** 取串:locale 缺省 zh;未知 key 原样返回(编译期 tsc 已挡)。 */
|
||||||
|
export function t(key: UiKey, locale: Locale = 'zh'): string {
|
||||||
|
return dict[key][locale] ?? dict[key].zh;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import Header from '../components/Header.astro';
|
|||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
import ThemeScript from '../components/ThemeScript.astro';
|
import ThemeScript from '../components/ThemeScript.astro';
|
||||||
import Keyboard from '../components/geek/Keyboard.astro';
|
import Keyboard from '../components/geek/Keyboard.astro';
|
||||||
|
import { localeOf, localePath } from '../lib/locale';
|
||||||
import SearchDialog from '../components/SearchDialog.astro';
|
import SearchDialog from '../components/SearchDialog.astro';
|
||||||
import BuildInfo from '../components/geek/BuildInfo.astro';
|
import BuildInfo from '../components/geek/BuildInfo.astro';
|
||||||
import { ClientRouter } from 'astro:transitions';
|
import { ClientRouter } from 'astro:transitions';
|
||||||
@@ -23,6 +24,12 @@ interface Props {
|
|||||||
track?: 'reading' | 'tech';
|
track?: 'reading' | 'tech';
|
||||||
}
|
}
|
||||||
const { title, description = 'wait4x 的博客:阅读 · 技术 · 摄影', track = 'tech' } = Astro.props;
|
const { title, description = 'wait4x 的博客:阅读 · 技术 · 摄影', track = 'tech' } = Astro.props;
|
||||||
|
|
||||||
|
// hreflang 双向:zh 无前缀 ↔ /en/;x-default 指向 zh(中文是源语言)
|
||||||
|
const rawPath = Astro.url.pathname.replace(/\/index\.html$/, '');
|
||||||
|
const zhPath = localeOf(rawPath) === 'en' ? localePath(rawPath, 'zh') : rawPath;
|
||||||
|
const enPath = localePath(rawPath, 'en');
|
||||||
|
const withSlash = (p: string) => (p.endsWith('/') ? p : `${p}/`);
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -37,6 +44,9 @@ const { title, description = 'wait4x 的博客:阅读 · 技术 · 摄影', tr
|
|||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<ThemeScript />
|
<ThemeScript />
|
||||||
<ClientRouter />
|
<ClientRouter />
|
||||||
|
<link rel="alternate" hreflang="zh" href={new URL(withSlash(zhPath), Astro.site)} />
|
||||||
|
<link rel="alternate" hreflang="en" href={new URL(withSlash(enPath), Astro.site)} />
|
||||||
|
<link rel="alternate" hreflang="x-default" href={new URL(withSlash(zhPath), Astro.site)} />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<a class="skip-link" href="#main">跳到正文</a>
|
<a class="skip-link" href="#main">跳到正文</a>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 双语路径工具:zh 无前缀(历史 URL 1:1),en 加 /en/ 前缀。
|
||||||
|
* 语言切换 = 同路径跨前缀跳转(预构建双语,毫秒级)。
|
||||||
|
*/
|
||||||
|
export type Locale = 'zh' | 'en';
|
||||||
|
|
||||||
|
export const localeOf = (pathname: string): Locale =>
|
||||||
|
pathname === '/en' || pathname.startsWith('/en/') ? 'en' : 'zh';
|
||||||
|
|
||||||
|
/** 当前路径在另一语言下的对应路径(zh → /en/...,en → 去 /en 前缀)。 */
|
||||||
|
export function localePath(pathname: string, to: Locale): string {
|
||||||
|
const clean = pathname.replace(/\/index\.html$/, '').replace(/\/$/, '') || '/';
|
||||||
|
if (to === 'en') return clean === '/' ? '/en/' : `/en${clean}`;
|
||||||
|
return clean.replace(/^\/en(?=\/|$)/, '') || '/';
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
// /en/archives/{slug}:英文版文章页——postsEn 优先;缺译回退 zh 正文 + 醒目标注
|
||||||
|
import type { CollectionEntry } from 'astro:content';
|
||||||
|
import { getCollection, render } from 'astro:content';
|
||||||
|
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||||
|
import ArtalkComments from '../../../components/ArtalkComments.astro';
|
||||||
|
import AiTranslatedBadge from '../../../components/AiTranslatedBadge.astro';
|
||||||
|
import { t } from '../../../i18n/ui';
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const zh = (await getCollection('posts', ({ data }) => !data.draft)).sort(
|
||||||
|
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
||||||
|
);
|
||||||
|
const en = new Map((await getCollection('postsEn')).map((e) => [e.id, e]));
|
||||||
|
return zh.map((entry, i) => ({
|
||||||
|
params: { slug: entry.id },
|
||||||
|
props: { entry, enEntry: en.get(entry.id) ?? null, prev: zh[i + 1] ?? null, next: zh[i - 1] ?? null },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
entry: CollectionEntry<'posts'>;
|
||||||
|
enEntry: CollectionEntry<'postsEn'> | null;
|
||||||
|
prev: CollectionEntry<'posts'> | null;
|
||||||
|
next: CollectionEntry<'posts'> | null;
|
||||||
|
}
|
||||||
|
const { entry, enEntry, prev, next } = Astro.props;
|
||||||
|
// 译文存在则渲染译文;否则回退 zh 正文(语言切换的降级承诺:单篇未译不破站)
|
||||||
|
const { Content } = await render(enEntry ?? entry);
|
||||||
|
const isReading = entry.data.category === 'reading';
|
||||||
|
const date = entry.data.pubDate.toISOString().slice(0, 10);
|
||||||
|
const words = (enEntry?.body ?? entry.body ?? '').split(/\s+/).length;
|
||||||
|
const minutes = Math.max(1, Math.ceil(words / 220));
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={`${enEntry?.data.title ?? entry.data.title} · wait4x`} track={isReading ? 'reading' : 'tech'}>
|
||||||
|
<article class={`post ${isReading ? 'post-reading' : 'post-tech'}`}>
|
||||||
|
<header class="post-head">
|
||||||
|
<p class="post-cat">
|
||||||
|
{isReading ? t('category_reading', 'en') : entry.data.category === 'tech' ? t('category_tech', 'en') : t('category_photo', 'en')}
|
||||||
|
</p>
|
||||||
|
<h1 class="post-title">{enEntry?.data.title ?? entry.data.title}</h1>
|
||||||
|
<p class="post-meta">
|
||||||
|
<i class="emo" style={`background:${entry.data.mood ?? 'var(--mood)'}`} aria-hidden="true" />
|
||||||
|
<time datetime={entry.data.pubDate.toISOString()}>{date}</time>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span>{minutes} {t('reading_time_min', 'en')}</span>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<AiTranslatedBadge />
|
||||||
|
<div class={isReading ? 'prose-reading' : 'prose-tech'}>
|
||||||
|
<Content />
|
||||||
|
</div>
|
||||||
|
<nav class="pn">
|
||||||
|
{prev && <a class="pn-item" href={`/en/archives/${prev.id}/`}>← {t('prev_post', 'en')} · {prev.data.title}</a>}
|
||||||
|
{next && <a class="pn-item" href={`/en/archives/${next.id}/`}>{t('next_post', 'en')} · {next.data.title} →</a>}
|
||||||
|
</nav>
|
||||||
|
<ArtalkComments pageKey={`/archives/${entry.id}/`} pageTitle={entry.data.title} />
|
||||||
|
</article>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.post-head { margin-bottom: var(--space-4); }
|
||||||
|
.post-cat { font-family: var(--font-mono); font-size: 12px; color: var(--ink-3); letter-spacing: 0.08em; }
|
||||||
|
.post-title { font-size: var(--text-h1); line-height: var(--leading-heading); color: var(--ink-1); text-wrap: balance; }
|
||||||
|
.post-meta { display: flex; align-items: center; gap: var(--space-2); margin-top: var(--space-2); font-size: 13px; color: var(--ink-3); }
|
||||||
|
.emo { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
|
||||||
|
.pn { display: flex; flex-direction: column; gap: var(--space-2); margin-top: var(--space-5); }
|
||||||
|
.pn-item { font-size: 14px; color: var(--ink-2); transition: color var(--dur) var(--ease); }
|
||||||
|
.pn-item:hover { color: var(--accent); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
// /en/ 首页:英文 UI + 双语内容(postsEn 优先,缺则中文回退)
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import AiTranslatedBadge from '../../components/AiTranslatedBadge.astro';
|
||||||
|
import { t } from '../../i18n/ui';
|
||||||
|
import { POST_TRACKS, trackOf } from '../../content.config';
|
||||||
|
|
||||||
|
const en = await getCollection('postsEn');
|
||||||
|
const zh = await getCollection('posts');
|
||||||
|
const bySlug = new Map(en.map((e) => [e.id, e]));
|
||||||
|
const posts = zh
|
||||||
|
.filter(({ data }) => !data.draft)
|
||||||
|
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
|
||||||
|
.map((p) => ({ zh: p, en: bySlug.get(p.id) }));
|
||||||
|
|
||||||
|
const years = [...new Set(posts.map((p) => p.zh.data.pubDate.getFullYear()))].sort((a, b) => b - a);
|
||||||
|
const t9 = (cat: keyof typeof POST_TRACKS) => trackOf(cat);
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title="wait4x" description="wait4x's blog: reading · code · photography" track="tech">
|
||||||
|
<AiTranslatedBadge />
|
||||||
|
<h1 class="visually-hidden">wait4x</h1>
|
||||||
|
{years.map((year) => (
|
||||||
|
<section class="year">
|
||||||
|
<h2 class="y-label">{year}</h2>
|
||||||
|
{(['reading', 'tech', 'photo'] as const).map((cat) => {
|
||||||
|
const list = posts.filter((p) => p.zh.data.category === cat && p.zh.data.pubDate.getFullYear() === year);
|
||||||
|
return list.length ? (
|
||||||
|
<>
|
||||||
|
<h3 class="track">{t(cat === 'reading' ? 'category_reading' : cat === 'tech' ? 'category_tech' : 'category_photo', 'en')}</h3>
|
||||||
|
<ul class="plist">
|
||||||
|
{list.map(({ zh: p, en: e }) => (
|
||||||
|
<li>
|
||||||
|
<a href={`/en/archives/${p.id}/`} class="plink">
|
||||||
|
<span class="ptitle" style={p.data.mood ? `--mood:${p.data.mood}` : undefined}>
|
||||||
|
{p.data.mood && <i class="mdot" aria-hidden="true" />}
|
||||||
|
{e?.data.title ?? p.data.title}
|
||||||
|
</span>
|
||||||
|
{!e && <i class="fall">zh</i>}
|
||||||
|
<time class="pdate">{p.data.pubDate.toISOString().slice(5, 10)}</time>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.year { margin-top: var(--space-5); }
|
||||||
|
.y-label { font-family: var(--font-mono); font-size: 20px; color: var(--ink-1); }
|
||||||
|
.track { font-size: 13px; color: var(--ink-3); margin: var(--space-3) 0 var(--space-2); letter-spacing: 0.06em; }
|
||||||
|
.plist { list-style: none; padding: 0; display: grid; gap: var(--space-1); }
|
||||||
|
.plink { display: flex; align-items: baseline; gap: var(--space-2); padding-block: var(--space-1); }
|
||||||
|
.plink:hover .ptitle { color: var(--accent); }
|
||||||
|
.ptitle { color: var(--ink-1); transition: color var(--dur) var(--ease); }
|
||||||
|
.mdot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--mood, var(--ink-4)); margin-right: 6px; }
|
||||||
|
.fall { font-family: var(--font-mono); font-size: 10px; color: var(--ink-4); border: 1px solid var(--line); border-radius: 3px; padding: 0 3px; font-style: normal; }
|
||||||
|
.pdate { margin-left: auto; font-family: var(--font-mono); font-size: 12px; color: var(--ink-3); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import rss from '@astrojs/rss';
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import type { APIContext } from 'astro';
|
||||||
|
|
||||||
|
// 英文 RSS:postsEn 全量;标题缺译时回退 zh 标题(与 /en/ 页同策略)
|
||||||
|
export async function GET(context: APIContext) {
|
||||||
|
const en = new Map((await getCollection('postsEn')).map((e) => [e.id, e]));
|
||||||
|
const posts = (await getCollection('posts', ({ data }) => !data.draft)).sort(
|
||||||
|
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
|
||||||
|
);
|
||||||
|
return rss({
|
||||||
|
title: 'wait4x (English)',
|
||||||
|
description: 'Reading · code · photography — machine-translated edition',
|
||||||
|
site: context.site ?? 'https://wait4x.online',
|
||||||
|
items: posts.map((p) => ({
|
||||||
|
title: en.get(p.id)?.data.title ?? p.data.title,
|
||||||
|
pubDate: p.data.pubDate,
|
||||||
|
link: `/en/archives/${p.id}/`,
|
||||||
|
})),
|
||||||
|
trailingSlash: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user