跳至内容
liuzhen932 的小窝
返回

博客 v7 升级小记:从 Astro 5 迁到 Astro 7

最近把博客从 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 改成了 postspages 两个集合:

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 后还有一个问题。我习惯用 linuxnetworklog 这种目录分类整理文章,但 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 不是只加东西,也删了不少:

保持仓库清爽才是第一位.

0x0b 最后

这次升级没有太多新功能,主要是整理。旧版能跑,但很多东西是一路补丁叠出来的,这次把内容集合、配置、路径处理、文章页交互、主题变量重新梳理了一遍。

对读者来说,最明显的变化是文章页:图片可以点开,有更养眼的字体,导航和主题切换也正常了。

以上就是这次升级的主要内容,如果你对本文有任何疑问,欢迎在评论区留言交流。


分享这篇文章:

下一篇
在浏览器里运行 Alpine Linux

人机验证:请刷新页面以加载评论区