1. 路由总表

所有页面均为构建期静态生成(Astro 默认 output: 'static')。src/pages/ 下共 16 个文件,对应下列路由:

URL源文件渲染组件title备注
/src/pages/index.astroHomePage无(用 title - subtitle)第 1 页
/{n}/(n≥2)src/pages/[page].astroHomePagepagination.pageTitle与首页共用组件,filter(page > 1) 排除第 1 页
/posts/{slug}/src/pages/posts/[slug].astroPostPagepost.data.titleseoType="article",showTocButton
/archive/src/pages/archive.astroArchivePagearchive.title全部文章(置顶区 + 年份分组)
/categories/src/pages/categories/index.astroTaxonomyPage(kind="categories")categories.title搜索 + 排序
/categories/{分类名}/src/pages/categories/[name].astroArchivePagepageTitle.category复用归档布局
/tags/src/pages/tags/index.astroTaxonomyPage(kind="tags")tags.title标签云(与分类页共用组件)
/tags/{标签名}/src/pages/tags/[name].astroArchivePagepageTitle.tag复用归档布局
/series/src/pages/series/index.astroSeriesIndexPageseries.title系列索引(系列名 + 篇数)
/series/{系列名}/src/pages/series/[name].astroSeriesDetailPagepageTitle.series系列总览,按 series_order 升序
/friends/src/pages/friends.astroFriendsPagenav.friends底部带 Comments
/about/src/pages/about.astroAboutPagepageTitle.about内容硬编码
/404.htmlsrc/pages/404.astro内联404静态托管兜底页
/rss.xmlsrc/pages/rss.xml.tsAPI Route—@astrojs/rss
/robots.txtsrc/pages/robots.txt.tsAPI Route—指向 sitemap-index.xml
/sitemap-index.xml、/sitemap-0.xml@astrojs/sitemap 集成——自动生成
/content-images/{hash}.{ext}src/pages/content-images/[file].tsAPI Route(prerender = true)—文章图片
/pagefind/*pagefind CLI(postbuild.js)——搜索索引

三种动态路由的区别

文件动态参数来源生成方式
[page].astroAstro 的 paginate() 辅助函数GetStaticPathsOptions.paginate
posts/[slug].astro、categories/[name].astro、tags/[name].astro、series/[name].astro文章 / 系列集合手写 getStaticPaths()
content-images/[file].ts文件系统遍历手写 getStaticPaths()(fs.readdir 递归)

2. 统一页面骨架

每个路由文件都是同一个模式(这是二次开发的入口约定):

astro
123456789101112131415
---
import XxxPage from '@/components/views/XxxPage.astro'
import { defaultLang, useTranslations } from '@/i18n/ui'
import BaseLayout from '@/layouts/BaseLayout.astro'
import { ... } from '@/lib/posts'

const lang = defaultLang
const t = useTranslations(lang)
const posts = await getPublishedPosts()          // ← 取数
const xxx = groupByXxx(posts)                    // ← 派生数据
---

<BaseLayout lang={lang} title={t('xxx.title')}>  {/* ← 布局 + SEO */}
  <XxxPage lang={lang} xxx={xxx} />              {/* ← 页面组件 */}
</BaseLayout>

要新增一个页面,照着这个模式写三块即可。默认导出内容为 .astro 模板,动态路由额外需要 export async function getStaticPaths()。

3. 各路由的取数逻辑

3.1 index.astro

ts
1234
const all = await getPublishedPosts()
const perPage = siteConfig.postsPerPage
const totalPages = Math.ceil(all.length / perPage) || 1
const posts = all.slice(0, perPage)

|| 1 保证无文章时总页数为 1(避免 Pagination 出现 0 页)。

3.2 [page].astro

ts
12345
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
  const posts = await getPublishedPosts()
  return paginate(posts, { params: {}, pageSize: siteConfig.postsPerPage })
    .filter((p) => Number(p.params.page) > 1)      // ← 关键:排除第 1 页
}
细节说明
为什么排除第 1 页第 1 页由 index.astro 负责,否则 / 与 /1/ 会生成重复内容
参数格式paginate(posts, { params: {} }) → 路由为 /{page}/
未分页时posts.length <= perPage 时 paginate 只返回 1 页,被 filter 掉 → 该路由不生成任何页面

