Washi 和紙

15 min read

展卷生温,落笔成章。


Washi 和紙

展卷生温,落笔成章。

License: MIT Astro Node

一个基于 Astro 7 的纯静态个人站点框架:多语言、双主题、全站配色可配置,带看板娘、Live2D 展示台与 Canvas 粒子特效,Docker 一键部署。

仓库自带一组示例内容,clone 下来直接构建即是一个可运行的完整站点;将示例替换为自己的内容即可。个人内容不入库,详见分发边界。


特性

国际化 简体中文 / 繁体中文 / English / 日本語,路由按 [locale] 分组
主题配色 全站颜色集中在 src/theme.json 一个文件中,改它即可更换整个站点的配色
暗色模式 浅色 / 暗色切换,偏好持久化到 localStorage,首次进入跟随系统
全站搜索 覆盖页面、文章、作品、友链
首页入口 「作品与文章」卡片列出最近条目,并给出 RSS 订阅入口
粒子特效 雨滴 / 萤火虫 / 樱花,跟随主题取色,隐藏时不占用 GPU
看板娘 首页 / 页脚插画、404、加载动画与机器人头像,可一键收起
Live2D 展示台 详情页(文章 / 作品)可渲染一座跟随指针的模型
SEO 每页 description / canonical / OG 与 Twitter 卡片;四语言互相标注 hreflang,sitemap 里同样
分发 /sitemap-index.xml、/robots.txt、单份 /rss.xml(四语言共用,条目指向 zh-CN)
详情页功能 阅读时长估算、上一篇 / 下一篇、代码块一键复制
标签与草稿 文章可写 tags(详情页显示)与 draft: true(开发时可见,生产构建中彻底不出现)
加载动画 每次跳转自动放映,覆盖浏览器抓取新页的空白期
字体子集 构建后按产物的实际用字子集化为 woff2,漏字会导致构建失败
响应式 桌面 / 平板 / 手机三端适配

技术栈

类别 技术 许可
框架 Astro v7(SSG,静态输出) MIT
语言 TypeScript Apache-2.0
字体 LXGW WenKai / WenKai Mono(霞鹜文楷 GB) SIL OFL 1.1
图标 Phosphor Icons(经 astro-icon) MIT
粒子 / 交互 原生 Canvas 2D + TypeScript —
Live2D pixi.js + pixi-live2d-display + Live2D Cubism Core MIT / Live2D 专有
部署 Docker + Nginx —

快速开始

需要 Node.js ≥ 22.12。

npm install
npm run dev        # 开发服务器,端口 4400
npm run build      # 构建到 dist/
npm run preview    # 构建后本地预览

首次 dev / build 会自动下载约 74 MB 的源字体到 fonts-src/(已 gitignore),随后进行子集化;只需要联网一次。

端口为什么是 4400:Windows 会把一段端口预留给 Hyper-V / WSL,落在其中的端口绑定时直接 EACCES(端口无人占用,系统不允许绑定),而 Astro 默认的 4321 可能正好落在预留段内。预留段每次开机重新划分,因此配置中固定一个不贴边界的端口。查看当前预留段:

netsh interface ipv4 show excludedportrange protocol=tcp

npm scripts

命令 作用
npm run dev 开发服务器(4400)。字体扫描 src/,是一个超集
npm run build 构建到 dist/,结束时自动运行字体子集与漏字校验
npm run preview 先 build 再本地预览 dist/
npm run fonts 单独重跑字体子集(修改内容后希望 dev 立即使用新字时运行)
npm run fonts:fetch 只下载源字体,不做子集化
npm run fonts:check 只做漏字校验(build 时已自动运行)

配置

站点配置每个字段都有默认值,不创建 .env 也能正常构建。需要自定义时复制模板:

