Skip to main content
项目58 1 分钟阅读

搭建这个数字花园

作者

用 Next.js + MDX + Cloudflare 搭建个人数字花园的记录:技术选型、摄影集图片流水线与 CDN 方案。

这个站点本身就是一个「项目记录」的好例子。本文记录它的搭建过程与技术决策。

技术选型 Spec

Markdown
### 目标

一个纯静态、构建期解析的数字花园,CSS 响应式兼容 Web/移动端,承载四类内容(对应站点四大栏目):

- **生活**`flows`):个人日常生活记录、旅行随笔、生活思考。
- **阅读**`series`):计算机领域优秀内容推荐(类似日报,链接到原文),以系列组织。
- **项目**`posts`):个人兴趣开发项目记录。
- **影集**`albums`):以旅行目的地为维度的摄影集,瀑布流展示、可放大浏览,支持滚轮/双击缩放、键盘 ←→ 与触屏左右滑动切换。

外加「关于」等静态页;站内另有「常青笔记」(`notes`,不进主导航)与标签 / 归档 / 知识图谱等发现入口。仅中文(单语言)。

---

## 实施现状总览

| 项                              | 状态   | 说明                                                  |
| ------------------------------ | ---- | --------------------------------------------------- |
| 仓库工程 `~/dev/wills-garden`      | ✅    | 基于 amytis v1.17.0;GitHub 私有仓库  |
| 四类内容 + 关于(中文)                  | ✅    | books 关闭;notes 存在但不进主导航                             |
| 影集系统(3 档图 / 瀑布流 / Lightbox)    | ✅    | 自包含内容层,见下文                                          |
| HDR 图片管线 `heic2uhdr.swift`     | ✅    | HEIC → ISO 21496-1 gain-map JPEG                    |
| Cloudflare R2(桶 + CORS + 自定义域) | ✅    | `blog-res.satd.tech`             |
| 主站托管                           | ✅    | 静态导出 `out/`;Cloudflare Pages 自动构建(push `main`)      |
| 评论 / 分析                        | ✅    | Giscus(独立公开评论仓库,与源码仓库解耦)+ Google Analytics |
| 海外节点加速                   | ⏳ 可选 | 已实现                                                 |

---

## 技术选型(已落地)

> 原「技术选型初版」的评审结论:核心方向全部成立,落地时栈与图片管线有细化。

- **框架**:Next.js 16(App Router)+ React 19 + Tailwind v4,`output: "export"` 纯静态导出,`trailingSlash: true`(页面导出为 `slug/index.html`,便于与同目录共存资源 / nginx 美化 URL)。运行时与构建工具:**Bun**
- **内容**:MDX / Markdown 为主(gray-matter + Zod 在 `src/lib/markdown.ts` 校验,非法 frontmatter 构建期抛错);保留 rST 渲染支持(Python docutils 桥,本站未用)。
- **渲染增强**:Shiki 构建期语法高亮(双主题,`globalThis` 单例)、KaTeX 数学、Mermaid 图、GitHub Alerts、代码块工具栏、沉浸式阅读模式(书籍章节 / 系列文章)。
- **搜索**:Pagefind(运行时加载 `/pagefind/pagefind.js`)。
- **生活记录中的图片**:直接存仓库,构建期 `copy-assets.ts` 拷入 `public/`,走 `next-image-export-optimizer` 优化。
- **影集图片**:量大,存 **Cloudflare R2**,经自定义域 `blog-res.satd.tech`(R2 直连,SSL 自动签发)提供;CDN 域名是构建期单一配置项(见下文)。
- **加速**:可选接入腾讯云 EdgeOne 海外节点作为前置 CDN,**无需改构建配置**(仅 DNS/回源层);备案状态按当地要求处理。
- **基底**:基于 [hutusi/amytis](https://github.com/hutusi/amytis)。amytis 仓库无 LICENSE,本站仓库私有、个人自用合规;若日后公开需先与 upstream 确认授权。

---

## 内容模型与站点结构

导航(`site.config.ts`,按 weight 排序):生活(`/flows`) · 项目(`/posts`) · 阅读(`/series`) · 影集(`/albums`) · 关于(`/about`)。

| 内容类型 | 目录 | 路由 | 说明 |
|---|---|---|---|
| 项目 / 文章 | `content/posts/` | `/posts/[slug]` | 独立文章或文件夹文章(`index.mdx`) |
| 系列 / 阅读 | `content/series/<slug>/` | `/<series-slug>/[slug]``autoPaths`) | 按文件夹自动归属;rST 兼容 |
| 生活 / Flow | `content/flows/YYYY/MM/DD.(md|mdx)` | `/flows/[y]/[m]/[d]` | 日更短记,带日历侧栏 |
| 影集 | `content/albums/<slug>/` | `/albums/[slug]` | **本站自研内容层**(见下文) |
| 常青笔记 | `content/notes/` | `/notes/[slug]` | 不进主导航,参与反向链接 / 图谱 |
| 静态页 | `content/*.mdx` | `/[slug]` | about / links / privacy 等 |

