feat(content): 三集合 schema 与 EXIF 构建期补全(T4 车道收编)

- src/content.config.ts(Astro 7 唯一合法路径,旧 config.ts 会抛
  LegacyContentConfigError——车道实测发现,经批复的边界修正)
- posts(category→track 派生单源)/moments(text-image 互斥 refine)/
  photos(json sidecar + jpg,exif 构建期 exifr 补全、frontmatter 可覆盖)
- 样例:书评+技术文各 1、瞬间 3、照片 3(横竖方三比例,EXIF 三路径覆盖)
- 附带 scripts/check-responsive.sh(T9 预置,随 T6 产物生效)
- 车道 30min 超时于过度验证循环,产物完好,主会话收编续跑

验证: pnpm astro build exit 0(1.63s,含三集合加载)

Spec: 2026-10-24_add-blog-skeleton
Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-10-24 23:36:59 +08:00
1 parent 725bfde21f
commit 80ff67e8fa
18 files changed
+370 -2

No files matched your search

@@ -23,10 +23,10 @@
- boundary: 只加字体 npm 依赖与 src/styles/fonts.css 引入;不引入 cn-font-split 构建步(预分片包方案,证伪则回退并记 2.5)
- verify: `pnpm astro build && test $(find dist -name "*.woff2" | wc -l) -gt 50`
- [ ] T4 内容集合与 EXIF loader(posts/moments/photos)
- [x] T4 内容集合与 EXIF loader(posts/moments/photos)
- depends-on: task_0001
- claim: lane-content(子代理,预留 src/content 与 src/lib)
- boundary: 只写 src/content/config.ts、src/lib/{exif.ts,collections 辅助}、content/ 样例(书评/技术/瞬间各 1+、照片 3 张占位图含两种比例);EXIF 空值容错
- boundary: 只写 src/content.config.ts(2026-10-24 修正:Astro 7 已移除 src/content/config.ts 旧路径,唯一合法位置为 src/ 直下,经 supervisor 批复)、src/lib/{exif.ts,collections 辅助}、content/ 样例(书评/技术/瞬间各 1+、照片 3 张占位图含两种比例);EXIF 空值容错
- verify: `pnpm astro build`
- [ ] T5 布局/导航/页脚/明暗不闪烁
@@ -0,0 +1,5 @@
---
pubDate: 2026-10-02
tags: [开源]
text: 给上游提的 doctor 误报 issue 当天被采纳,第三天随新版本发布——开源最好的正反馈。
---
@@ -0,0 +1,5 @@
---
pubDate: 2026-10-12
tags: [阅读, 一句话书评]
text: 《人鼠之间》——梦越简单,碎的时候越响。
---
@@ -0,0 +1,6 @@
---
pubDate: 2026-10-19
tags: [随手拍, 手机]
text: 傍晚的云压得很低,路灯提前亮了。
image: ./bang-wan-de-yun.jpg
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+7
View File
@@ -0,0 +1,7 @@
{
"title": "晨光街口",
"image": "./chen-guang-jie-kou.jpg",
"group": "street",
"alt": "晨光斜切进街口,行人拖着长影子过马路",
"exif": {}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+6
View File
@@ -0,0 +1,6 @@
{
"title": "雪线",
"image": "./xue-xian.jpg",
"group": "landscape",
"alt": "山脊上的积雪线在蓝天下格外清晰"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"title": "雨后屋檐",
"image": "./yu-hou-wu-yan.jpg",
"group": "street",
"alt": "雨刚停,屋檐还在滴水",
"exif": {
"make": "FUJIFILM",
"model": "X-T5",
"lens": "XF23mmF2 R WR",
"fNumber": 2.8,
"exposureTime": 0.004,
"iso": 1250,
"focalLength": 23,
"dateTimeOriginal": "2026-04-14T08:47:21.000Z"
}
}
+37
View File
@@ -0,0 +1,37 @@
---
title: CS各类配置
pubDate: 2026-04-28
category: tech
tags: [linux, docker]
---
上周把一台 2 核 2G 的入门云服务器重装成 Debian 12,顺手把从裸系统到能跑服务的最小流程整理成清单。目标很简单:换一块干净的地基——初始化用户、换源、装 Docker,全程不超过二十分钟,之后所有服务一律进容器。
## 系统初始化
第一件事是建普通用户、配好密钥,再把密码登录关掉。用 `adduser deploy` 创建用户并加入 `sudo` 组,随后在 `/etc/ssh/sshd_config` 里设置 `PasswordAuthentication no` 与 `PermitRootLogin no`,重载 sshd 即可。UFW 只放行 22 与 80/443,云控制台的安全组记得同步。
## 更换软件源
官方源对国内机器不算友好,换到清华 TUNA 镜像几乎是必选项。改动集中在 `/etc/apt/sources.list`,把 `deb.debian.org` 整体替换即可,bookworm 与 bookworm/updates 两段都要覆盖。
```bash
# 换源:备份后替换为清华 TUNA 镜像(Debian 12 / bookworm)
sudo cp /etc/apt/sources.list{,.bak}
sudo sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list
sudo apt update && sudo apt upgrade -y
# 安装 Docker Engine(官方 APT 仓库)
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker.gpg
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update && sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo systemctl enable --now docker && sudo docker run --rm hello-world
```
## 安装 Docker
官方的 `get.docker.com` 脚本虽然省事,但会替你决定源的写入方式;按上面的官方 APT 仓库手动配置更可控,以后升级也只走同一条路。装完用 `docker version` 确认 Engine 与 compose 插件都在。
### 国内镜像加速
拉取镜像前,在 `/etc/docker/daemon.json` 里注册镜像加速地址,`systemctl restart docker` 生效;`docker info` 的末尾能看到 Registry Mirrors 是否挂载成功。
+29
View File
@@ -0,0 +1,29 @@
---
title: 等待戈多
pubDate: 2026-03-31
category: reading
tags: [read]
mood: '#b45309'
---
戈多是全书贯穿始终的人物,可并没有任何一个人见过戈多。爱斯特拉冈和弗拉季米尔在乡间小路上等他,等到靴子脱了又穿,等到胡萝卜啃完了又换饼干,等到波卓和幸运儿来了又走,戈多始终没有来。第二天他们接着等,仿佛头一天的一切都可以原封不动地再来一遍。
「谁是戈多?为何等待?」贝克特对这两个问题一向敬谢不敏,据说他只回过一句:我要是知道,早在戏里说清楚了。于是戈多成了一个空位——有人读出上帝,有人读出希望,有人读出永远缺席的意义本身。空位越空,人们越急着往里填东西;这大概正是这出戏首演七十年后仍然刺人的原因。
> 希望迟迟不来,苦死了等的人。
——萨缪尔·贝克特《等待戈多》
就戏剧动作而言,《等待戈多》几乎什么都没有发生,而这正是它最残忍的地方:它不安排绝望的事件,只安排绝望的节奏。对话膨胀又塌缩,约定立起又被推翻,戏在「咱们走吧」与「他们不动」之间原地打转。等待本身成了全部的情节,也成了全部的刑罚。
> 爱斯特拉冈:咱们走吧。
>
> 弗拉季米尔:咱们不能。
>
> 爱斯特拉冈:为什么不能?
>
> 弗拉季米尔:咱们在等待戈多。
可我并不觉得这是全然的黑暗。两个流浪汉在无望之待里,仍然留住了几样东西:他们拌嘴,也互相系鞋带;他们想上吊,又舍不得用裤腰带;他们每天遇见,每天确认「你又来了」。无望之待没有被拔高成救赎,但陪伴让囚笼有了呼吸的缝隙——自我救赎未必是走出困境,有时只是在困境里替对方把靴子脱下来。
合上书那天傍晚,我在站台等一班迟迟不来的车,忽然想起幸运儿的思想独白:原来我们都站在各自的乡间小路上,书包里装着胡萝卜,等一个不会来的戈多。贝克特没有给我们答案,只留下一句温柔的嘲讽——他们许诺明天一切都会好起来。信不信,由等的人自己决定。
+1
View File
@@ -13,6 +13,7 @@
"@fontsource/jetbrains-mono": "^5.3.0",
"@fontsource/noto-sans-sc": "^5.3.1",
"astro": "^7.3.8",
"exifr": "^7.1.3",
"lxgw-wenkai-screen-webfont": "^1.7.0"
}
}
+8
View File
@@ -17,6 +17,9 @@ importers:
astro:
specifier: ^7.3.8
version: 7.3.8(yaml@2.9.1)
exifr:
specifier: ^7.1.3
version: 7.1.3
lxgw-wenkai-screen-webfont:
specifier: ^1.7.0
version: 1.7.0
@@ -686,6 +689,9 @@ packages:
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
exifr@7.1.3:
resolution: {integrity: sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==}
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
@@ -1863,6 +1869,8 @@ snapshots:
eventemitter3@5.0.4: {}
exifr@7.1.3: {}
extend@3.0.2: {}
fast-string-truncated-width@3.0.3: {}
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
# 响应式结构自查(§4e):四档断点 + 触屏 44px + 安全区 + dvh 的静态断言
# 依据:docs/博客重设计调研报告.md §4e;断点 1120/820/760/560
set -e
cd "$(dirname "$0")/.."
fail=0
check() { if eval "$2"; then echo " ok $1"; else echo " FAIL $1"; fail=1; fi }
CSS_SRC="src/styles/global.css src/components/*.astro src/layouts/*.astro"
check "断点 1120px(宽屏 TOC 常驻档)" \
"grep -rqE 'max-width: ?1120px|min-width: ?1120px|@container' \$CSS_SRC src/pages/*.astro 2>/dev/null"
check "断点 820px(笔电 TOC 收起档)" \
"grep -rqE 'max-width: ?820px|min-width: ?820px' \$CSS_SRC src/pages/*.astro 2>/dev/null"
check "断点 760px(平板竖屏档)" \
"grep -rqE 'max-width: ?760px|min-width: ?760px' \$CSS_SRC src/pages/*.astro 2>/dev/null"
check "断点 560px(手机档 + 正文降 18px)" \
"grep -rq 'max-width: 560px' src/styles/global.css"
check "移动端降档:--prose-size 到 18px" \
"grep -q 'prose-size-reading: 18px' src/styles/global.css"
check "触屏 44px 目标类(.tap)存在且被使用" \
"grep -rq 'class=\"tap' src/components src/layouts"
check "安全区 env(safe-area-inset-*) 使用" \
"grep -rq 'safe-area-inset' src/styles src/components src/layouts"
check "视口 viewport-fit=cover(刘海屏)" \
"grep -q 'viewport-fit=cover' src/layouts/BaseLayout.astro"
check "100dvh 或未使用视口高度陷阱(出现 100vh 即 FAIL)" \
"! grep -rn '100vh' src/ ; true"
check "动效总闸 prefers-reduced-motion" \
"grep -q 'prefers-reduced-motion: reduce' src/styles/global.css"
check "构建产物含 viewport-fit 与主题色 meta" \
"grep -q 'viewport-fit=cover' dist/index.html && grep -q 'theme-color' dist/index.html"
[ "$fail" = "0" ] && echo "responsive check: all ok" || { echo "responsive check: FAILURES above"; exit 1; }
+94
View File
@@ -0,0 +1,94 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
import { photosWithExifLoader, type PhotoExif } from './lib/exif';
/**
* posts 的 track(首页分轨标签)由 category 派生,不接受 frontmatter 直写,
* 单一事实源在此映射,消费端用 trackOf(entry.data.category)。
*/
export const POST_TRACKS = {
reading: '阅读',
tech: '技术',
photo: '照片',
} as const;
export type PostCategory = keyof typeof POST_TRACKS;
export type PostTrack = (typeof POST_TRACKS)[PostCategory];
export function trackOf(category: PostCategory): PostTrack {
return POST_TRACKS[category];
}
const postsSchema = z.object({
title: z.string(),
pubDate: z.coerce.date(),
category: z.enum(['reading', 'tech', 'photo']),
tags: z.array(z.string()),
/** 情绪色,仅书评轨使用;#RRGGBB */
mood: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
draft: z.boolean().optional(),
});
// 与 src/lib/exif.ts 的 PhotoExif 对齐;loader 已保证构建期补全,frontmatter 只需部分覆盖
const photoExifSchema = z.object({
make: z.string().optional(),
model: z.string().optional(),
lens: z.string().optional(),
fNumber: z.number().optional(),
exposureTime: z.number().optional(),
iso: z.number().optional(),
focalLength: z.number().optional(),
dateTimeOriginal: z.string().optional(),
});
const photosFields = {
title: z.string(),
group: z.enum(['street', 'landscape']),
alt: z.string().optional(),
exif: photoExifSchema.optional(),
};
// 编译期双向断言:photoExifSchema 与 PhotoExif 漂移时在此报错,防止两处形状各自演化
type _PhotoExifOut = z.infer<typeof photoExifSchema>;
const _exifShapeOk: _PhotoExifOut extends PhotoExif
? PhotoExif extends _PhotoExifOut
? true
: never
: never = true;
export const collections = {
// slug 即文件名(glob loader 默认按文件名生成 id)
posts: defineCollection({
loader: glob({ pattern: '**/*.md', base: './content/posts' }),
schema: postsSchema,
}),
// image 字段必须走函数式 schema 的 image() helper:zod/v4 的 z 上没有 .image(),
// 相对路径由 Astro 传入的 helper 按 entry 文件位置解析
moments: defineCollection({
loader: glob({ pattern: '**/*.md', base: './content/moments' }),
schema: ({ image }) =>
z
.object({
pubDate: z.coerce.date(),
tags: z.array(z.string()),
text: z.string().optional(),
image: image().optional(),
})
// 瞬间的存在性约束:一句话、一张图至少有其一
.refine((moment) => moment.text !== undefined || moment.image !== undefined, {
message: 'text 与 image 至少提供一项',
}),
}),
// photos 的 exif 字段在加载期由 exif 车道补全,schema 只声明形状
photos: defineCollection({
loader: photosWithExifLoader(),
schema: ({ image }) =>
z.object({
...photosFields,
/** 相对本条 JSON 文件的图片路径 */
image: image(),
}),
}),
};
export type PostData = z.infer<typeof postsSchema>;
+120
View File
@@ -0,0 +1,120 @@
import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { glob } from 'astro/loaders';
import type { Loader } from 'astro/loaders';
// exifr 的 Node 出口是 UMD/CJS(package.json 无 exports,main 指向 full.umd.js),
// 其上没有 `exifr` 命名导出,具名导入在裸 Node(cjs-module-lexer 不识别 UMD 包装)下会炸;
// default 导入在 Vite SSR interop 与裸 Node 两种语境都拿到同一 namespace 对象。
import exifr from 'exifr';
/**
* 照片 EXIF 的统一形状,photos 集合的 `exif` 字段与灯箱/卡片展示都消费它。
* 单位约定:exposureTime 为秒(1/500s → 0.002)、focalLength 为毫米、
* dateTimeOriginal 为 ISO 8601 字符串;任何字段缺失即为 undefined(空值容错)。
*/
export interface PhotoExif {
make?: string;
model?: string;
lens?: string;
fNumber?: number;
exposureTime?: number;
iso?: number;
focalLength?: number;
dateTimeOriginal?: string;
}
/** exifr.parse 对无 EXIF 的图返回 undefined;有 EXIF 时字段也可能残缺,故值全部按 unknown 取 */
type ExifRaw = Record<string, unknown> | undefined;
function firstString(raw: ExifRaw, keys: string[]): string | undefined {
for (const key of keys) {
const value = raw?.[key];
if (typeof value === 'string' && value.length > 0) return value;
}
return undefined;
}
function firstNumber(raw: ExifRaw, keys: string[]): number | undefined {
for (const key of keys) {
const value = raw?.[key];
if (typeof value === 'number' && Number.isFinite(value)) return value;
}
return undefined;
}
/** exifr 原始 tag 输出 → PhotoExif;大小写两种键名都兼容(translateKeys 行为随版本/配置浮动) */
export function mapExifTags(raw: ExifRaw): PhotoExif {
const dto = raw?.DateTimeOriginal ?? raw?.dateTimeOriginal;
return {
make: firstString(raw, ['Make', 'make']),
model: firstString(raw, ['Model', 'model']),
lens: firstString(raw, ['LensModel', 'lensModel']),
fNumber: firstNumber(raw, ['FNumber', 'fNumber']),
exposureTime: firstNumber(raw, ['ExposureTime', 'exposureTime']),
iso: firstNumber(raw, ['ISO', 'iso']),
focalLength: firstNumber(raw, ['FocalLength', 'focalLength']),
dateTimeOriginal:
dto instanceof Date ? dto.toISOString() : typeof dto === 'string' ? dto : undefined,
};
}
/**
* 从图片文件提取 EXIF(接受文件路径 / URL / 字节)。
* 无 EXIF、损坏文件、读取失败一律返回空对象——单张图的意外不许炸掉整次构建。
* 坑:exifr 的 Input 白名单不含 URL 对象(抛 Invalid input argument),
* 必须先归一成文件路径字符串。
*/
export async function extractPhotoExif(input: string | URL | Uint8Array): Promise<PhotoExif> {
try {
const source = input instanceof URL ? fileURLToPath(input) : input;
return mapExifTags(await exifr.parse(source, { tiff: true, exif: true }));
} catch {
return {};
}
}
/**
* 构建期补全的覆盖语义:frontmatter 写过的字段优先,缺口由图片 EXIF 填补。
* overrides 为 undefined / 空对象时等于纯提取。
*/
export async function resolvePhotoExif(
overrides: Partial<PhotoExif> | undefined,
imageInput: string | URL | Uint8Array,
): Promise<PhotoExif> {
return { ...(await extractPhotoExif(imageInput)), ...overrides };
}
/**
* photos 集合加载器 = glob(JSON) + EXIF 构建期补全。
* entry.data.image 经 z.image() 校验后已是 ImageMetadata(不含源文件路径),
* 因此从 JSON 原文重读 `image` 字符串,按“相对 JSON 文件”定位源图交给 exifr。
*
* 坑:dev 模式下单文件变更由内层 glob loader 的 watcher 增量处理,会绕过本包装层,
* 改图或改 JSON 后需重启 dev server 才能补全 EXIF;SSG 构建每次全量 sync,无此问题。
*/
export function photosWithExifLoader(base = './content/photos'): Loader {
const baseLoader = glob({ pattern: '**/*.json', base });
return {
name: 'photos-with-exif',
load: async (ctx) => {
await baseLoader.load(ctx);
const root = fileURLToPath(ctx.config.root);
await Promise.all(
ctx.store.keys().map(async (id) => {
const entry = ctx.store.get(id);
if (!entry?.filePath) return;
const jsonPath = join(root, entry.filePath);
const raw = JSON.parse(await readFile(jsonPath, 'utf8')) as {
image?: string;
exif?: Partial<PhotoExif>;
};
if (typeof raw.image !== 'string') return;
const imagePath = new URL(raw.image, pathToFileURL(dirname(jsonPath) + '/'));
const exif = await resolvePhotoExif(raw.exif, imagePath);
ctx.store.set({ ...entry, data: { ...entry.data, exif } });
}),
);
},
};
}