cp .env.example .env
变量 说明
SITE_NAME 站点名
SITE_URL 站点完整网址,须带协议(https://example.com)。用于把站内路径拼成绝对地址,见 MY_FRIEND_AVATAR。写错会在构建期报错
SITE_EMAIL 联系邮箱
SITE_COPYRIGHT 页脚版权信息
SITE_DESCRIPTION 站点级描述。每个页面的兜底(详情页使用条目自身的 desc),同时是 RSS 频道的描述
SITE_KEYWORDS 原样进入 <meta name="keywords">,逗号分隔。不设 → 整条 meta 不输出
SITE_OG_IMAGE 分享卡片图(og:image),填 public/ 下的稳定地址(1200×630 为佳)
SITE_TWITTER 形如 @yourhandle。不设 → 只输出 twitter:card
HERO_NAME 首页 Hero 昵称
HERO_SUBTITLE Hero 副标题
HERO_QUOTE Hero 个性引言
PROFILE_NAME / PROFILE_BIO 个人信息卡片的昵称与简介
PROFILE_BILIBILI / PROFILE_GITHUB 社交链接
STATUS_PLAYING 正在玩的游戏
STATUS_MUSIC_TITLE / STATUS_MUSIC_URL 音乐挂件的曲目与链接
STATUS_ALMANAC 今日宜忌词池,逗号分隔(中英文逗号都接受)。不设 → 使用内置词池;设为空 → 整块不显示
ACTIVITY_LIMIT 首页「最近动态」显示条数,0 表示不折叠。默认 3
BOT_NAME / BOT_DESC 机器人卡片
MY_FRIEND_NAME / _DESC / _URL / _AVATAR 友链互抄用的本站信息
EXCLUDE_EXAMPLES 构建期是否排除示例内容,见内容管理
FONTS_OFFLINE 内网 / 离线环境设为 1,跳过字体下载的联网检查

MY_FRIEND_AVATAR 是供他人复制使用的地址:交换友链那张卡片会把它作为文本印出,对方复制后要能直接使用,所以必须是公开可访问的。填绝对地址(图床亦可);填站内路径(如 /head.svg)会自动拼上 SITE_URL。不要填站内 astro:assets 那条路径(/_astro/head.<hash>.webp):每次构建都会更换哈希,被复制走的地址在下次构建后即失效。

不设也没有关系:默认是 src/pages/head.svg.ts 生成的占位头像,一定存在,拼出来就是 https://你的域名/head.svg。

所有变量读取的都是静态的 import.meta.env.XXX:Vite 只对静态成员访问做构建期替换,写成 import.meta.env['XXX'] 或整个对象解构在生产构建中会取不到值。

配色与主题

全站颜色集中在 src/theme.json 一个文件中,改它即可更换整个站点的配色,不需要改动任何组件:

{
  "accent":  { "light": "#8b5e3c", "dark": "#c4a574" },
  "text":    { "light": "#8b7355", "dark": "#d4bfa8" },
  "fx-rain": "#beaf9b"
}

每个 token 自动生成两个 CSS 变量:

  • --c-accent:完整色值,直接用于 color / background / border
  • --c-accent-rgb:139, 94, 60 形式的三元组,用于在样式中拼任意透明度:rgba(var(--c-accent-rgb), .08)

三元组是这套机制的支点:全站十几处不同透明度的强调色(描边、悬停、引用线、时间轴圆点)只认 accent 一个源,改一次即全部跟随变化。

  • light / dark 是昼夜两套值;只写一个字符串表示两种模式共用(粒子颜色、favicon 这类不必写两遍)
  • 写 "@别的 token" 表示跟随那个 token,修改被跟随者时这里一并变化
  • 值支持 #rgb / #rrggbb / rgb() / rgba() / transparent / 整条渐变(linear-gradient(...) 这类)
  • 每个 token 带一行 note 说明用途;添加自定义 token 是允许的,--c-<名字> 立即可用
  • 改完保存即可,dev 下热更新(theme.json 经 Vite 直接 import)

值写错会在构建期直接报错并指名是哪个 token,不会静默失效:排查错误的代价远高于打错一个字。

站点图标 /favicon.svg 由 src/pages/favicon.svg.ts 按 favicon token 渲染:改颜色改 theme.json,改形状改 src/assets/favicon.svg。public/favicon.ico 是为旧浏览器准备的栅格兜底,不参与主题化。

Canvas 粒子读不到 CSS 变量,走 src/scripts/theme-colors.ts 的 readThemeColors(),用 getComputedStyle 取三元组;那是 src/ 中唯一允许保留颜色字面量(兜底值)的地方。

内容管理

内容通过 Astro 内容集合管理,schema 定义在 src/content.config.ts:

集合 目录 字段
作品 works src/content/works/*.md name / desc / icon / order / github / release / links / live2d(可选,正文顶部渲染展示台)/ style(可选,本页自定义 CSS)+ 正文
文章 blog src/content/blog/*.md title / desc / date / tags(可选,详情页显示)/ draft(可选,true = 草稿)/ links / style(可选,本页自定义 CSS)/ live2d(可选,正文顶部渲染展示台)+ 正文
友链 friends src/content/friends/*.json name / desc / url / avatar
技术栈 skills src/content/skills/*.json name / icon(Phosphor 图标名)
最近动态 activity src/content/activity/*.json date / text / link(可选,整条动态的跳转地址)

新增或修改内容只需在对应目录增删文件,无须改动代码。每个集合的 _example.* 是自带示例,字段填写较为完整,可以参照修改。

desc 不只是列表卡片上的摘要:搜索结果、RSS 条目、og:description 都读取它(RSS 条目刻意不带正文),因此要当作摘要认真编写。

draft: true 的文章在开发时照常可见、可预览,生产构建中彻底不出现:列表、搜索、RSS、sitemap 以及它自己的页面全都没有。过滤集中在 src/content-query.ts 一处,新增页面取数时请走它(不要直接 getCollection('blog'),漏掉一处就是一个「写了草稿却照样发布」的陷阱)。

替换为自己的内容

src/content/<集合>/_example.* 是随仓库分发的示例条目,使 clone 下来直接就是一个可运行的演示站。开始撰写自己的内容后,有两种方式让它消失:

  1. 构建期排除(推荐):在 .env 中设 EXCLUDE_EXAMPLES=1
  2. 直接删除 _example.* 文件

两者等效。采用方式 1 的好处是示例文件仍留在本地,随时可以翻出来对照字段写法。

⚠️ 修改开关后须整体重启 dev server。content.config.ts 只在启动时求值一次,删除 .env 里的开关后热更新不会跟上。

分发边界:这个仓库提交什么、不提交什么

本仓库的定位是只开源框架、不公开个人内容,因此 .gitignore 固定忽略 5 个内容集合:

src/content/blog/**/*.md
src/content/works/**/*.md
src/content/activity/**/*.json
# ...(friends / skills 同理)

因此这个仓库中只会提交每个集合的 _example.*,个人撰写的内容不会被提交,由 .gitignore 保证。反向放行规则刻意只写单层:

!src/content/*/_example.*

把「豁免入库」的范围精确限制在这 5 个示例文件上;改成 ** 是放宽限制,不要当作 bug 修改。

若 fork 下来作为自己的博客使用,并且希望把文章纳入版本控制,必须先把那几行删掉(或注释掉)。保留它们的后果是:自己写的 src/content/blog/my-post.md 会被 git 静默忽略:文件在本地完好,git status 中却看不见,既提交不上,也部署不出去。

同样被忽略的还有:.env(私密配置)、public/fonts/(构建产物)、fonts-src/ / live2d-src/ / assets-src/(素材母版)、content-backup/(个人内容备份)。完整清单见 .gitignore。

部署

Docker

确保已安装 Docker 与 Docker Compose:

cp .env.example .env
vim .env
docker compose up -d --build

镜像分三个阶段构建(取源字体 → 构建站点 → Nginx 托管),最终只有一个容器:

容器 说明
washi-site Nginx 静态站点,对外 80

docker-compose.yml 默认把容器接入名为 nginx-proxy 的外部网络。这个网络须先存在,或者按自己的反向代理方案修改:

docker network create nginx-proxy

.env 会被带进构建阶段(.dockerignore 未排除它),因此构建期变量在容器中同样生效,包括 EXCLUDE_EXAMPLES;而最终镜像只 COPY --from=build /app/dist,.env 不会进入镜像。

Nginx

nginx.conf 中有一条不能改的规则:

location ^~ /fonts/ { add_header Cache-Control "max-age=600, must-revalidate"; }

字体子集的文件名固定、内容每次构建都变,不能 immutable:回访者若拿着 30 天 immutable 的旧子集,新写的字会回落成系统字体且刷新不掉。同理也不做内容哈希文件名:<link rel="preload"> 位于构建前就渲染好的 HTML 中,要哈希就得构建两遍。

目录结构

Washi/
├── src/
│   ├── assets/            # 构建期优化图片(头像、favicon 源、插图,经 astro:assets)
│   ├── components/        # Astro 组件
│   │   ├── Navbar.astro        # 顶栏:品牌、导航、语言切换、搜索、汉堡菜单
│   │   ├── NoIXCard.astro      # 机器人 / 友链卡片(含 Live2D 舞台)
│   │   ├── Live2DViewer.astro  # Live2D 舞台:pixi + cubism4,空闲时才延迟加载
│   │   ├── TableOfContents.astro # 详情页目录侧栏(窄屏收成抽屉,浮钮在右下角)
│   │   ├── CodeCopy.astro      # 代码块复制按钮(运行时包一层 .code-block)
│   │   ├── RainFX / FireflyFX / SakuraFX.astro   # Canvas 粒子特效
│   │   ├── Status.astro        # 音乐 / 游戏 / 宜忌挂件
│   │   └── …                   # Profile、Activity、TechStack、SearchCard…
│   ├── content/           # 内容集合(个人内容 gitignore,只留 _example.*)
│   ├── content.config.ts  # 内容集合 schema
│   ├── content-query.ts   # 内容取数的唯一入口:挡草稿 + 定顺序
│   ├── reading-time.ts    # 正文阅读时长估算(零依赖)
│   ├── i18n/              # 多语言文案
│   ├── layouts/Layout.astro    # 根布局:偏好恢复脚本、主题注入、SEO 标签
│   ├── pages/
│   │   ├── [locale]/      # 按语言分组的页面路由
│   │   ├── favicon.svg.ts # 按主题色渲染站点图标
│   │   ├── head.svg.ts    # 友链互抄用的占位头像
│   │   ├── robots.txt.ts  # robots.txt(域名取自 env)
│   │   ├── rss.xml.ts     # 单份 RSS feed(四语言共用)
│   │   └── 404.astro      # 全局 404
│   ├── scripts/theme-colors.ts # Canvas 粒子取主题色
│   ├── styles/global.css  # 全局样式与正文排版
│   ├── theme.json         # 全站配色(唯一真值源)
│   ├── theme.ts           # 读 theme.json → 校验 → 生成 CSS 变量
│   └── site.config.ts     # 站点配置(env 驱动)
├── scripts/               # 字体管线(下载 / 子集化 / 漏字校验)
├── public/                # 静态资源(背景纹理、favicon.ico;fonts/ 是构建产物)
├── fonts-src/             # 源字体 TTF(gitignore,首次构建自动下载)
├── assets-src/            # 插画母版(gitignore,入库的那份按展示宽度压过)
├── live2d-src/            # Live2D 贴图母版(gitignore)
├── content-backup/        # 个人内容备份(gitignore,绝不入库)
├── Dockerfile / docker-compose.yml / nginx.conf
└── .env.example

入库的图片素材按真实展示宽度存放,不直接存放母版;原因见图片尺寸。

实现说明

配色

唯一真值源是 src/theme.json。 src/theme.ts 读取它、校验并生成一段 CSS,由 Layout.astro 用 <style is:global set:html={themeCss}> 内联进 <head> 最前面(首帧前生效,不会闪色)。组件中不再出现颜色字面量。

暗色不另起一套名字::root 定义浅色,body.dark 用同名重定义。自定义属性靠继承传递、不参与选择器匹配,所以组件中写一次 var(--c-text) 就会随 <body> 的 class 自动翻转。

这带来两个好处:

  1. 不需要 body.dark <选择器> 覆盖:全站原有约 46 处逐条覆盖的颜色规则已经全部删除,以后新增组件不必再写两遍
  2. 绕开 Astro 的作用域陷阱:自定义属性靠继承传递,不受 data-astro-cid-* 作用域属性影响

SEO 与分发

<head> 里那一整块由 Layout.astro 渲染,站点级的值走 env,条目级的值走 frontmatter:

  • 每页输出 description(详情页用条目的 desc,其余回落 SITE_DESCRIPTION)、canonical、OG 与 Twitter 卡片
  • 四语言互为 hreflang:同一篇文章在 /zh-CN/ /zh-TW/ /en/ /ja/ 各有一份,互相标注是告诉搜索引擎「这是同一页的四个版本」,不是四份重复内容。sitemap 里那份(xhtml:link)说的是同一件事
  • canonical 与 sitemap 都带尾斜杠,两端对齐;og:locale 用下划线(zh_CN)、hreflang 用连字符(zh-CN),不是同一套写法
  • og:image 默认指向 /head.webp(256px,占位)。要做正式的分享卡片,就放一张 1200×630 到 public/,再把 SITE_OG_IMAGE 指过去;必须是 public/ 下的稳定地址,不能走 astro:assets(哈希路径每次构建都变,而这张图会被外部平台长期缓存)
  • /sitemap-index.xml / /robots.txt / /rss.xml 三个产物:sitemap 由 @astrojs/sitemap 生成(索引文件名是 sitemap-index.xml),robots 做成路由以便取 SITE_URL,RSS 只有一份(内容集合与语言无关,同一篇 md 在四个语言壳里各渲染一次,出四份就是同一批条目重复)
  • 404 与 search 标 noindex 且不进 sitemap。前者是因为 nginx 的 try_files 是拿 /404.html 以 200 回给不存在的地址的(软 404),后者把整站内容内联进一页 HTML

详情页功能

  • 阅读时长:src/reading-time.ts,零依赖。中文按字、西文按词,各除各的速度再相加(按空格切词的算法会把整段中文当成一个词);代码块、行内代码、图片与链接地址先剔除。速度值是估算的,只要量级正确即可
  • 上一篇 / 下一篇:顺序即列表顺序(文章按 date 降序、作品按 order 升序),从列表点进来再往前后翻,位置不会错乱。顺序只有 src/content-query.ts 一份实现
  • 代码块复制:Shiki 的 <pre> 外面没有任何包裹元素,而它自身 overflow-x: auto:按钮直接绝对定位在其内部会随横向滚动一同移动。因此在运行时包一层 .code-block 再挂按钮,图标在构建期渲进 <template> 后克隆,不引入图标库的 JS。复制走 navigator.clipboard,非 HTTPS 环境下回落到 textarea + execCommand

字体子集

字体子集(三个 woff2)是构建产物,不入库。scripts/fonts-integration.mjs 挂在 Astro 生命周期上:dev 启动时切一份到 public/fonts/,build 结束时切一份到 dist/fonts/。clone 下来直接运行即可,不用手动准备字体。

  • 核心不变式:字符集与字体出自同一次构建。 build 模式扫描的是构建产物(按原始字节收取,不解析 HTML),所以「这次渲染出来的字」必然在「这次生成的字体」中,不存在「内容更新而字体未跟上」的时间窗
  • 两种模式分辨率不同,这是有意为之:build 精确(扫描构建产物,只收这次真正渲染出的字),dev 没有 dist 可扫,退回扫 src/,是一个含注释用字的超集。已知边界:dev 启动之后新写的内容不会反映到字体中(Vite 不会重跑 config:setup),要立即生效就运行 npm run fonts。三个 woff2 合计约 0.35 MB
  • 漏字不会报错,只会静默回落到系统字体,因此构建流程自动运行 npm run fonts:check:它独立重扫一遍产物来比对(刻意不共用生成器的扫描逻辑,否则生成器的 bug 在它眼中不可见),漏字以非零码退出、构建失败
  • 不使用「常用汉字表」之类的保底字符集:实测站点只用到 3500 常用字中的 797 个,另外 2703 字从未出现过,却让每个字重多背约 660 KB
  • 源字体来自 lxgw/LxgwWenkaiGB(GB 版是独立仓库,lxgw/LxgwWenKai 上的 ttf 与压缩包都是非 GB 的)。sha256 钉在 scripts/font-pins.mjs 中;子集化走 subset-font(harfbuzz wasm),读字体走 fontkitten,全流程不需要 Python

内容集合与构建期开关

src/content.config.ts 中每个集合的 glob() 模式根据 EXCLUDE_EXAMPLES 决定是否带上 !**/_example.* 这条负向模式。排除做在 loader 层,所以 getCollection / getEntry 的所有调用点都自动看不到示例,新增集合也会自动继承。

开关优先读取 import.meta.env(认 1 / true / yes / on),取不到时回落 process.env。构建时会打印一行当前模式,不会静默。

注意:Astro 的「下划线开头的文件会被忽略」只对旧的内容集合层生效,astro/loaders 的 glob() 不忽略下划线文件,因此排除必须显式写成负向模式;改文件名前缀没有用。

偏好模型

三个偏好存放在 localStorage,由 Layout.astro 在 <body> 最前面的内联脚本恢复(首帧前完成,以免跟随系统暗色时先闪一下浅色):

偏好 首次进入的默认值 存储键
昼夜 跟随系统,只在加载时判定一次 theme
粒子 普通页按昼夜取默认值(浅色樱花 / 暗色萤火虫);详情页默认关闭 fx:normal / fx:detail
看板娘 显示 mascot

详情页指文章正文与作品详情,判定依据是 <body data-page-kind="detail">。详情页的「默认关闭」不写入 localStorage,以免把「用户未选择过」固化为「用户选择了关闭」。

粒子特效

RainFX、FireflyFX、SakuraFX 三个 Canvas 特效共享同一模式:

  • 画布初始为 display: none,停止时完全不占用 GPU
  • 偏好 class 挂在 <body> 上(fx-rain / fx-firefly / fx-sakura / fx-none)
  • 组件通过 MutationObserver(监听 class 变化)与 fx-init 事件双重监听,确保无论加载顺序如何都能正确启停;running 标志位防止重复执行
  • 颜色在每次 start*() 的守卫之前重读一次:切换昼夜时 MutationObserver 走的就是这条路径,颜色才会立即跟随变化

图片尺寸:为什么不能放母版

Astro 用 getSizesAttribute() 按「图片固有宽度与视口」推导 sizes,没有配置项可以覆盖。因此:

  • <Image> 的 width 必须写 CSS 中真实的展示宽度,源图也不要超过真实展示宽度,否则浏览器会按过大的 sizes 挑档(写 width={1760} 而实际只显示 440px,就会白白拉取 4 倍大的图)
  • CSS 固定尺寸的图(width: 240px 之类)用 layout="fixed"
  • 高分辨率母版因此另放 assets-src/(已 gitignore)

目录侧栏

详情页左侧那条目录是自动生成的:数据直接取 render() 本来就返回的 headings;Astro 默认已用 github-slugger 给正文标题写好 id,因此不需要 rehype 插件,也没有新增依赖。

  • frontmatter 中 toc: false 可以整篇关闭;默认是自动,二级以上标题达到 3 条才生成:一篇两节便说完的短文挂上目录比正文还重
  • 桌面为 208px 的左侧分栏,正文栏写死 832px,与没有目录的页面逐像素一致;分栏与正文之间留 32px。空间不足时(≤1152px)收成右下角的浮钮与从左侧推出的抽屉,与顶栏的汉堡菜单是同一套语汇。栏中不写「目录」二字:一条轴加几枚书签已足以自明,无障碍名交给 aria-label
  • 整块(目录栏与正文栏)在视口中居中,再向左偏移 24px。做法是把容器宽度定为 1120px(= 208 + 32 + 832 + 48 内边距),恰好包住两栏,由父级居中;左右内边距再按左 0 / 右 48 不均分,内容盒便整体左移 24px。这是让位的唯一手法,改动容器外侧会牵动断点。居中与「轴对齐顶栏左缘」两个目标无法同时成立:整块居中,正文栏必然落在视口中心以右(左侧还压着 208 + 32 的目录栏);轴与顶栏对齐,整块必然偏左。早先取的是后者的 1311px 方案(容器宽 = 顶栏宽 + 2 × 227,zh-CN 顶栏实测 857 → 1311),代价是空白全部落在右侧、整块偏左约 95px,现已改为居中并左让 24px
  • 正文那一栏写死 832px,不写 minmax(0, 1fr):容器比内容宽,弹性轨道会把正文拉到 991px,中文一行从五十来字涨到六十多字。间距因此成为一个可以单独调整的旋钮:调它只推动正文,不会顺带把目录拽走
  • 窄屏那条竖虚线轴翻到左边(抽屉自左侧推出,轴跟着靠左才顺),书签牌与支线整组镜像;桌面轴在右边,顺带承担了目录栏与正文之间的分割线
  • 书签牌的造型:靠轴的两个角是圆角,远离轴那条边是一道浅 V 剪口(书签剪口)。clip-path 与 border-radius 可以共存:前者裁的是绘制完成后的像素,圆角早由后者画进背景,多边形只要把四个角原样包住就留得下来
  • 颜色另开了 toc-* 一族 token:同一套透明度两种模式不能共用。暗色的纸纹更花,浅色下那层 7% 的牌底铺上去几乎看不见,牌会散开;文字若继续用 muted-dim,在暗色下也只有约 3:1。因此浅色沿用原来的值、暗色单独加浓
  • 右下角原本是「回到顶部」的位置。有目录的页面由它自己向上让出一格(:has(.toc-fab) 只在真有浮钮的页面上生效),两个浮钮共用同一条右边线、上下排列;没有目录的详情页与其他页面位置照旧
  • 两条容易踩到的 CSS 约定:正文那一列不能使用 1fr 之类的弹性轨道(容器比内容宽,正文会被拉长到不合适的行宽;写死 832px 同时也免疫宽表格把整列撑开);目录那一列不能写 align-items: start(须拉伸到整行高,其中的 sticky 才有参照)
  • 顺带修掉一个既有 bug:此前点击锚点跳转,标题会停在 sticky 顶栏下方。现在标题带 scroll-margin-top,平滑滚动也遵循 prefers-reduced-motion

样式约定

  • 颜色一律使用 var(--c-*),值来自 src/theme.json。组件 <style> 中不写颜色字面量
  • 昼夜两套值由 :root 与 body.dark 以同名变量定义,组件中写一次即自动翻转,不需要再写 body.dark 覆盖
  • Astro 作用域陷阱:<html> / <body> 上的 class(body.dark、html.mascot-off)做后代选择器时,前缀必须写成 html. / body.。Astro 只对这两个元素选择器免于添加作用域属性,写成 .mascot-off .x 会被编成 .mascot-off[data-astro-cid-x] .x[data-astro-cid-x],而 <html> 上并没有这个属性,规则静默失效且不报错
  • 同理,组件 <style> 中给 markdown 正文(<Content /> 渲染出的元素)写的选择器也匹配不到,正文排版一律放在 global.css
  • 窄屏正文不会被撑宽:正文中的表格、代码块超出栏宽时就地横向滚动,滚动条隐去。表格必须先写 display: block 才受 overflow-x 约束:overflow 对 display: table 的盒子不生效,直接写是一条不报错的无效声明;改成 block 后内层匿名表格按 auto 计算宽度,桌面上仍然满宽,观感不变
  • Canvas 特效读不到 CSS 变量,走 src/scripts/theme-colors.ts

常见问题

构建时提示某个字符在源字体中没有字形? 先确认该字符是否由自己写入。若是第三方 bundle(例如 Live2D 的 display.*.js)携带的装饰符号,可以忽略,它会回落到系统字体。若出自自己的内容,说明子集化没有覆盖到,执行一次 npm run build 即可(build 模式扫描的是产物,不会遗漏)。

dev 已启动,但新写入的字符仍是系统字体? dev 模式的字符集在启动时求值一次,之后新增的内容不会反映。执行 npm run fonts 立即生效。

修改了 .env 中的 EXCLUDE_EXAMPLES 却没有反应? content.config.ts 只在启动时求值一次,须整体重启 dev server。

图片模糊,或拉取了多个不同尺寸? 见图片尺寸:源图不要超过真实展示宽度。

端口无法绑定(EACCES)? 该端口落在 Windows 的 Hyper-V / WSL 预留段内,见快速开始中的排查命令。

致谢

许可

本项目的源代码以 MIT 许可发布,© 2026 NoIX。

但仓库中有几类文件不在 MIT 覆盖范围内,使用时须自行替换或移除:

文件 说明
public/live2d/live2dcubismcore.min.js Live2D 专有软件,适用 Live2D Proprietary Software License Agreement,不是 MIT。商用或使用它发布站点前,须自行确认已满足 Live2D 的授权条件
src/assets/noix-*.png、public/noix-*.png、public/noix-half-body-avatar/** 作者本人的角色插画与 Live2D 模型,保留所有权利,不随 MIT 授权。请替换为自己的素材
src/assets/head.jpg 作者头像,同上

字体(fonts-src/、构建产出的 public/fonts/)是构建期下载的 霞鹜文楷 GB,遵循 SIL OFL 1.1,不随本仓库分发。