发现入口:标签(`/tags`)、归档(`/archive`)、作者(`/authors`)、知识图谱(`/graph`)。Feed:`feed.xml`/`feed.atom`(精选)、`all.*`(全量)、`flows/feed.*``sitemap.ts``search.json`

URL 一律经 `src/lib/urls.ts``getPostUrl()` / `getSeriesCustomPaths()` / `getAlbumUrl()` 解析,不硬编码。`redirectFrom` 别名生成静态跳转页,便于改名 / 迁移路径后旧 URL 仍可达。

内容脚手架模板见仓库 `templates/`(post / flow / note / album / series-index / series-issue 等)。

---

## 技术方案细节

### 影集数据形态

三档图,均为 HDR-capable 的 gain-map JPEG(HDR 设备显示 HDR,其余回退 SDR,不破图):

| 层 | 内容 | 命名规则 | 存放 | 入 git |
|---|---|---|---|---|
| 原图 | HEIC → gain-map JPEG(q95);非 HEIC 原样拷贝(不缩放) | `<stem>-<hash>.jpg` | R2,永不改 | 否 |
| `-m` 中图 | gain-map JPEG,最大边 ≤ 2400(q90) | `<stem>-m-<hash>.jpg` | R2,永不改 | 否 |
| `-s` 缩略图 | gain-map JPEG,最大边 ≤ 800(q80) | `<stem>-s-<hash>.jpg` | R2,永不改 | 否 |
| manifest | 每图 `{ src, w, h, medium, thumb }`,路径用相对资源根的 stem | — | 仓库内 | 是 |
| 影集 MDX | frontmatter:`title / date / location / excerpt / poster / manifest / tags / featured / draft` | — | 仓库内 | 是 |

关键规则:

- 三档**各自独立 content-hash**,关联靠 manifest 的 `medium`/`thumb` 字段,不靠同名 hash。
- 处理顺序固定:**准备原图(HEIC→JPEG)→ 写 EXIF → 算 content-hash → 命名 → 上传**(hash 基于打标记后的最终字节)。
- `w/h`**显示尺寸**`image-size` 取原始像素,再读 EXIF Orientation;90°/270° 旋转(值 5/6/7/8)时宽高互换。否则瀑布流会为竖图排出横框。
- 上传后永不覆盖;换图即换 hash 文件名。`--prune` 按 manifest 清理 R2 孤儿对象。
- **中图优化**:源文件最长边已 ≤ 2400 时跳过再编码,`medium == src`(绝不放大、绝不存重复字节)。
- **已知缺口**`--prune` 只追踪 manifest 条目,不追踪 frontmatter `poster:` stem;与 manifest 条目不同的 poster 需手动清理。

EXIF 版权标记(`scripts/process-album.ts` 内置):

- `Artist` = `will lao`
- `Copyright` = `will lao (https://blog.satd.tech)`
- `XMP xmpRights:WebStatement` = `https://blog.satd.tech`

