1. 路由总表
所有页面均为构建期静态生成(Astro 默认 output: 'static')。src/pages/ 下共 16 个文件,对应下列路由:
| URL | 源文件 | 渲染组件 | title | 备注 |
|---|---|---|---|---|
/ | src/pages/index.astro | HomePage | 无(用 title - subtitle) | 第 1 页 |
/{n}/(n≥2) | src/pages/[page].astro | HomePage | pagination.pageTitle | 与首页共用组件,filter(page > 1) 排除第 1 页 |
/posts/{slug}/ | src/pages/posts/[slug].astro | PostPage | post.data.title | seoType="article",showTocButton |
/archive/ | src/pages/archive.astro | ArchivePage | archive.title | 全部文章(置顶区 + 年份分组) |
/categories/ | src/pages/categories/index.astro | TaxonomyPage(kind="categories") | categories.title | 搜索 + 排序 |
/categories/{分类名}/ | src/pages/categories/[name].astro | ArchivePage | pageTitle.category | 复用归档布局 |
/tags/ | src/pages/tags/index.astro | TaxonomyPage(kind="tags") | tags.title | 标签云(与分类页共用组件) |
/tags/{标签名}/ | src/pages/tags/[name].astro | ArchivePage | pageTitle.tag | 复用归档布局 |
/series/ | src/pages/series/index.astro | SeriesIndexPage | series.title | 系列索引(系列名 + 篇数) |
/series/{系列名}/ | src/pages/series/[name].astro | SeriesDetailPage | pageTitle.series | 系列总览,按 series_order 升序 |
/friends/ | src/pages/friends.astro | FriendsPage | nav.friends | 底部带 Comments |
/about/ | src/pages/about.astro | AboutPage | pageTitle.about | 内容硬编码 |
/404.html | src/pages/404.astro | 内联 | 404 | 静态托管兜底页 |
/rss.xml | src/pages/rss.xml.ts | API Route | — | @astrojs/rss |
/robots.txt | src/pages/robots.txt.ts | API Route | — | 指向 sitemap-index.xml |
/sitemap-index.xml、/sitemap-0.xml | @astrojs/sitemap 集成 | — | — | 自动生成 |
/content-images/{hash}.{ext} | src/pages/content-images/[file].ts | API Route(prerender = true) | — | 文章图片 |
/pagefind/* | pagefind CLI(postbuild.js) | — | — | 搜索索引 |
三种动态路由的区别
文件 动态参数来源 生成方式 [page].astroAstro 的 paginate()辅助函数GetStaticPathsOptions.paginateposts/[slug].astro、categories/[name].astro、tags/[name].astro、series/[name].astro文章 / 系列集合 手写 getStaticPaths()content-images/[file].ts文件系统遍历 手写 getStaticPaths()(fs.readdir递归)
2. 统一页面骨架
每个路由文件都是同一个模式(这是二次开发的入口约定):
---
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
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
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
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
// 分类
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.description | props |
<link rel="canonical"> | new URL(Astro.url.pathname, Astro.site).href | 自动 |
<link rel="icon" type="image/webp"> | 硬编码 /logo.webp | BaseLayout |
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 | 固定 summary | BaseLayout |
twitter:title / twitter:description / twitter:image | 同 OG | props |
JSON-LD BlogPosting | 仅 seoType === 'article' && title 时输出 | 见下 |
4.2 OG locale 映射
const ogLocale = { zh: 'zh_CN', ja: 'ja_JP', en: 'en_US' }[lang]
加语言时需同步此表。
4.3 JSON-LD 结构
{
"@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 / site | siteConfig 对应值 |
customData | <language>{defaultLang}</language> |
| 条目 | 每个已发布文章一条 |
link | /posts/{slug}/(相对,由 @astrojs/rss 拼绝对) |
categories | [category, ...tags] |
content | renderMarkdownHtml(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
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 = defaultLang | index.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 | 用途 |
|---|---|---|
nav | home archive categories tags friends about | 导航与页面标题 |
header | menu switchToLight switchToDark | 头部 |
search | title placeholder hint empty failed open moreHits | 搜索弹窗(moreHits 含 {n} 占位,为「本文还有 N 处命中」) |
post | uncategorized words minutes visitorsUnit updated prev next noMore toc tableOfContents collapseToc backHome copy copied footnotes | 文章页 |
post.copyright | author(含 {year})licenseLabel licenseText(含 {link} 占位)licenseName mayTitle mayText mustTitle mustText originalLink disclaimer | 版权块 |
list | empty pinned readMore postsUnit | 列表 |
archive | title subtitle | 归档 |
categories | title subtitle searchPlaceholder empty total(含 {n}) | 分类页 |
tags | title subtitle searchPlaceholder empty total(含 {n}) | 标签页 |
friends | subtitle emptyTitle emptySub(含 {config} 占位,用 set:html 注入 <code>) | 友链 |
profile | wordsUnit hoursUnit articlesUnit wordsLabel hoursLabel articlesLabel pvUnit pvLabel uvUnit uvLabel | 首页名片 |
pagination | prev next pageTitle(含 {n}) | 分页 |
pageTitle | category(含 {name})tag(含 {name})about | 页面标题 |
footer | rss sitemap running(含 {days}) | 页脚 |
notFound | title message back home | 404 |
misc | sortByName sortByCount backToTop clear | 通用(clear 为分类/标签页搜索框清空按钮的 aria-label / title) |
player | title 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。
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) |
langs | Lang[] | ['zh', 'ja', 'en'] |
defaultLang | Lang | 来自 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) => boolean | Swup 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() 的行为:
localePath('zh', '/about') // → '/about/' (默认语言不加前缀,补尾斜杠)
localePath('en', '/about') // → '/en/about/'
localePath('zh', '/rss.xml') // → '/rss.xml' (含 . 的最后一段视为文件,不补斜杠)
localePath('zh', '/') // → '/'
localePath('en', '/') // → '/en'
5.4 加一门语言(以韩语 ko 为例)
完整的多语言路由需要自行实现。
下面只完成「词条与类型」部分。要真正产出
/ko/…页面,需要为每个路由写语言维度:ts12345678export 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]/(默认语言可用[...]可选参数或单独保留一份)。这是一次结构性改动,建议先确认是否真的需要。
仅换界面语言(最小成本):
-
src/i18n/ko.json—— 复制zh.json并翻译(key 结构必须完全一致); -
src/i18n/ui.ts—— 三处改动:ts1234import 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 } -
src/site.config.ts——defaultLang: 'ko' as 'zh' | 'ja' | 'en' | 'ko'(注意Lang联合类型也要同步); -
astro.config.mjs—— 脚注标题映射:js1const footnoteLabel = { zh: zhJson, ja: jaJson, en: enJson, ko: koJson }[siteConfig.defaultLang].post.footnotes -
BaseLayout.astro—— OG locale 映射:{ zh: 'zh_CN', ja: 'ja_JP', en: 'en_US', ko: 'ko_KR' }; -
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
---
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
---
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 都要加)
// src/i18n/zh.json → nav 块内
"guestbook": "留言板"
第 4 步:登记并启用导航项
// 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 加一个带分页的列表页
---
// 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的baseUrlprop 决定,pageUrl()会生成{base}/与{base}/{n}/。
6.3 页面级 SEO 覆盖
在 BaseLayout 上传入:
<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:
| 元素 | 说明 |
|---|---|
大号 404 | text-[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-swap | DOM 已替换 | 重新应用主题(theme.ts);同步 TOC 按钮显隐(BaseLayout);关闭搜索弹窗(SearchModal) |
astro:page-load | 页面完全就绪 | onPageLoad() 回调;同步导航状态 |
Swup visit:start | 开始导航 | syncNavState(目标路径) 提前更新高亮 |
Swup visit:end | 导航结束 | (未使用) |
8.2 写页面脚本的正确方式
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):
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目录):
BaseLayout的点击委托发现「同源 + 带#+ 目标路径 ≠ 当前路径」的内部链接 →preventDefault();- 锚点 id 记到
window.__pendingScrollHash,swup.navigate()用去掉 hash 的 URL 导航,换页自然落在页顶; 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() |