3.3 posts/[slug].astro

ts
1234567891011
export async function getStaticPaths() {
  const posts = await getPublishedPosts()
  return posts.map((post, idx) => ({
    params: { slug: post.id },                       // post.id 即 frontmatter 的 slug
    props: {
      post,
      prevPost: idx > 0 ? posts[idx - 1] : null,
      nextPost: idx < posts.length - 1 ? posts[idx + 1] : null,
    },
  }))
}
细节说明
prev / next 语义按 getPublishedPosts() 的顺序(置顶优先,再时间倒序)取相邻项。prevPost 为列表中前一项(更靠前/更新)
首尾处理无上一/下一篇时传 null,PostPage 渲染为不可点击的占位块(opacity-40)
OG 图post.body 正则匹配第一张图:/!\[[^\]]*\]\(([^)\s]+)/,传给 BaseLayout 的 seoImage

排序对「上一篇/下一篇」的影响

置顶文章(index > 0)会排在列表最前,因此它们的「下一篇」会指向置顶序列中的下一项,而不是时间上更新的文章。若你希望 prev/next 纯粹按时间,需要在 getStaticPaths 里单独按 published 排序一份列表。

有 series 的文章例外:getStaticPaths 会额外把同系列文章按 series_order 升序排成 seriesPosts,并计算当前文章的下标 seriesIdx。PostPage 有系列时用系列内相邻的上一/下一篇(首篇/末篇显示禁用态),与全局排序无关;无系列时仍走全局 prev/next。

3.4 categories/[name].astro 与 tags/[name].astro

ts
123456
// 分类
const names = [...new Set(posts.map((p) => p.data.category))]
return names.map((name) => ({ params: { name }, props: { name } }))

// 标签
const names = [...new Set(posts.flatMap((p) => p.data.tags))]

注意路径编码的不对称(容易踩坑):

环节编码处理
生成链接(PostCard / PostPage / TaxonomyPage)encodeURIComponent(name)
params 传值原始名(Astro 会自动编码到 URL)
过滤p.data.category === name / p.data.tags.includes(name)(比较原始值)

所以中文分类/标签能正常工作:URL 里是 %E7%A4%BA%E4%BE%8B,params.name 会由 Astro 解码回 示例。

若分类名里含 /,encodeURIComponent 会编成 %2F,但很多静态托管会在路由层解码后当作路径分隔符 → 建议分类/标签名不要包含 /。

4. SEO 与元信息

4.1 BaseLayout 输出的标签

标签值来源
<title>{siteConfig.title} - {title};无 title 时 {siteConfig.title} - {siteConfig.subtitle}props
<meta name="description">description prop,默认 siteConfig.descriptionprops
<link rel="canonical">new URL(Astro.url.pathname, Astro.site).href自动
<link rel="icon" type="image/webp">硬编码 /logo.webpBaseLayout
og:site_name / og:type / og:title / og:description / og:url / og:image / og:locale对应值props + ogLocale 映射
article:published_time / article:modified_time仅 seoType === 'article' 且有值时props
twitter:card固定 summaryBaseLayout
twitter:title / twitter:description / twitter:image同 OGprops
JSON-LD BlogPosting仅 seoType === 'article' && title 时输出见下

4.2 OG locale 映射

ts
1
const ogLocale = { zh: 'zh_CN', ja: 'ja_JP', en: 'en_US' }[lang]

加语言时需同步此表。

4.3 JSON-LD 结构

json
12345678910111213141516
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "<文章标题>",
  "description": "<描述>",
  "image": "<og 图绝对 URL>",
  "datePublished": "<published ISO>",        // 仅当 publishedTime 传入时输出
  "dateModified": "<updated 或 published ISO>", // 仅当 modifiedTime 或 publishedTime 传入时输出
  "author": { "@type": "Person", "name": "<siteConfig.author>" },
  "publisher": {
    "@type": "Organization",
    "name": "<siteConfig.title>",
    "logo": { "@type": "ImageObject", "url": "<siteConfig.logo 绝对 URL>" }
  },
  "mainEntityOfPage": "<canonical URL>"
}

以 is:inline 内联脚本输出,不经过 postbuild.js 压缩(type 是 application/ld+json,不在 JS_TYPES 白名单)。

