5907 字
30 分钟
Fuwari 博客 2026 大升级:维护、Content Layer 迁移与全站功能改造

前言#

这个博客自 2025-07-18 之后就没怎么动过了。时间一长,几个问题会一起冒出来:Dependabot 攒了 5 个依赖 PR 没人合、质量门禁从 2025-12 开始连续 10 次失败、内容集合还停留在旧式 API、以及很多别的 Fuwari 站点早就有的阅读与交互功能本站一直缺着。

所以这次不是「加个小功能」,而是一次从 CI 到内容层再到前端交互的整包升级。全程以 Node 24 本地开发、保持 engines.node >= 18 与 CI 的 [22, 23] 矩阵不变,最后同步到 GitHub 主仓库并由 Vercel 自动重部署。

NOTE

本文属于「博客改造计划」系列,写法沿用之前的教程体:先讲清问题,再给出改动前后的代码差异,最后附上验证结果。所有改动都已上线,可以直接打开本站对应页面对照。

TIP

本文开启了 comments: true,底部的 Waline 评论区正好可以用来验证第五部分修好的暗黑模式可读性——切到夜间模式看看「评论」计数和排序标题是不是变成浅色了。

本次改动总览#

四个提交,按依赖顺序落地:

提交日期内容规模
34bf1482026-11-06修复 CI 门禁、整合依赖、迁移 Content Layer、关闭 5 个 PR15 files, +1834 / -1276
b536f6f2026-11-06参考 Besty0728/fuwari 的全站功能改造20 files, +1096 / -28
00bebbb2026-11-06悬浮面板与 /hot/ 排序标签的暗黑可读性修复1 file, +16 / -2
77878ce2026-11-09Waline 评论组件的暗黑可读性修复1 file, +10

一、修复 CI 质量门禁(commit 34bf148)#

问题:Biome 版本漂移#

先说最容易被忽略、但其实最该先修的一项。原来的质量门禁是这样写的:

.github/workflows/biome.yml(修改前)
name: Code quality
on:
push:
branches: [ main ] # Adjust branches as needed
pull_request:
branches: [ main ] # Adjust branches as needed
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Biome
uses: biomejs/setup-biome@f382a98e582959e6aaac8e5f8b17b31749018780 # v2.5.0
with:
version: latest
- name: Run Biome
run: biome ci ./src --reporter=github

关键在 version: latest。仓库里锁定的却是 @biomejs/biome: 2.1.1,而 CI 里那个 latest 实际跑到了 Biome 2.6.5。两个版本的规则不一致,于是每次 biome ci 都会吐出约 10 条 annotation,main 分支上这个检查自 2025-12 起连续 10 次 failure。

CAUTION

这是个「既有问题」,不是那 5 个 dependabot PR 引入的。也就是说,光把 PR 合掉并不会让 CI 变绿——必须先把门禁修对,否则后面每次提交都还是红的。

修法:让 CI 用与仓库完全相同的版本#

思路很简单:不要在 CI 里单独装一个 Biome,直接用 pnpm install --frozen-lockfile 装出来的那个,再用 pnpm exec 调用它。

.github/workflows/biome.yml
name: Code quality
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Biome
uses: biomejs/setup-biome@f382a98e582959e6aaac8e5f8b17b31749018780 # v2.5.0
with:
version: latest
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: 24
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run Biome
run: biome ci ./src --reporter=github
run: pnpm exec biome ci ./src --reporter=github

顺手补了两点:

  • concurrency:同一分支上重复推送时取消旧的在跑任务,避免排队浪费;
  • permissions: contents: read:把 token 权限收到最小。

修完之后本地 pnpm exec biome ci ./src 与 CI 跑的是同一个 2.1.1,结果一致、可复现。

NOTE

这里还有一个很容易踩的坑:仓库的 blob 本身是纯 LF,git config core.autocrlf true 只是把 checkout 出来的工作区转成 CRLF,Biome 2.1.1 对行尾并不敏感,但如果你为了「统一行尾」去做整树字节级重写,会损坏 PNG、并让 git status 凭空冒出上百个 M。正确做法是 git config core.autocrlf false 后 git reset --hard HEAD,工作区就回到纯 LF 了。别用 git show | Out-File 去测 blob 行尾,PowerShell 管线会自己转成 CRLF,测出来的是假的。