> 说明: **gain-map JPEG**——因为 sips / ImageMagick 在缩放时会丢掉 Apple HDR gain map,只有自研 `heic2uhdr.swift` 能同比例缩放 base 与 gain map,保住 HDR。三档共用同一转换器(带最大边参数),HDR 源出 HDR、无 gain map 的源出 SDR。

### HDR 图片管线(`scripts/heic2uhdr.swift`

iPhone HEIC 照片携带 Apple HDR gain map。`heic2uhdr.swift` 把它重新封装为 **ISO 21496-1 / Ultra HDR gain-map JPEG****ImageIO 解码**(base SDR 图 + gain map + tone-map 元数据),**libultrahdr 编码** ISO/MPF 容器。为什么要拆两步——ImageIO 的 writer(`CGImageDestinationAddAuxiliaryDataInfo`)只会写出 Apple 命名空间(`urn:com:apple:photo:2020:aux:hdrgainmap`)的 gain map,Safari 认、Chrome 的 Ultra HDR 解码器不认(它只认 ISO 命名空间 `urn:iso:std:iso:ts:21496:-1` + MPF 容器),所以 ISO 容器必须由 libultrahdr 编码。

- **Safari(macOS Sonoma+ / iOS 17+)和 Chrome(116+)都显示 HDR**,其余浏览器自动回退 SDR,单文件兼顾两端。
- 缩放时同比例缩放 base 图与 gain map → 中图 / 缩略图同样保 HDR。
- 依赖系统 `swift`(Xcode CLT)与 `libultrahdr``brew install libultrahdr`,提供 `ultrahdr_app`)。

### 域名与 CDN

| 域名                   | 用途              | 回源                           |
| -------------------- | --------------- | ---------------------------- |
| `blog.satd.tech`     | 主站(HTML/JS/CSS) | Cloudflare Pages             |
| `blog-res.satd.tech` | 影集图片资源          | Cloudflare R2(自定义域,SSL 自动签发) |

- `NEXT_PUBLIC_RESOURCE_BASE`(默认 `https://blog-res.satd.tech`)为**构建期唯一配置项**;manifest 与 poster 只存相对资源根的 stem,组件运行时由 `resolveAsset()` 拼接。换 CDN 域名只改这一处。单影集可在 manifest 顶层用 `resourceBase` 覆盖(含 `""` 表示走本地 `public/`)。
- 当前为 **Cloudflare 原生全球分发**(可用,大陆体验一般)。
- **可选**:接入 EdgeOne 海外节点做加速(备案状态按当地要求处理)——两域名均经海外节点;`blog-res` 回源 R2、`blog.satd.tech` 回源 Pages,回源与缓存按 CDN 常规配置。`NEXT_PUBLIC_RESOURCE_BASE` 域名不变,EdgeOne 仅前置加速,无需改构建。

### 相册与渲染

- **影集列表页**`/albums`):卡片网格,以 `poster` 为封面(`AlbumCard` 用原生 `<img>`,绕过 `next-image-export-optimizer`——poster 在 R2,构建期不该被抓取),并复用为 `og:image`
- **影集内部**`react-photo-album``ColumnsPhotoAlbum`(瀑布流)显示 `-s` 缩略图;响应式列数(宽 <480→2 列,<900→3 列,否则 4 列)。点击任意图打开 Lightbox。
- **Lightbox**`src/components/albums/Lightbox.tsx`,基于 **PhotoSwipe v5**):
  - **渐进加载**:每项把 `-s` 缩略图作为 PhotoSwipe 原生 `msrc` 占位图秒出;全分辨率图加载完成后无缝替换(无 decode 空白)。
  - **按设备选档**:打开瞬间用 `isMobile()``navigator.userAgentData.mobile` 优先,回退 UA 嗅探)采样一次——手机拉 `-m` 中图(≤2400,移动端解码/流量小 ~4×),桌面/平板拉原图。缩略图始终是占位图。
  - **缩放**`wheelToZoom: true` + `maxZoomLevel: 'full'`(滚轮 / 双击 / 双指捏合,PhotoSwipe 核心,无需插件)。
  - **导航**`arrowKeys` / `escKey` / `pinchToClose` / 触屏左右滑动(核心能力)。
  - **内存上限**`preload: [1, 1]`,最多常驻 ~3 张(1 前 + 当前 + 1 后),匹配手机解码预算。
  - PhotoSwipe Core 懒加载为独立 chunk,画廊页首屏包体保持轻量。
  - React↔PhotoSwipe 桥接有三个反馈环(`isOpenRef` / `lastIndexRef` / close handler),均加守护避免重复开/回调抖动;最新回调经 ref 始终取到最新父组件 props。
  - 设计取舍见 `docs/adr/0001-medium-tier-for-mobile-lightbox.md`(ADR 0001)。