4.4 RSS(src/pages/rss.xml.ts)

字段值
title / description / sitesiteConfig 对应值
customData<language>{defaultLang}</language>
条目每个已发布文章一条
link/posts/{slug}/(相对,由 @astrojs/rss 拼绝对)
categories[category, ...tags]
contentrenderMarkdownHtml(post.body, { filePath, site }) 的输出

absolutize():把内容里的相对 src/href 转为绝对 URL,基准是 {site}/posts/{slug}/。已跳过 http(s):、data:、#、mailto:、// 开头的值。

RSS 用的是独立的 markdown 管线

src/lib/md/pipeline.ts 与 astro.config.mjs 的 markdown 配置是两份独立的插件链,差异如下:

项页面(astro.config.mjs)RSS(pipeline.ts)
插件来源Astro 的 markdown.processor手写 unified()
脚注标题按 defaultLang 取 i18n 的 post.footnotes固定 'footnote'
rehypeRaw由 Astro/remark-rehype 内部保留 raw 节点显式 .use(rehypeRaw)
rehypeAutolinkHeadings 的 ariaLabel'Heading anchor''heading anchor'(小写 a)
gfm / smartypants通过 unified({ gfm: true, smartypants: false })显式 remarkGfm,无 smartypants

改 markdown 渲染时两份都要改,否则页面与 RSS 会出现差异。

4.5 robots.txt

ts
1234
User-agent: *
Allow: /

Sitemap: {site}/sitemap-index.xml

4.6 Sitemap

@astrojs/sitemap 无参数配置,输出 /sitemap-index.xml + /sitemap-0.xml。SiteFooter 的链接硬编码为 /sitemap-index.xml。

5. i18n 现状与扩展

5.1 重要前提:只构建默认语言的页面

事实证据
所有路由都写 const lang = defaultLangindex.astro、archive.astro、posts/[slug].astro … 全部如此
getLangFromUrl() 没有任何调用点全项目 grep 只在该函数定义处出现
localePath(lang, path) 在 lang === defaultLang 时返回原路径所以所有链接都不带语言前缀

结论:i18n 当前是「单语言站点 + 三套 UI 词条」。切换 siteConfig.defaultLang 会整体换掉界面文案,但不会同时提供 /en/、/ja/ 路由。

5.2 三语词条文件

文件语言大小
src/i18n/zh.json中文133 行
src/i18n/ja.json日语—
src/i18n/en.json英语—

词条结构与 key 清单(以 zh.json 为准):

顶层 key子 key用途
navhome archive categories tags friends about导航与页面标题
headermenu switchToLight switchToDark头部
searchtitle placeholder hint empty failed open moreHits搜索弹窗(moreHits 含 {n} 占位,为「本文还有 N 处命中」)
postuncategorized words minutes visitorsUnit updated prev next noMore toc tableOfContents collapseToc backHome copy copied footnotes文章页
post.copyrightauthor(含 {year})licenseLabel licenseText(含 {link} 占位)licenseName mayTitle mayText mustTitle mustText originalLink disclaimer版权块
listempty pinned readMore postsUnit列表
archivetitle subtitle归档
categoriestitle subtitle searchPlaceholder empty total(含 {n})分类页
tagstitle subtitle searchPlaceholder empty total(含 {n})标签页
friendssubtitle emptyTitle emptySub(含 {config} 占位,用 set:html 注入 <code>)友链
profilewordsUnit hoursUnit articlesUnit wordsLabel hoursLabel articlesLabel pvUnit pvLabel uvUnit uvLabel首页名片
paginationprev next pageTitle(含 {n})分页
pageTitlecategory(含 {name})tag(含 {name})about页面标题
footerrss sitemap running(含 {days})页脚
notFoundtitle message back home404
miscsortByName sortByCount backToTop clear通用(clear 为分类/标签页搜索框清空按钮的 aria-label / title)
playertitle toggle play pause stop prev next shuffle order loop volume playlist empty音乐播放器

5.3 取词 API(src/i18n/ui.ts)

URL 与语言判定统一在 src/lib/url.ts(Lang / langs / defaultLang / getLangFromUrl / localePath / isAssetUrl / shouldIgnoreSwup),src/i18n/ui.ts 转导出其中的语言常量与路径函数以兼容既有引用,取词函数 useTranslations 留在 ui.ts。