二、一次性整合依赖,处理 5 个 dependabot PR#

5 个 open PR 全是 dependabot,mergeable_state 都是 unstable,而且它们都从同一个基线 097787d 切出来。

PR内容head sha
#73patch 组 22 项522e8167
#72overlayscrollbars 2.11.4 → 2.14.0c84c5812
#71svelte 5.36.1 → 5.53.7f5bff87b
#70unist-util-visit 5.0.0 → 5.1.0442237b7
#62pagefind 1.3.0 → 1.4.04f050e9e

实测顺序合并:#73、#72 能过,但 #71、#70、#62 都会在 package.json 和 pnpm-lock.yaml 上冲突——因为它们各自独立地改了同一份依赖清单。与其一个个 rebase,不如把这 5 组升级一次性整合进一次 pnpm install,让 pnpm 自己重算 lockfile,再把 5 个 PR 统一留言 Superseded by the maintenance commit 34bf148 后关闭。

主要版本变化:

依赖旧新
astro5.11.15.18.2
@astrojs/svelte7.1.07.2.5
@astrojs/check^0.9.4^0.9.12
@astrojs/rss^4.0.12^4.0.19
@astrojs/sitemap^3.4.1^3.7.4
astro-expressive-code 四件套^0.41.3^0.44.3
pagefind^1.3.0^1.4.0
svelte^5.36.1^5.53.7
tailwindcss^3.4.17^3.4.19
overlayscrollbars / katex / markdown-it / sharp / unist-util-visit 等—同步升到 patch 组目标版本

同时在 package.json 里补上:

package.json
{
"engines": {
"node": ">=18"
},
"packageManager": "pnpm@9.14.4"
}
NOTE

build.yml 的 matrix.node: [22, 23] 这次没有动。质量门禁用 Node 24 是因为它只跑 Biome;构建/检查矩阵仍保留 22、23 两个版本做兼容性验证,而 engines.node >= 18 只是给使用者一个下限声明。三者互不冲突。

升级后顺手把 Biome 2.1.1 报出来的 lint 问题清干净:src/utils/content-utils.ts 三处 map → forEach、archive/tag/[tag].astro:24 的 forEach 表达式体改块体、setting-utils.ts / ArchivePanel.astro 四处 Number.parseInt 补 radix 10、plugins/expressive-code/language-badge.ts:9 直接删掉 // @ts-ignore(那里 baseStyles 本来就合法,换成 @ts-expect-error 反而会报 ts(2578): Unused '@ts-expect-error' directive)。

CAUTION

改完依赖一定要重新 pnpm install,否则 pnpm-lock.yaml 与 package.json 不一致,CI 上的 --frozen-lockfile 会直接失败。另外调整 .astro 缩进别手动敲 tab,用 pnpm exec biome format --write <单文件> 让格式化器来改最省事。

三、迁移到 Astro Content Layer API(本次最核心的代码改动)#

旧版 Fuwari 用的是 src/content/config.ts + defineCollection({ schema }) 的旧式集合,Astro 5 已经推荐改用 Content Layer。这一步改动最大,也最需要小心 URL 兼容性。

3.1 集合定义:从 schema 到 glob loader#

文件从 src/content/config.ts 移到根目录的 src/content.config.ts:

src/content.config.ts(原 src/content/config.ts)
import { defineCollection, z } from "astro:content";
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";
const postsCollection = defineCollection({
schema: z.object({
const posts = defineCollection({
loader: glob({ base: "./src/content/posts", pattern: "**/*.{md,mdx}" }),
schema: z.object({
title: z.string(),
published: z.date(),
updated: z.date().optional(),
draft: z.boolean().optional().default(false),
description: z.string().optional().default(""),
image: z.string().optional().default(""),
tags: z.array(z.string()).optional().default([]),
category: z.string().optional().default(""),
lang: z.string().optional().default(""),
comments: z.boolean().optional().default(false),
pinned: z.boolean().optional().default(false),
series: z.string().optional(),
/* For internal use */
prevTitle: z.string().default(""),
prevSlug: z.string().default(""),
nextTitle: z.string().default(""),
nextSlug: z.string().default(""),
}),
});
export const collections = {
posts: postsCollection,
};
const spec = defineCollection({
loader: glob({ base: "./src/content/spec", pattern: "**/*.{md,mdx}" }),
schema: z.object({}),
});
export const collections = { posts, spec };

要点:

  • z 从 astro/zod 引入,不再从 astro:content 引入;
  • 每个集合都要声明 loader,用 glob({ base, pattern }) 指向实际文件目录;
  • spec 集合(about / friends 等独立页面内容)也一起迁过来,之前 getEntry("spec", "about") 这类调用不用改;
  • 旧的 src/content/config.ts 删除,避免两套配置并存。

3.2 调用点改动#

全站所有读取文章的地方同步改三处:

src/pages/posts/[...slug].astro 等
import { getCollection, type CollectionEntry } from "astro:content";
import { getCollection, render, type CollectionEntry } from "astro:content";
const { Content } = await entry.render();
const { Content } = await render(entry);
const url = getPostUrlBySlug(entry.slug);
const url = getPostUrlBySlug(entry.id);
const posts: CollectionEntry<"posts">[] = ...
const posts: CollectionEntry<"posts">[] = ... // 类型名不变,数据源换成 loader

受影响的还有 PostCard.astro、PostPage.astro、widget/Series.astro、archive/tag/[tag].astro、rss.xml.ts、utils/content-utils.ts 等一共十来个文件。

3.3 一个容易踩的坑:子目录封面图#

IMPORTANT

glob loader 默认生成的 entry.id 恰好等于旧式的 entry.slug(同样做小写化、去掉 /index),所以 51 条既有文章 URL 完全不变——这是本次迁移能安全落地的前提,我用构建后的 URL 清单做了 51/51 逐条 diff,LOST_COUNT=0。

但 id 里不含目录。像 src/content/posts/docker/index.md 这种文章,旧式 slug 是 docker,如果封面图用相对路径写在 frontmatter 里,按 id 去拼目录就会 404。为此新增一个 getPostDir(entry),用 Content Layer 保留的 entry.filePath 还原真实目录:

src/utils/url-utils.ts
/**
* Resolve the directory that contains a post's source file, relative to `src/`.
* Content Layer entries keep their real path in `filePath` (e.g.
* `src/content/posts/docker/index.md`), which is needed to locate cover images
* referenced with relative paths. The `id` is only a slug and has no directory.
*/
export function getPostDir(entry: { id: string; filePath?: string }): string {
const filePath = entry.filePath ?? `src/content/posts/${entry.id}`;
const dirname = filePath.includes("/")
? filePath.slice(0, filePath.lastIndexOf("/"))
: "";
return `${dirname.replace(/^src\//, "")}/`;
}
TIP

迁移是否成功,不要只看 astro check 过没过。最可靠的验收是:干净克隆 → pnpm install → pnpm build → 把产出的路由清单和迁移前对比。本次还额外确认了草稿(如 case/http1.1.md 的草稿态)没有生成页面、子目录封面图正常加载、spec 的弃用警告消失。

这次维护还用 OpenSpec 留了完整档案:change revive-fuwari-maintenance 归档为 2026-11-06-revive-fuwari-maintenance,包含 ci-quality-gate、dependency-maintenance、content-collection-model 三项能力与 36 个任务,openspec validate --all 3 passed。

四、参考 Besty0728/fuwari 的全站功能改造(commit b536f6f)#

维护做完,接下来是「把博客变好用」。参考对象是另一个重度改造过的 Fuwari 站点:

Besty0728
/
fuwari
Waiting for api.github.com...
00K
0K
0K
Waiting...
NOTE

只借鉴功能面,不照搬代码。 参考仓库仍用旧式 src/content/config.ts,依赖版本比本站更旧(astro 5.7.9 / Biome 1.9.4),还塞了大量个人硬编码:Umami / GA 统计、AdSense、防盗链 Base64、收款码、经纬度、ICP 备案号、双 CDN……这些一律不引入。所有功能按本站的 Content Layer API 与现有组件重写。

4.1 配置中心:featureConfig#

新增的开关与文案全部集中在 src/config.ts 的 featureConfig,类型定义在 src/types/config.ts:

src/config.ts
/**
* 站点附加功能(参考 Besty0728/fuwari 的「整个博客改造」)。
* 这里只放可安全公开的配置;第三方统计 / 广告 / 防盗链 / 收款码等个人化硬编码一律不引入。
*/
export const featureConfig: FeatureConfig = {
readingProgress: { enable: true },
mermaid: { enable: true },
recentPosts: { enable: true, count: 5 },
forceDark: { enable: true, defaultOn: false },
randomBackground: { enable: true, defaultOn: false, opacity: 0.25 },
musicPlayer: {
enable: true,
defaultOn: false,
// 默认使用一段免版权的公共音频;如要换成自己的,只需改这个 URL。
title: "Ambient",
src: "https://cdn.pixabay.com/download/audio/2022/03/15/audio_c8c8a73467.mp3?filename=ambient-piano-logo-165357.mp3",
},
hot: { enable: true },
works: { enable: true },
apps: { enable: true, items: [ /* Fuwari / Astro 文档 / p1ume 源码 */ ] },
labels: { /* 23 条中文文案,单一配置源 */ },
};
为什么文案放在 labels 里

模板的 Translation = { [K in I18nKey]: string } 要求每个新词都同步进 7 个语言文件,成本很高且容易漏。所以把「最近更新」「作品」「应用」「热门文章」「强制暗黑」「随机背景」「背景音乐」「发布时间 / 更新时间 / 字数」,以及导航入口名、空状态与「功能已关闭」提示这类面向访客的新文案统一收进 featureConfig.labels,页面直接读,不去动 src/i18n/languages/*。原有 i18n 机制完全保留。

这样全站中文文案只有一个来源:导航、/works/、/apps/、/hot/ 的空态与禁用态,改一处 labels 就全部生效,不会出现「英文导航配中文页面」的割裂感。

4.2 客户端配置桥与 site-features.js#

服务端配置怎么传给浏览器?加一个 src/components/ConfigCarrier.astro,把所有开关写成 data-*:

src/components/ConfigCarrier.astro
---
import { featureConfig, siteConfig } from "../config";
---
<div
id="config-carrier"
data-hue={siteConfig.themeColor.hue}
data-feature-force-dark={String(featureConfig.forceDark.enable)}
data-feature-force-dark-default={String(featureConfig.forceDark.defaultOn)}
data-feature-random-bg={String(featureConfig.randomBackground.enable)}
data-feature-random-bg-opacity={String(featureConfig.randomBackground.opacity)}
data-feature-music={String(featureConfig.musicPlayer.enable)}
data-feature-music-title={featureConfig.musicPlayer.title}
data-feature-music-src={featureConfig.musicPlayer.src}
data-feature-reading-progress={String(featureConfig.readingProgress.enable)}
data-feature-mermaid={String(featureConfig.mermaid.enable)}
data-label-random-bg={featureConfig.labels.randomBg}
data-label-force-dark={featureConfig.labels.forceDark}
data-label-music={featureConfig.labels.music}
data-label-controls={featureConfig.labels.controls}
></div>

功能逻辑放在 public/scripts/site-features.js(338 行,纯原生 JS、零 npm 依赖),以 is:inline 引入、不参与打包。它开头就读这份 carrier:

public/scripts/site-features.js
/*
* 站点附加功能(参考 Besty0728/fuwari 的「整个博客改造」,按本仓库重写)。
* 纯原生 JS,无 npm 依赖;通过 #config-carrier 的 data-* 读取功能开关。
* 该脚本位于 public/ 下、以 is:inline 方式引入,不参与打包,在 Swup 页面切换时只需
* 监听 page:view 重新处理新内容即可。
*/
(() => {
"use strict";
function cfg() {
const c = document.getElementById("config-carrier");
if (!c) return {};
return {
forceDark: c.dataset.featureForceDark === "true",
randomBg: c.dataset.featureRandomBg === "true",
randomBgOpacity: Number.parseFloat(c.dataset.featureRandomBgOpacity || "0.25"),
mermaid: c.dataset.featureMermaid === "true",
/* ... */
};
}
/* ... */
if (window.swup?.hooks) {
window.swup.hooks.on("page:view", onPageView);
} else {
document.addEventListener("swup:enable", () => {
window.swup?.hooks?.on("page:view", onPageView);
});
}
})();

因为本站用 Swup 做无刷新跳转,脚本必须监听 page:view 重新绑定事件、重绘图表、重算进度条,否则换页后功能就失效了。开关状态用 localStorage 记忆,主题切换用 MutationObserver 观察 html.class 来触发 Mermaid 重绘。

功能清单:

功能实现要点
阅读进度条scrollTop / (scrollHeight - clientHeight),滚动时更新宽度
Mermaid 图表从 CDN 动态 import() mermaid@11,theme: dark ? "dark" : "default";因 Expressive Code 会把代码拆成 .ec-line,需逐行拼回源码再渲染
最近更新widget/RecentPosts.astro,按 updated ?? published 排序取前 5 条
强制暗黑优先级高于 theme=auto/light/dark,可单独覆盖
随机背景用 --random-bg-image 生成 linear-gradient(135deg, hsla(...), ...),透明度可配
背景音乐<audio> + play().catch(),默认关闭,URL 可换
热门排序按 data-words / data-published / data-updated 重排 #hot-list,并 ?sort= 同步到地址栏
显示控制面板齿轮按钮 + 三个开关 + 三个排序按钮,事件委托,不侵入现有组件

4.3 新页面与内容类型#

本次一共新增 5 条路由(加上原 51 页共 56 页):

路由说明
/works/作品集列表,getCollection("works") 按 published 倒序
/works/fuwari-blog/、/works/music-panel/作品详情,getStaticPaths + render(entry)
/apps/应用/常用链接,读 featureConfig.apps.items,外链自动 target="_blank"
/hot/热门文章,按发布时间 / 更新时间 / 字数排序

为此在 src/content.config.ts 里加了第三个集合 works:

src/content.config.ts
/** 作品集:参照参考仓库的 works 内容类型,按 Content Layer glob loader 定义。 */
const works = defineCollection({
loader: glob({ base: "./src/content/works", pattern: "**/*.{md,mdx}" }),
schema: z.object({
title: z.string(),
description: z.string().optional().default(""),
published: z.date(),
updated: z.date().optional(),
tags: z.array(z.string()).optional().default([]),
link: z.string().optional().default(""),
image: z.string().optional().default(""),
draft: z.boolean().optional().default(false),
status: z.string().optional().default(""),
}),
});
export const collections = { posts, spec, works };

友链页面也顺手改成数据驱动——以前是 friends.astro 里一大段硬编码数组,现在在 src/content/friends/ 下放 JSON 即可:

src/pages/friends.astro
// 友链改为数据驱动:新增友链只需在 src/content/friends/ 下放一个 JSON 文件。
const friendModules = import.meta.glob("../content/friends/*.json", {
eager: true,
import: "default",
}) as unknown as Record<string, Friend>;
const items: Friend[] = Object.values(friendModules);

Friend 接口还是 title / imgurl / desc / siteurl / tags,卡片样式和原来的 <Waline /> 都没动。

4.4 导航栏#

新页面要能从导航进。把 navBarConfig 挪到 featureConfig 之后定义,入口名统一读 labels、并按各自的 enable 开关条件展开:

src/config.ts
export const navBarConfig: NavBarConfig = {
links: [
LinkPreset.Home,
LinkPreset.Archive,
LinkPreset.About,
LinkPreset.Friends,
{
name: featureConfig.labels.navMusic, // "音乐"
url: "http://music.alger.fun/#/",
external: true,
},
...(featureConfig.works.enable
? [{ name: featureConfig.labels.works, url: "/works/" }] // "作品"
: []),
...(featureConfig.apps.enable
? [{ name: featureConfig.labels.apps, url: "/apps/" }] // "应用"
: []),
...(featureConfig.hot.enable
? [{ name: featureConfig.labels.hot, url: "/hot/" }] // "热门文章"
: []),
],
};
NOTE

三个入口刻意用普通 NavBarLink 对象,不去扩 LinkPreset 枚举。因为 Navbar.astro 对 number 类型的项会走 LinkPresets[item] 映射;加枚举还得同步改 i18nKey.ts、7 个语言文件和 constants/link-presets.ts,用普通对象更直接。

名字统一取自 featureConfig.labels,与四个中文 preset(主页 / 归档 / 关于 / 友链)和各自落地页标题保持一致,站点 lang: zh_CN 下不再出现「英文导航配中文页面」的割裂。用 ...(enable ? [...] : []) 条件展开,是为了让「配置里关掉的功能」连导航入口一起消失;对应地,/works/、/apps/、/hot/ 三个页面也会显示「该功能已关闭。」而不是一个空列表。navBarConfig 放在 featureConfig 之后定义,是为了避开 const 的暂时性死区(TDZ)——它引用了后者的 labels 与 enable。

上面这段 navBarConfig 是整包升级当时的版本,里面还保留着当时的「音乐」外链;该外链已在后续维护中移除,文中其余提到 /works/music-panel/ 的地方也同属当时状态,详见文末「本文的范围」。

4.5 组件插在哪里#

  • ConfigCarrier + GlobalFeatures 放在 layouts/Layout.astro 的 <slot /> 之后——也就是 Swup 容器外面,这样换页时它们不会被替换掉;
src/layouts/Layout.astro
<ConfigCarrier />
<slot />
<GlobalFeatures />
  • RecentPosts 插进 widget/SideBar.astro 的 #sidebar-sticky:
src/components/widget/SideBar.astro
<RecentPosts class="onload-animation" style="animation-delay: 100ms" />

4.6 用一张图收尾#

下面这个流程图就是用本次加的 Mermaid 功能渲染的——你在页面上看到的是 SVG 图表,而不是一段代码:

flowchart LR
A[修复 CI 质量门禁] --> B[一次性整合依赖升级]
B --> C[迁移 Content Layer API]
C --> D[全站功能改造]
D --> E[暗黑模式可读性修复]
E --> F[本地验证 + CI + Vercel 发布]
CAUTION

第一次跑 pnpm astro check 时报了 2 个错:RecentPosts 缺 style prop、works 详情页的 <Markdown> 缺必填 class。补齐后 68 files 0 errors / 0 warnings / 0 hints。新组件别省 Props 默认值,类型检查比浏览器更早发现问题。

五、暗黑模式可读性修复(commit 00bebbb + 77878ce)#

功能上线后自己切到夜间模式检查,发现新加的几个控件「看不清」。这部分单独拿出来说,因为根因挺有意思。

5.1 根因:模板没有基础文字色#

src/styles/main.css 里只有一组文字工具类带 dark: 变体:

src/styles/main.css
.text-90 { @apply text-black/90 dark:text-white/90 }
.text-75 { @apply text-black/75 dark:text-white/75 }
.text-50 { @apply text-black/50 dark:text-white/50 }
/* ... */

而卡片本身只管背景、不管文字:

src/styles/main.css
.card-base {
@apply rounded-[var(--radius-large)] overflow-hidden bg-[var(--card-bg)] transition;
}

全仓库没有任何 body / html 的基础文字色。所以凡是没包 text-* 类的文字,暗黑模式下就继承浏览器默认的纯黑 rgb(0, 0, 0),压在 --card-bg: oklch(.23 .015 250) 这种深色卡片上自然看不清。反而是激活态的排序按钮因为写了 .hot-sort.is-on { color: white } 一直正常——「有显式色就没事,没显式色就出事」,正好印证这个判断。

5.2 修法一:悬浮面板与排序按钮#

在 src/components/GlobalFeatures.astro 的 <style is:global> 里给两个容器补明暗两套显式色:

src/components/GlobalFeatures.astro
#feature-controls-panel {
display: flex;
/* 模板未设基础文字色,默认继承纯黑;暗黑模式必须显式给出可读文字色 */
color: rgba(0, 0, 0, 0.75);
}
.dark #feature-controls-panel {
color: rgba(255, 255, 255, 0.75);
}
/* 排序按钮同时出现在悬浮面板与 /hot/ 页面,需独立给出明暗两套文字色 */
.hot-sort {
color: rgba(0, 0, 0, 0.75);
}
.dark .hot-sort {
color: rgba(255, 255, 255, 0.75);
}
/* 激活态白字优先级需同时压过明暗两套基础色 */
.hot-sort.is-on,
.dark .hot-sort.is-on {
color: #ffffff;
}

.hot-sort 同时被悬浮面板里的 3 个按钮和 /hot/ 页面里的 3 个按钮用到,所以写成全局规则一次覆盖。

5.3 修法二:Waline 评论区#

全站复核时,/friends/ 还剩 2 个黑字:「评论」计数和排序标题,来自模板自带的 Waline 组件。

NOTE

这不是本次改造引入的回归。<Waline /> 早在基线 097787d 的 src/pages/friends.astro 里就存在,我这次只改了友链数据来源,没碰第 73 行的评论组件。

Waline 是 @waline/client@v3,src/components/Waline/Waline.astro 里配了 dark: "html.dark",样式从 https://unpkg.com/@waline/client@v3/dist/waline.css 加载。实测发现:

  • 覆盖 --waline-color / --waline-text-color 这些变量无效;
  • 只有给 #waline 容器设基础 color 才生效;
  • wl-count / wl-meta-head 在亮色和暗色下都算成 rgb(0, 0, 0)。

所以加一条最小 scoped 规则:

src/components/GlobalFeatures.astro
/*
* Waline 评论组件(模板自带的 @waline/client@v3)在暗黑模式下未给容器设置
* 基础文字色,评论计数、排序标题等标签会继承纯黑,压在深色背景上看不清。
* 用 #waline 容器 ID 提升特异度,只作用于评论区域,不影响站点其它文字;
* 自带显式颜色的控件(如绿底主按钮)仍保留原有配色。
*/
.dark #waline {
color: rgba(255, 255, 255, 0.75);
}
为什么不硬编码 .wl-count 之类

借 #waline 这个容器 ID 提特异度,属于 Waline 公开的挂载点,升级客户端版本不会静默失效;而 .wl-count / .wl-meta-head 是内部类名,改版就可能变。另外这条规则只在 .dark 下生效,亮色模式完全不受影响。

「提交」按钮刻意保留原样:它是绿底 #27ae60 + 黑字,对比度 7.31:1;改成白字会掉到约 2.6:1,属于负优化。而且它由 Waline 自带的 .wl-btn.primary 显式 color 控制,本来也不会被容器 color 覆盖。

关于验证方法

这次没有靠截图判断(有些环境看不了图),而是在真实页面里用 getComputedStyle 逐元素测颜色:先看 --card-bg 是否加载、.text-90 探针是否为 rgba(255,255,255,0.9),确认样式表真的生效了,再用 TreeWalker 扫全页可见文字节点、把 rgb 三通道都小于 60 的记为「黑字」。Waline 那条规则做了 A/B 可逆验证:基线 2 个黑字 → 加规则 1 个 → 移除探针回到 2 个,证明不是测量伪影。顺便排除了几个疑点:applyTheme 争抢 dark class、Mermaid 重绘竞态、随机背景发灰,都不是原因。

CAUTION

别用隐藏 iframe + document.write 去测样式。 那种方式里样式表根本没加载,会返回 --card-bg: ""、满屏假黑字,看着像一堆 bug,其实全是测量伪影。要测就用真实 tab 导航到真实 URL,或者用同源 iframe 的 iframe.src = origin + path 并等 load 完成后再测。

六、验证与发布#

6.1 本地验证#

每次提交前都跑完整套:

检查结果
pnpm exec biome ci ./src78 files, No fixes applied, EXIT=0
pnpm astro checkResult (68 files): 0 errors / 0 warnings / 0 hints
pnpm build56 pages built,pagefind v1.6.0 Indexed 25 pages / 22499 words
URL diff51/51 既有路由全部保留,LOST_COUNT=0
node --checksite-features.js 语法通过

新增的 5 条路由:/apps/、/hot/、/works/、/works/fuwari-blog/、/works/music-panel/。

NOTE

构建产物里 CSS 会被压缩,例如 rgba(255, 255, 255, 0.75) 会变成 #ffffffbf。所以核对某条规则是否上线时,别直接搜原始字符串;用压缩后的等价写法(或按选择器前缀)去搜。本次产物 /_astro/_page_.c7zMAKFv.css 里能找到 .dark #waline{color:#ffffffbf}。

6.2 CI / Vercel / 生产站#

git push origin main 会自动触发 Vercel 重部署。四个提交最终状态:

  • GitHub check-runs:5/5 success(Astro Check for Node.js 22/23、Astro Build for Node.js 22/23、Code quality);
  • Vercel:state=success;
  • 生产站 https://p1ume.vercel.app/ 上 /、/works/、/apps/、/hot/ 等全部 HTTP 200;
  • 6 个新页面暗黑模式逐页复核:/apps/、/friends/、/works/、/works/fuwari-blog/、/works/music-panel/、/hot/ 全部 cssOk: true、黑字数 0(/friends/ 仅剩刻意保留的绿色「提交」按钮),Mermaid 图表在 /works/fuwari-blog/ 已渲染成 SVG。
NOTE

推送时如果碰到 fatal: could not read Username for 'https://github.com'(某些环境重启后 git 凭证脚本会失效),可以用一次性的内联 Basic 头推送,不要往全局 config 里写死凭证:

Terminal window
TOK=$(tr -d '\r\n' < /c/Users/p1ume_/AppData/Local/Temp/ghtok.txt)
AUTH=$(printf 'x-access-token:%s' "$TOK" | base64 | tr -d '\r\n')
GIT_TERMINAL_PROMPT=0 git -c credential.helper= -c "http.extraHeader=Authorization: Basic $AUTH" push origin main

小结#

四个提交已经全部同步到 GitHub 主仓库,CI 与 Vercel 全绿,仓库 main...origin/main 干净:

34bf148 chore: revive maintenance — fix CI gate, upgrade deps, migrate content layer
b536f6f feat: blog-wide enhancements inspired by Besty0728/fuwari
00bebbb fix: readable feature controls and sort labels in dark mode
77878ce fix: readable Waline comment labels in dark mode

这次之后,模板自带的深浅色切换、Swup、TOC、置顶、KaTeX、Admonitions、代码块增强、GitHub 卡片、PhotoSwipe、pagefind、系列、Waline、显示设置、字数与阅读时长都还在,另外补上了阅读进度、Mermaid、作品集、应用页、热门排序、最近更新、强制暗黑、随机背景、背景音乐与显示控制面板。

暂时没做的事

Astro 7 与 @astrojs/svelte 9.x 要求 engines.node >= 22.23.4,和本站 node >= 18 的承诺冲突,所以这轮没有升。相关前置条件记在 track-b-prerequisites.md 里,只留了文档、没有改代码。等哪天决定把最低 Node 版本抬上去,再单独开一轮。

如果你也在维护一个久未更新的 Fuwari 站点,建议就按这个顺序来:先让 CI 变绿 → 再一次性整合依赖 → 再动内容层 → 最后加功能 → 每一步都用构建产物和真实页面验收。跳过前面两步直接堆功能,后面排查会非常痛苦。

本文的范围

本文只记录这次「整包升级」的四个提交。此后站点还在持续做风格与文案的统一打磨(导航入口统一成中文、空态与禁用态文案收进 labels、友链申请格式与 JSON 数据驱动对齐、给早期教程补「适用版本说明」等),这些属于日常维护、提交会继续累加,但都不改变本文描述的架构与思路。

再往后还做过一轮外观与内容整理:移除了导航栏的「音乐」外链与「音乐面板」这条作品(/works/music-panel/ 随之下线,所以上文的 5 条新路由、作品详情示例与暗黑复核列表里出现的这个地址,写的都是当时状态);/works/、/apps/ 与作品详情页换成了统一的图标卡片 / 胶囊按钮 / 显式明暗色 token 设计——详见 src/styles/feature-pages.css 与 src/components/FeatureCard.astro。要看当前状态,以 GitHub 主仓库和线上站点为准。

Fuwari 博客 2026 大升级:维护、Content Layer 迁移与全站功能改造
https://p1ume.vercel.app/posts/fuwari/maintenance-2026/
作者
p1ume
发布于
2026-11-09
许可协议
CC BY-NC-SA 4.0