版本记录
这个站是怎么造出来的
目录
传统书籍会在末页留一页版本记录,交代用了什么字体、什么纸、什么工艺。这一页是它的网页版本。
站点现在有四处:主页是门面,文章是里屋,另外还有一个导航站和一个图床。四处的性格不同——主页柔、文章静、导航站有色、图床几乎只剩一个投放口——但字体、构建系统、主色、那层玻璃的配方是同一套。接缝在那里,不装作没有。
标志#
ruuo 四个字母连成一条路径,从 r 的竖笔起笔,到 o 闭合收笔,中间不抬笔。买这个域名的理由就是这四个字母能连起来,所以标志应该把这件事画出来,而不是找一支字体把它排出来。
它不是手写坐标凑的,是按几何参数生成的:碗底是真半圆,竖笔切在圆的最左或最右点,因此每个转折都切线连续,弯是软的。o 比 x 高多探出 2.5 个单位——圆形字母在视觉上会显得比方形小,这是排版上的常规补偿。
最难的一处是 r。臂太宽就读成 n,太窄又只剩一根竖笔像 i。最后定在臂展 60(u 的碗跨度是 76),并且让肩缓缓抬起、到最右侧才陡然落下——n 的拱顶在正中,只要这个不对称还在,就不会认错。
页面载入时它会一笔写出来。因为整条路径本来就是连续的,这个动画演的正是“连得起来”这件事本身。
字体#
站点最开始只有四支字体。现在是九支、十一个子集,因为“字体”这件事被拆成了角色:同一页上,正文的汉字、正文的拉丁、巨标题、角落里的小标签、英文引文,各有各的脸。
| 角色 | 用的是 | 原始 | 子集后 |
|---|---|---|---|
| 正文中文 | 思源黑体 | 16.9 MB | 365 KB |
| 正文拉丁 | Satoshi | 0.1 MB | 28 KB |
| 界面中文 | MiSans | 官方分片 | 按需取片 |
| 巨标题 | 未来荧黑 Bold | 8.7 MB | 91 KB |
| 展示级中文 | 未来荧黑 Light / Regular | 17.6 MB | 641 KB |
| 分类标题 | 思源宋体 | 24.0 MB | 39 KB |
| 英文引文 | Fraunces | 0.3 MB | 113 KB |
| 小标签 | 一支像素体 | 6.7 MB | 47 KB |
| 等宽 | JetBrains Mono | 0.3 MB | 41 KB |
| 兜底 | Inter(含斜体) | 1.7 MB | 195 KB |
思源宋体压掉了 99.8%——它只负责导航站上那几个分类名,二十来个字,收整套是不划算的。
排在这些前面还有一支 HanPunct,只覆盖中日韩标点区段。它必须排在第一位,因为西文字体里也有这些码位,一旦轮到它们,引号和括号就会变成西文的形状。
回退顺序就是分工#
字体回退是逐字进行的,所以字体栈的顺序不是“首选和备胎”,而是分工表。正文那一栈把拉丁排在中文前面:拉丁字母交给 Satoshi,汉字自然落到后面的中文字体。反过来写也能出字,但那是让一支中文字体去排 English——它的拉丁只是附赠品。
这条也踩过坑:巨标题那一栈原来把一支只收了二十来个字的 Bold 排在最前,结果同一行标题里,凡是不在那二十来个字里的统统掉回 Regular,有的粗有的不粗。
子集是从渲染结果里扫出来的#
扫的是整段 HTML,不是剥完标签的纯文本——主页首屏那几句话写在 data-lines 属性里,标签一剥就没了,只按纯文本收集会让它们上线即豆腐块。同理,写作台复用站点的字体,它界面上的字、连同只存在于服务端代码里的报错文案,也一并喂进子集。
字符集只增不减地累积,因此发新文章不会让回访读者重新下载字体。
预载按页面区分#
各取所需:文章页预载中文黑体和圆体,导航站同理,主页只预载首屏那两支。
这一条最容易过期。主页首屏原来是一句中文,走圆体,于是预载写的是圆体加 Inter。后来那句话换成了英文、拉丁也从 Inter 换成了 Satoshi,预载却没跟着改——结果首屏真正要用的两支得等 CSS 解析完才开始下载,而抢先下来的两支首屏一个字都不用。预载是一份关于“第一眼看见什么”的断言,页面改了,断言就得跟着改。
中文排版#
文章那边的正文处理,读者不会注意到,但做错就会一直制造摩擦:
- 中西文间距
- 汉字与拉丁字母、数字相邻时补约八分之一字宽。用零宽元素的外边距实现,复制文本不会带出多余空格。跨标签的接缝同样处理。
- 智能引号
- 直引号自动转弯引号,跨标签配对,段落边界重置。撇号与引号分开判断,
It's不会被当成开引号。 - 标点压缩
- 只施加在显示级字号上。正文里全角标点占满一个字宽是对的,但到了大标题,一个「,」撑开的空洞会把版面撕开。压多少随字号增大而增大,这是印刷排版的老规矩。
- 着重号
- 含中文的强调渲染为字下加点,而不是几何倾斜的伪斜体——汉字没有斜体设计。构建期判定,运行时零成本。
- 避头尾
- 标点不落行首,开引号不落行尾。这条到处都成立。
- 悬挂标点
- 行首标点吊出版心,让左边界视觉齐平——只有 Safari 认这条属性。样式挂在
@supports下面,别的浏览器上这一行不生效,也不会因此错位。写在这里是因为:一份规格说明如果只写自己想要的样子,那它记的就不是这个站。
脚注在宽屏上渲染为页边注,浮在正文右侧空白处,与引用它的那一行齐平;窄屏收起,点编号就地展开。这套交互没有 JavaScript 参与。正文里的上标编号和右边那条注互相点亮——一页有三条注的时候,读者不该自己去数。
界面的材质#
界面上那些“面”——导航站的卡片、文章页的目录、上下篇——是同一个配方做的,三条:
面要真的半透。 纯白压在有颜色的底上是一块挖空。留一点透光,底下的颜色透上来一点,面和它坐着的地方才像同一种材料。
描边是减出来的,不是画上去的。 单色 border 只能一圈一样亮,而真实的边是上沿亮、下沿带一点环境色。做法是铺一层渐变,再用 mask-composite 把中间挖掉,只留一圈一像素。
加白之前先把底压暗。 高光是往上加白,可一块底已经在 0.8 的面没有加白的余量。余量是压出来的:先让它暗一点,光才有地方亮。同一个光源要在三处同时成立——面上一团、被扫亮的那段棱、朝反方向倒的影子——才是一个光源,而不是三个特效。
一块面,不是一排闪#
列表里不给每一项各配一套 hover 高亮,而是整列只放一块面,指针移到哪一项它就滑过去、变形到那一项的尺寸。导航站的卡片、左侧分类轨、文章归档、文章页的目录,四处都是这个手法。N 个各自闪的高亮和一块会走的面,信息量一样,后者是一件东西在动。
文章页的目录那一块还多一层职责:它跟着阅读位置走,鼠标悬停时临时被抢过去,移开再交还——目录本来就要同时回答“我在哪”和“我要去哪”。
图标#
全站图标出自同一份源文件,都画在 24 的网格上,视觉外径统一。粗细不写在图标里,写在调用的地方:同一枚图标在 19px 和 14px 上要保持看起来一样粗,落地像素得反算回网格上的描边宽度。三挡尺寸各配一个值,小的那一挡刻意粗一点,是光学补偿。
动效#
主页的背景是五团颜色,由 SVG 的高斯模糊加 alpha 阈值滤镜做融合,靠近时像液体互相吞并——纯滤镜,没有 WebGL。花瓣会被光标推开,斥力随距离平方衰减。指针是一个滞后跟随的环加一个精确跟随的点。
缓动曲线是解阻尼弹簧方程导出的,不是手调的贝塞尔。位移幅度全部刻意压过一轮——廉价的特效感通常不来自“做了什么”,而来自“做过了头”。
入场是有先后的:头像浮现,标志一笔写完,然后那句话才开始打。主角先,配角后。
顶栏的毛玻璃过渡、阅读进度条、缩略标志的渐显都由 CSS 滚动驱动动画完成,跑在合成器上,没有一个滚动监听器。凡是要读位置的脚本,几何一律量一次存起来,改窗口才重量——在滚动的路径上读一次元素尺寸,就等于逼浏览器把整页重新算一遍。
跨页有过场动画。它捕捉的是新页面的第一帧,那一帧若字体还没到,过场中途字会换脸重排,看上去就是卡了一下;所以过场会等字体,但最多等 300 毫秒。往里走和往回走的位移方向相反。
prefers-reduced-motion 下全部关闭。
深浅#
文章、导航站、图床跟随系统深浅,也可以在顶栏手动指定,三态循环。
主页没有深色。那一屏本来就是一片流动的浅色,把它压暗不是换个模式,是换一件东西。
实现#
站点是纯静态的,由一套自写的生成器产出。
- Markdown:
markdown-it-py,扩展了脚注、定义列表、任务列表、容器块 - 代码高亮:构建期由 Pygments 完成,页面不加载任何高亮脚本
- 模板:Jinja2 字体子集:
fontTools图像:Pillow - 搜索索引构建期生成,浏览器端做子串与二元组匹配——中文没有词边界,这比引入分词器更划算,而且断网可用
一次完整构建两秒多。样式三份(文章、导航站、图床),各约 150–180 KB,gzip 后 48–56 KB;搜索索引 15 KB。文章页的运行时脚本 13 KB,gzip 后 4.5 KB。
全部关闭 JavaScript 后页面依然可读、可导航:所有“先隐藏、等脚本点亮”的规则都挂在一个由内联脚本打上的标记下面,脚本没跑,页面自动退回全部可见。
图床#
对象存储只是个放字节的地方——它不能查、不能排序,列举还按次计费。所以“有哪些图、多大、什么时候传的、谁传的”一律记在本地的 SQLite 里,对象存储那边只认一个键。
键是内容哈希。两个人传同一张图,数据库里是两行,存储上只有一份对象。
上传前会把图里的元数据摘掉,像素一个都不动。EXIF 是跟着照片走的:机型、拍摄时间,手机拍的还带 GPS 坐标。转成 WebP 时这些自然就掉了,但“转完反而更大就留原样”那条路,会把原始字节原封不动送到一个公开链接上——传一张街拍,别人从链接里读得出你当时站在哪个路口。所以摘不干净的一律不走原样,改用转码出来的那份:宁可文件大一点,也不能因为没把握就把原文放出去。
写作与发布#
文章在站点自带的网页编辑器里写,预览走的是和生产完全相同的渲染管线与正式样式表,所以所见即所得是字节级的。
编辑器的结构按“作用域”分层,而不是把功能平铺在顶栏上。顶栏只留三样:文章列表、当前状态、发布。属性——文件名、日期、标签、摘要——不藏进抽屉,而是折在标题下方的一行摘要里,展开就在正文上方,不遮任何东西;写的时候它本来就该在眼前。
正文区整篇一起滚:输入框自己不滚动,由脚本撑到内容高度,于是标题和属性条会跟着正文走,读起来像一份文档而不是一个套在框里的输入区。
三处后台是一个规矩:在那一页的地址后面加 admin。博客是 /admin,导航站是 /nav/admin,图床是 /img/admin。不必记三个各不相同的路径。
保存后触发一次完整构建,产物写入新的版本目录,全部就绪才原子地切换软链接。因此发布过程中不存在页面半新半旧的中间态,回滚也只是把软链接指回上一版。