ts
1234567
import { defaultLang, localePath, useTranslations, type Lang, langs, getLangFromUrl } from '@/i18n/ui'

const t = useTranslations('zh')
t('nav.home')                              // → '首页'
t('pagination.pageTitle', { n: 2 })        // → '第 2 页'(替换 {n})
t('footer.running', { days: 30 })          // → '博客已运行 30 天'
t('不存在的.key')                           // → 原样返回 key(便于发现缺失)
导出类型说明
Lang'zh' | 'ja' | 'en'语言类型(定义在 src/lib/url.ts)
langsLang[]['zh', 'ja', 'en']
defaultLangLang来自 siteConfig.defaultLang
getLangFromUrl(url)(URL) => Lang从路径首段判断语言(当前未被调用)
localePath(lang, path)(Lang, string) => string生成带语言前缀的路径
useTranslations(lang)(Lang) => (key, params?) => string取词函数(ui.ts 自身导出)
isAssetUrl(path)(string) => boolean资源 / 保留目录判定(无扩展名白名单外的 .xml .json .png 等,或 /pagefind/ /fonts/ /images/ 前缀)
shouldIgnoreSwup(url)(string) => booleanSwup ignore 判定:带 # 或资源路径;astro.config.mjs 直接引用该函数

shouldIgnoreSwup 必须自包含

@swup/astro 会把传给它的 ignore 函数序列化成源码、在浏览器里用 eval 还原(@swup_astro_serialise.js 的 reviver),因此函数体内不能引用模块作用域的变量或常量(V1.0.9 之后把判定集中到 lib/url.ts 时踩过:改成调用 isAssetUrl() 后每次加载都抛 ReferenceError: isAssetUrl is not defined,Swup 的预加载判定随之失效)。所以 shouldIgnoreSwup 内部直接写正则字面量,isAssetUrl 与它规则相同但仅供程序内调用,不要改成互相引用。两侧正则字面量需保持逐字一致,改动任一侧后跑 pnpm check-swup-ignore(scripts/check-swup-ignore.js)校验,不一致 exit 1 并列出差异。

t() 的内部实现:按 . 拆 key 逐层下钻;非字符串结果回退为 key 本身;{param} 用 replaceAll 替换。

localePath() 的行为:

ts
12345
localePath('zh', '/about')      // → '/about/'      (默认语言不加前缀,补尾斜杠)
localePath('en', '/about')      // → '/en/about/'
localePath('zh', '/rss.xml')    // → '/rss.xml'     (含 . 的最后一段视为文件,不补斜杠)
localePath('zh', '/')           // → '/'
localePath('en', '/')           // → '/en'

5.4 加一门语言(以韩语 ko 为例)

完整的多语言路由需要自行实现。

下面只完成「词条与类型」部分。要真正产出 /ko/… 页面,需要为每个路由写语言维度:

ts
12345678
export async function getStaticPaths() {
  return langs.flatMap((lang) =>
    posts.map((post) => ({
      params: { lang, slug: post.id },      // 需要把路由挪到 src/pages/[lang]/ 下
      props: { post, lang },
    })),
  )
}