### 影集内容层(`src/lib/albums.ts`

- **自包含**:刻意不复用 `src/lib/markdown.ts`(posts/flows/notes/books 机制)。一个影集 = `content/albums/<slug>/` 下的 `index.mdx`(frontmatter + 正文游记)+ `manifest.json`
- **Zod 校验 + 严格构建**:frontmatter 与 manifest 非法均在构建期 `throw`(与全站 strict-build 一致)。`medium` 字段必填——旧影集须重新生成,否则 Zod 解析构建期失败。
- manifest 路径相对资源根,构建期由 `resolveAsset()` + resourceBase 拼成绝对 URL;无 manifest 的影集仅渲染正文、无画廊。
- 导出 `getAllAlbums()` / `getAlbumBySlug()`,模块级缓存(同 markdown.ts 家族的开发态缓存坑:内容编辑后须重启 `bun dev`)。

### 预处理脚本契约(`scripts/process-album.ts`

```
bun scripts/process-album.ts <input-dir> <slug> [--prune] [--dry-run]
```

输入一个本地原图文件夹,步骤:

1. 准备原图:HEIC → gain-map JPEG(`heic2uhdr.swift`,q95);非 HEIC 原样拷贝。
2. 对原图写 EXIF 版权标记。
3. 算 content-hash,命名 `<stem>-<hash>.<ext>`,按需上传原件到 R2 的 `albums/<slug>/` 前缀(已存在则跳过,幂等)。
4. 生成 `-s` 缩略图(≤800,q80)→ 写 EXIF → hash → `<stem>-s-<hash>.jpg` → 上传。
5. 生成 `-m` 中图(≤2400,q90,HDR 保真)→ EXIF → hash → `<stem>-m-<hash>.jpg` → 上传。最长边已 ≤2400 则跳过(`medium == src`)。
6.`manifest.json`(每图 `src` / `w` / `h` / `thumb` / `medium`)到 `content/albums/<slug>/`;若无 `index.mdx` 则生成带 frontmatter 的桩文件待编辑。
7. `--prune`:删除该 slug 下未被 manifest 引用的 R2 孤儿对象。

环境(`.env`,不入 git):`R2_ACCOUNT_ID` / `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY`(必填);`R2_BUCKET`(默认为你的桶名)。外部工具:`exiftool``brew install exiftool`)、`swift`(Xcode CLT 自带)。`--dry-run` 跳过上传但仍跑本地转换并预览 manifest。

### 发布流程

- **文字 / MDX / manifest**`git push main` → Cloudflare Pages 自动 `bun install --frozen-lockfile && bun run build` → 部署(自定义域 `blog.satd.tech`)。生产环境变量须设 `NEXT_PUBLIC_RESOURCE_BASE=https://blog-res.satd.tech`
- **图片**:本地跑 `process-album.ts` 上传 R2(独立动作,**须先于对应 MDX 的 push**)。
- **顺序约定****先跑脚本(传图 + 写 manifest)→ 再 push MDX**

> 备选部署:框架另附 `scripts/deploy.ts`(rsync→自建 nginx,`sshpass`,自动 reload)与 `nginx.conf.example`(含 sendfile / 文件描述符缓存 / gzip / HSTS / 分层缓存 / 去 trailing slash 重定向 / TLS 加固)。静态导出设计本身与托管方无关,任意静态服务器可托管。

## 需要人工配合操作的内容,输出 handoff.md 文件。

这是一座会慢慢长大的花园。

Will lao

作者

Will lao

Coder, traveler, photographer.