Washi 和紙
展卷生温,落笔成章。
一个基于 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 下来直接就是一个可运行的演示站。开始撰写自己的内容后,有两种方式让它消失:
- 构建期排除(推荐):在
.env中设EXCLUDE_EXAMPLES=1 - 直接删除
_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 自动翻转。
这带来两个好处:
- 不需要
body.dark <选择器>覆盖:全站原有约 46 处逐条覆盖的颜色规则已经全部删除,以后新增组件不必再写两遍 - 绕开 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 预留段内,见快速开始中的排查命令。
致谢
- Astro:站点框架
- 霞鹜文楷 GB(LXGW WenKai):正文字体,SIL OFL 1.1
- Phosphor Icons:图标
- pixi.js / pixi-live2d-display:Live2D 渲染
- Live2D Cubism Core:Live2D 运行时
许可
本项目的源代码以 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,不随本仓库分发。