并把 src/pages/*.astro 移入 src/pages/[lang]/(默认语言可用 [...] 可选参数或单独保留一份)。这是一次结构性改动,建议先确认是否真的需要。

仅换界面语言(最小成本):

  1. src/i18n/ko.json —— 复制 zh.json 并翻译(key 结构必须完全一致);

  2. src/i18n/ui.ts —— 三处改动:

    ts
    1234
    import ko from './ko.json'
    export type Lang = 'zh' | 'ja' | 'en' | 'ko'
    export const langs: Lang[] = ['zh', 'ja', 'en', 'ko']
    const messages: Record<Lang, Record<string, unknown>> = { zh, ja, en, ko }
    
  3. src/site.config.ts —— defaultLang: 'ko' as 'zh' | 'ja' | 'en' | 'ko'(注意 Lang 联合类型也要同步);

  4. astro.config.mjs —— 脚注标题映射:

    js
    1
    const footnoteLabel = { zh: zhJson, ja: jaJson, en: enJson, ko: koJson }[siteConfig.defaultLang].post.footnotes
    
  5. BaseLayout.astro —— OG locale 映射:{ zh: 'zh_CN', ja: 'ja_JP', en: 'en_US', ko: 'ko_KR' };

  6. ProfileCard.astro —— 数字本地化:{ zh: 'zh-CN', ja: 'ja-JP', en: 'en-US', ko: 'ko-KR' }[lang]。

漏改第 4/5/6 项不会报错,但会静默回退(脚注标题变 undefined、OG locale 报错)。建议加完语言后跑一次 pnpm check 与 pnpm build 并检查文章页与首页。

6. 新增页面与导航项

6.1 加一个静态页面(例:/guestbook/)

第 1 步:创建页面组件 src/components/views/GuestbookPage.astro

astro
123456789101112131415
---
import Comments from '@/components/Comments.astro'
import { defaultLang, type Lang, useTranslations } from '@/i18n/ui'

interface Props { lang?: Lang }
const { lang = defaultLang } = Astro.props
const t = useTranslations(lang)
---

<div class="mx-auto mt-8 max-w-[800px]">
  <header class="mb-8 text-center">
    <h1 class="font-serif text-[1.75rem] font-bold text-ink">{t('nav.guestbook')}</h1>
  </header>
  <Comments />
</div>

第 2 步:创建路由 src/pages/guestbook.astro

astro
123456789101112
---
import GuestbookPage from '@/components/views/GuestbookPage.astro'
import { defaultLang, useTranslations } from '@/i18n/ui'
import BaseLayout from '@/layouts/BaseLayout.astro'

const lang = defaultLang
const t = useTranslations(lang)
---

<BaseLayout lang={lang} title={t('nav.guestbook')}>
  <GuestbookPage lang={lang} />
</BaseLayout>

第 3 步:加词条(三个 JSON 都要加)

json
12
// src/i18n/zh.json → nav 块内
"guestbook": "留言板"

第 4 步:登记并启用导航项

ts
12345
// 1) src/components/SiteHeader.astro → NAV_DEFS 注册表加一行(path → 词条 key + 图标)
'/guestbook': { key: 'nav.guestbook', icon: mdiNotebookOutline },   // ← 新增

// 2) src/site.config.ts → nav 数组加入该 path(位置即显示顺序;首页 '/' 固定第一、无需配置)
nav: ['/archive', '/categories', '/tags', '/series', '/friends', '/guestbook', '/about'],

导航高亮由 BaseLayout.syncNavState() 自动处理(依据 data-nav-path),无需额外代码。

6.2 加一个带分页的列表页

astro
123456789101112131415
---
// src/pages/notes/[page].astro
import type { GetStaticPathsOptions, Page } from 'astro'
import BaseLayout from '@/layouts/BaseLayout.astro'
import { defaultLang, localePath } from '@/i18n/ui'
import { getPublishedPosts, type Post } from '@/lib/posts'

export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
  const posts = await getPublishedPosts()
  return paginate(posts, { params: {}, pageSize: 10 })
}

const lang = defaultLang
const { page } = Astro.props as { page: Page<Post> }
---

分页基址由 Pagination 的 baseUrl prop 决定,pageUrl() 会生成 {base}/ 与 {base}/{n}/。

6.3 页面级 SEO 覆盖

在 BaseLayout 上传入:

astro
12345678910
<BaseLayout
  lang={lang}
  title="自定义标题"
  description="自定义描述"
  seoType="article"
  publishedTime={new Date().toISOString()}
  modifiedTime={new Date().toISOString()}
  seoImage="/custom-og.webp"
  showTocButton
>

7. 404 页面

src/pages/404.astro:

元素说明
大号 404text-[6rem] 主题色,移动端 4.5rem
「返回上一页」href="javascript:history.length > 1 ? history.back() : void 0"
「返回首页」localePath(lang, '/')
min-h-[60vh]垂直居中

静态托管需要在平台侧配置「未知路径回落到 404.html」——Cloudflare Pages / Vercel / Netlify 这类平台默认就有此行为,自托管时需要自己配置。

8. Swup 无刷新导航与页面脚本

8.1 生命周期钩子对照表

钩子触发时机项目中的用途
astro:before-swap新 DOM 即将替换旧 DOM移除容器外的 #toc-affix;重置导航高亮;卸载评论适配器(comments/client.ts)。搜索弹窗不在此关闭
astro:after-swapDOM 已替换重新应用主题(theme.ts);同步 TOC 按钮显隐(BaseLayout);关闭搜索弹窗(SearchModal)
astro:page-load页面完全就绪onPageLoad() 回调;同步导航状态
Swup visit:start开始导航syncNavState(目标路径) 提前更新高亮
Swup visit:end导航结束(未使用)

8.2 写页面脚本的正确方式

ts
1234567891011
import { onPageLoad } from '@/lib/pageLifecycle'

function initMyFeature() {
  const root = document.getElementById('my-root')
  if (!root) return
  if (root.dataset.bound) return       // ← 幂等保护,防止重复绑定
  root.dataset.bound = '1'
  // ...
}

onPageLoad(initMyFeature)

onPageLoad() 的实现(src/lib/pageLifecycle.ts):

ts
1234
export function onPageLoad(init: () => void) {
  document.addEventListener('astro:page-load', init)
  if (window.__pageLoadFired) init()      // ← 脚本在 page-load 之后才执行时补跑一次
}
要点说明
为什么要补跑Swup 换页时,从新页面加载的 <script> 可能在 astro:page-load 之后才执行
__pageLoadFired由 BaseLayout 的脚本设置(page-load 时 true,before-swap 时 false)
幂等onPageLoad 不保证只调用一次,回调内部必须自己做幂等保护(如 dataset.bound)

8.3 未使用 Swup 的地方

以下情况会跳过 Swup(ignore 规则命中)。注意「跳过 Swup」不等于「必须整页刷新」:

  • 含 # 的 URL:同页锚点(正文标题锚点、目录)由 PostPage 内容区拦截平滑滚动;跨页锚点由 BaseLayout 点击委托接管为 SPA 导航 + 加载后平滑滚动(见 8.4),都不会闪现
  • 静态资源扩展名(见 04-站点配置.md#3.3)
  • /pagefind/、/fonts/、/images/ 开头的路径

需要强制整页刷新时

给 <a> 加 data-no-swup 属性(Swup 的内置约定),或在 astro.config.mjs 的 ignore 回调里加规则。

8.4 跨页锚点链接的平滑跳转

跨文章/跨页面的锚点链接(如 markdown 相对链接 [标题](./05-内容与Markdown.md#9-文章页面的-toc目录),会被 rehypeRelativeLinks 转成 /posts/05-nei-rong-yu-markdown#9-文章页面的-toc目录):

  1. BaseLayout 的点击委托发现「同源 + 带 # + 目标路径 ≠ 当前路径」的内部链接 → preventDefault();
  2. 锚点 id 记到 window.__pendingScrollHash,swup.navigate() 用去掉 hash 的 URL 导航,换页自然落在页顶;
  3. astro:page-load 时 consumePendingScrollHash() 把 hash 写回地址栏(可分享/刷新),120ms 后按目录同款 80px 偏移(HEADING_SCROLL_OFFSET)平滑滚动到标题。

同时覆盖「直接打开深链接」(收藏/分享的带 # URL)场景:<head> 里一段内联脚本先记录锚点并去掉 URL 上的 hash,阻止浏览器原生闪现定位,随后走同样的平滑滚动。

搜索结果的章节级跳转复用同一机制:SearchModal 的 go() 在跨页时把锚点写入 __pendingScrollHash 再导航;同页命中则直接调用 window.__scrollToHeadingAnchor(BaseLayout 暴露的同款平滑滚动)原地滚动,不经换页。

要点说明
同页锚点不受影响目标路径 == 当前路径时点击委托直接返回,仍由 PostPage 内容区/目录的 smoothScrollToHeading() 处理
尊重用户操作meta/ctrl/shift/alt 修饰键、非左键、data-no-swup、外站链接一律放行(保持原生行为)
降级无 window.swup 时回退 location.assign(原 href);prefers-reduced-motion: reduce 时降级为瞬时定位
锚点容错与目录一致:先按原始 id、再按 decodeURIComponent(id) 查找
想改回原生闪现删掉 BaseLayout.astro 内联去 hash 脚本与底部脚本里的点击委托 + consumePendingScrollHash()