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