最近把博客从 v6(Astro 5)迁到了 v7(Astro 7)。v6 创建于一年前,其实还能接着跑下去,只是 Astro 5 的配置散落在不同文件里,文章目录和页面目录混在一起,部分组件为了兼容旧写法越长越奇怪。这次拿 v6 和 v7 做了完整对比,把改动记录一下。
这次对比出来的整体规模大概是:
287 files changed, 5440 insertions(+), 7260 deletions(-)
其中大部分是内容目录迁移:原来的 src/data/blog 被迁到 src/content/posts,文章本身没有改只是换了位置,没什么好说的,所以下面只讲影响博客行为的部分。
0x00 依赖和构建
v6 的 package.json 还是老样子,build 一条命令把 Astro 检查、构建和 Pagefind 全跑了:
{
"name": "blog",
"version": "5.0.0",
"scripts": {
"dev": "astro dev",
"cache:favicons": "tsx scripts/cache-favicons.ts",
"build": "astro check && astro build && pagefind --site dist && cp -r dist/pagefind public/"
},
"dependencies": {
"astro": "^5.18.2",
"astro-expressive-code": "^0.41.7"
}
}
v7 改成了这样:
{
"name": "blog",
"version": "7.0.0",
"scripts": {
"dev": "astro dev",
"build": "astro check && astro build",
"postbuild": "pagefind --site dist && cp -r dist/pagefind public/",
"preview": "astro preview"
},
"dependencies": {
"astro": "^7.0.3"
}
}
这么改是因为构建站点和生成搜索索引是两件事。之前绑在一起,排查 Astro 类型问题时还要跟着跑一遍 Pagefind。现在 build 只负责站点,postbuild 再处理索引。
另外,astro-expressive-code 这次删掉了,换回 Astro 自带的 Shiki。文件名、高亮行、词语高亮和 diff 标记用 transformer 实现,后面升级 Astro Paper 的版本时会省事一些。
0x01 Astro 配置
v6 的 astro.config.ts 比较直接,站点地址取 SITE.website,然后挂 sitemap 和 Expressive Code:
import sitemap from "@astrojs/sitemap";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig, envField } from "astro/config";
import remarkCollapse from "remark-collapse";
import remarkToc from "remark-toc";
import expressiveCode from "astro-expressive-code";
import { SITE } from "./src/config";
export default defineConfig({
site: SITE.website,
integrations: [
sitemap({
changefreq: "hourly",
priority: 0.8,
lastmod: new Date(),
}),
expressiveCode(),
],
markdown: {
remarkPlugins: [remarkToc, [remarkCollapse, { test: "本文导览" }]],
},
vite: {
plugins: [tailwindcss()],
optimizeDeps: {
exclude: ["@resvg/resvg-js"],
},
},
});
v7 里这块变长了,参考上游 Astro Paper v6,主要是在接 MDX、i18n、callout、Shiki transformer、字体和 SVG 优化:
export default defineConfig({
integrations: [
mdx(),
sitemap({
filter: page =>
config.features?.showArchives !== false || !page.endsWith("/archives/"),
}),
],
i18n: {
locales: ["en", "zh-CN"],
defaultLocale: "zh-CN",
routing: {
prefixDefaultLocale: false,
},
},
markdown: {
processor: unified({
remarkPlugins: [
remarkToc,
[remarkCollapse, { test: "Table of contents" }],
],
rehypePlugins: [rehypeCallouts],
}),
shikiConfig: {
themes: { light: "min-light", dark: "night-owl" },
defaultColor: false,
wrap: false,
transformers: [
transformerFileName({ style: "v2", hideDot: false }),
transformerNotationHighlight(),
transformerNotationWordHighlight(),
transformerNotationDiff({ matchAlgorithm: "v3" }),
],
},
},
});
这里主要说一下 i18n 和 Shiki。i18n 目前只把 UI 字符串拆了出来,文章内容还没有翻译成英文。Shiki 解决的是代码块展示问题,文件名、行高亮、diff 标记直接在代码块里写,不用额外的插件约定。
0x02 文章目录迁移到 Content Collections
v6 的内容集合叫 blog,文章路径是 src/data/blog。这套写法能用,但 Astro 现在推荐 Content Collections,目录名也不合适。
旧版是这样:
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
import { SITE } from "@/config";
export const BLOG_PATH = "src/data/blog";
const blog = defineCollection({
loader: glob({ pattern: "**/[^_]*.md", base: `./${BLOG_PATH}` }),
schema: ({ image }) =>
z.object({
author: z.string().default(SITE.author),
pubDatetime: z.date(),
modDatetime: z.date().optional().nullable(),
title: z.string(),
tags: z.array(z.string()).default(["others"]),
ogImage: image().or(z.string()).optional(),
description: z.string(),
canonicalURL: z.string().optional(),
hideEditPost: z.boolean().optional(),
timezone: z.string().optional(),
}),
});
export const collections = { blog };
v7 改成了 posts 和 pages 两个集合:
import { defineCollection } from "astro:content";
import { z } from "astro/zod";
import { glob } from "astro/loaders";
import config from "@/config";
export const BLOG_PATH = "src/content/posts";
const posts = defineCollection({
loader: glob({ pattern: "**/[^_]*.{md,mdx}", base: `./${BLOG_PATH}` }),
schema: ({ image }) =>
z.object({
author: z.string().default(config.site.author),
pubDatetime: z.date(),
modDatetime: z.date().optional().nullable(),
title: z.string(),
featured: z.boolean().optional(),
draft: z.boolean().optional(),
tags: z.array(z.string()).default(["others"]),
ogImage: image().or(z.string()).optional(),
description: z.string(),
canonicalURL: z.string().optional(),
hideEditPost: z.boolean().optional(),
timezone: z.string().optional(),
}),
});
const pages = defineCollection({
loader: glob({ pattern: "**/[^_]*.{md,mdx}", base: "./src/content/pages" }),
schema: z.object({
title: z.string(),
description: z.string().optional(),
ogImage: z.string().optional(),
canonicalURL: z.string().optional(),
}),
});
export const collections = { posts, pages };
新写法的好处是,v6 的 pages 不用单独写 Layouts 了,只需要新建处理器去处理这些 markdown 文本和对应的组件即可,可以很好的优化文件数量。
0x03 文章 URL
文章迁到 src/content/posts 后还有一个问题。我习惯用 linux、network、log 这种目录分类整理文章,但 URL 不想变成 /posts/linux/xxx/,所以我们约定生成路径时把下划线开头的目录过滤掉,URL 保持干净。
比如这篇文章在仓库里是 src/content/posts/_log/changelog-202608.md,所以前台 URL 不会带 _log,整理目录时不用担心把已有链接改坏。
0x04 配置
v6 的配置是一个 SITE 常量:
export const SITE = {
website: "https://blog.liuzhen932.top/",
author: "liuzhen932",
profile: "https://blog.liuzhen932.top/about/",
desc: "只要愿意去做,人无所不通。欢迎来到 liuzhen932 的小窝",
title: "liuzhen932 的小窝",
ogImage: "og.png",
lang: "zh-Hans-CN",
timezone: "Asia/Shanghai",
} as const;
这个东西一开始很方便,写久了就有点乱。站点信息、分页、功能开关全部塞在一个常量里,社交链接还在另一个常量文件。v7 改成了一个更像用户配置的入口:
export default defineAstroPaperConfig({
site: {
url: "https://blog.liuzhen932.top/",
title: "liuzhen932 的小窝",
description: "只要愿意去做,人无所不通。欢迎来到 liuzhen932 的小窝",
author: "liuzhen932",
profile: "https://blog.liuzhen932.top/about/",
ogImage: "og.png",
lang: "zh-CN",
timezone: "Asia/Shanghai",
dir: "ltr",
},
socials: [
{ name: "github", url: "https://github.com/liuzhen9320" },
],
});
然后 src/config.ts 只负责补默认值:
const config: ResolvedAstroPaperConfig = {
site: {
...userConfig.site,
ogImage: userConfig.site.ogImage ?? DEFAULT_OG_IMAGE,
lang: userConfig.site.lang ?? "en",
timezone: userConfig.site.timezone ?? "UTC",
dir: userConfig.site.dir ?? "ltr",
googleVerification:
userConfig.site.googleVerification || PUBLIC_GOOGLE_SITE_VERIFICATION,
},
posts: {
perPage: userConfig.posts?.perPage ?? 4,
perIndex: userConfig.posts?.perIndex ?? 4,
scheduledPostMargin:
userConfig.posts?.scheduledPostMargin ?? 15 * 60 * 1000,
},
features: {
lightAndDarkMode: userConfig.features?.lightAndDarkMode ?? true,
dynamicOgImage: userConfig.features?.dynamicOgImage ?? true,
showArchives: userConfig.features?.showArchives ?? true,
showBackButton: userConfig.features?.showBackButton ?? true,
editPost: userConfig.features?.editPost ?? { enabled: false },
search: userConfig.features?.search ?? "pagefind",
},
};
以后换域名、关搜索、改首页文章数量,只需要改这一个文件,不用再去组件和工具函数里找配置。
0x05 base path 和 locale
v7 加了 i18n 后,路径处理要分情况:/en/posts/xxx/ 和 /posts/xxx/ 都可能存在,部署到子路径时还要多一层 base。所以加了 withBase.ts:
const base = import.meta.env.BASE_URL.replace(/\/+$/, "");
const baseRoot = base === "" ? "/" : `${base}/`;
export function stripLocale(pathname: string, locale: string): string {
const prefix = `/${locale}`;
if (pathname === prefix) return "/";
if (pathname.startsWith(`${prefix}/`)) return pathname.slice(prefix.length);
return pathname;
}
export function stripBase(pathname: string): string {
if (base === "") {
return pathname;
}
if (pathname === base) {
return "/";
}
if (pathname.startsWith(baseRoot)) {
const stripped = pathname.slice(base.length);
return stripped === "" ? "/" : stripped;
}
return pathname;
}
export function getAssetPath(path: string): string {
const normalizedPath = path.replace(/^\/+/, "");
if (!normalizedPath) {
return base === "" ? "/" : base;
}
return baseRoot + normalizedPath;
}
静态站点部署在子路径时,容易出现的现象是首页能打开,但图标、RSS、sitemap 或导航状态不对。这部分逻辑现在集中处理了。
Header 里也会用这些函数判断当前路径:
const relativePath = stripBase(Astro.url.pathname);
const pathWithoutTrailingSlash =
relativePath.endsWith("/") && relativePath !== "/"
? relativePath.slice(0, -1)
: relativePath;
const currentPath = stripLocale(pathWithoutTrailingSlash, locale);
Header 用这些函数判断当前路径,导航高亮不会再受 base path 或语言前缀影响。
0x06 文章页拆分
v6 的文章详情页一个文件里塞了太多东西。v7 拆成了 PostLayout.astro 和若干 _components。
文章页的 meta 和结构化数据现在单独放在 PostLayout.astro:
---
import Layout from "./Layout.astro";
import config from "@/config";
const { site } = config;
const { title, description, ogImage, canonicalURL, pubDatetime, modDatetime } =
Astro.props;
const structuredData = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: title ?? site.title,
image: ogImage,
...(pubDatetime && { datePublished: pubDatetime.toISOString() }),
...(modDatetime && { dateModified: modDatetime.toISOString() }),
author: [
{
"@type": "Person",
name: site.author,
...(site.profile && { url: site.profile }),
},
],
};
---
<Layout {title} {description} {ogImage} {canonicalURL}>
<Fragment slot="head">
<meta property="og:type" content="article" />
<script
type="application/ld+json"
is:inline
set:html={JSON.stringify(structuredData)}
/>
</Fragment>
<slot />
</Layout>
基础 Layout 管站点级 meta,PostLayout 管文章级 meta,以后改 JSON-LD 不用在整个文章详情页里找。
0x07 代码块复制和图片灯箱
这次文章页实际能感受到的变化主要有两个:代码块复制和图片灯箱。
过去 v6 复制按钮是组件,但是我没有继续使用 code 插件,导致我需要在文章页脚本里给所有 pre 自动挂上去:
function attachCopyButtons() {
const copyButtonLabel = "Copy";
const codeBlocks = Array.from(document.querySelectorAll("pre"));
for (const codeBlock of codeBlocks) {
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
const computedStyle = getComputedStyle(codeBlock);
const hasFileNameOffset =
computedStyle.getPropertyValue("--file-name-offset").trim() !== "";
const topClass = hasFileNameOffset
? "top-(--file-name-offset)"
: "-top-3";
const copyButton = document.createElement("button");
copyButton.className = `copy-code absolute end-3 ${topClass} rounded bg-muted border border-muted px-2 py-1 text-xs leading-4 text-foreground font-medium`;
copyButton.innerHTML = copyButtonLabel;
codeBlock.setAttribute("tabindex", "0");
codeBlock.appendChild(copyButton);
codeBlock?.parentNode?.insertBefore(wrapper, codeBlock);
wrapper.appendChild(codeBlock);
copyButton.addEventListener("click", async () => {
await copyCode(codeBlock, copyButton);
});
}
}
这里处理了 --file-name-offset。Shiki 的文件名会占一块位置,复制按钮如果还按普通代码块定位,会跟文件名挤在一起。
图片灯箱就更长一点。核心思路是只处理正文里的普通图片,链接包住的图片不动:
function initLightbox() {
const article = document.getElementById("article");
if (!article) return;
requestAnimationFrame(() => {
const images = Array.from(article.querySelectorAll("img"));
for (const image of images) {
if (image.closest("a")) continue;
image.setAttribute("role", "button");
image.setAttribute("tabindex", "0");
image.setAttribute("aria-haspopup", "dialog");
image.setAttribute(
"aria-label",
image.alt ? `Zoom image: ${image.alt}` : "Zoom image"
);
}
});
function open(src, alt, trigger) {
if (overlay) return;
lastFocused = trigger ?? document.activeElement;
overlay = document.createElement("div");
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.className =
"fixed inset-0 z-50 flex cursor-zoom-out items-center justify-center bg-black/70 backdrop-blur-sm opacity-0 transition-opacity duration-200 motion-reduce:transition-none";
}
}
实际文件里还有双指缩放、拖动、Esc 关闭、焦点返回等处理,这里就不全贴了。起因是文章里有些截图字很小,手机上看不清,点开比放大页面方便。
0x08 主题系统
v6 的主题变量在 global.css 里,能用,但和其他全局样式混在一起。v7 拆出了 theme.css
颜色、字体 token、明暗主题都在一个文件里,改颜色不用去 global.css 里翻,也不会改到其他样式。
字体也顺手接到了 Astro 的 fonts 配置里:
fonts: [
{
name: "JetBrains Mono",
cssVariable: "--font-mono",
provider: fontProviders.fontsource(),
fallbacks: ["LXGW WenKai"],
weights: [300, 400, 500, 600, 700],
styles: ["normal", "italic"],
formats: ["woff2"],
},
{
name: "LXGW WenKai",
cssVariable: "--font-lxgw",
provider: fontProviders.fontsource(),
fallbacks: ["Noto Serif SC"],
weights: [300, 500, 700],
styles: ["normal"],
subsets: ["latin"],
formats: ["woff2", "ttf"],
},
]
这个傻逼字体折磨了我整整六个小时,你妈的 LXGW Wenkai 默认在 Fontsource 上没有分包,导致每个用户都要下载一个 16 MB 的 OpenType 字体文件。
为了解决首屏问题,我使用优先 fallback 到 Noto Serif SC 字体的逻辑(后者只需要数个 ~20KB 的小 woff2),待 LXGW Wenkai 加载完成后一次性应用,确保最少的 CLS。
0x09 UI 文案
这次 UI 层已经先拆出来了 i18n,比如中文文案现在在 src/i18n/lang/zh-CN.ts:
export default {
nav: {
home: "主页",
posts: "文章",
tags: "标签",
about: "关于",
archives: "归档",
search: "搜索",
},
post: {
publishedAt: "发布于",
updatedAt: "更新于",
sharePostIntro: "分享这篇文章:",
sharePostOn: "在 {{platform}} 上分享这篇文章",
sharePostViaEmail: "通过电子邮件分享这篇文章",
tagLabel: "标签",
backToTop: "回到顶部",
goBack: "返回",
previousPost: "上一篇",
nextPost: "下一篇",
},
home: {
socialLinks: "社交链接",
featured: "精选文章",
recentPosts: "近期水文",
allPosts: "所有文章",
},
} satisfies UIStrings;
先把 UI 文案拆出来,是为了以后加英文界面时,不用去 Header、Footer、Pagination、Search 里找硬编码的中文。文章内容不会考虑翻译。
0x10 更新 Artalk
我在 cnb/pwsh/Artalk 提交了更多实用的变更,并应用到了本站点,现在留言默认需要登录(也可以选择匿名评论,过渡期结束会删掉)。
0x0a 这次删掉了什么
v7 不是只加东西,也删了不少:
- 旧的
.vscode模板文件 - 模板 README 和一些无关静态页
- 旧的
PostDetails.astro - 一堆只包了一层壳的页面 layout
- 旧的 OG 模板和相关工具函数
保持仓库清爽才是第一位.
0x0b 最后
这次升级没有太多新功能,主要是整理。旧版能跑,但很多东西是一路补丁叠出来的,这次把内容集合、配置、路径处理、文章页交互、主题变量重新梳理了一遍。
对读者来说,最明显的变化是文章页:图片可以点开,有更养眼的字体,导航和主题切换也正常了。
以上就是这次升级的主要内容,如果你对本文有任何疑问,欢迎在评论区留言交流。