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:
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)
|
- boundary: 只加字体 npm 依赖与 src/styles/fonts.css 引入;不引入 cn-font-split 构建步(预分片包方案,证伪则回退并记 2.5)
|
||||||
- verify: `pnpm astro build && test $(find dist -name "*.woff2" | wc -l) -gt 50`
|
- 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
|
- depends-on: task_0001
|
||||||
- claim: lane-content(子代理,预留 src/content 与 src/lib)
|
- 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`
|
- verify: `pnpm astro build`
|
||||||
|
|
||||||
- [ ] T5 布局/导航/页脚/明暗不闪烁
|
- [ ] 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 |
@@ -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 |
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"title": "雪线",
|
||||||
|
"image": "./xue-xian.jpg",
|
||||||
|
"group": "landscape",
|
||||||
|
"alt": "山脊上的积雪线在蓝天下格外清晰"
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 是否挂载成功。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
title: 等待戈多
|
||||||
|
pubDate: 2026-03-31
|
||||||
|
category: reading
|
||||||
|
tags: [read]
|
||||||
|
mood: '#b45309'
|
||||||
|
---
|
||||||
|
|
||||||
|
戈多是全书贯穿始终的人物,可并没有任何一个人见过戈多。爱斯特拉冈和弗拉季米尔在乡间小路上等他,等到靴子脱了又穿,等到胡萝卜啃完了又换饼干,等到波卓和幸运儿来了又走,戈多始终没有来。第二天他们接着等,仿佛头一天的一切都可以原封不动地再来一遍。
|
||||||
|
|
||||||
|
「谁是戈多?为何等待?」贝克特对这两个问题一向敬谢不敏,据说他只回过一句:我要是知道,早在戏里说清楚了。于是戈多成了一个空位——有人读出上帝,有人读出希望,有人读出永远缺席的意义本身。空位越空,人们越急着往里填东西;这大概正是这出戏首演七十年后仍然刺人的原因。
|
||||||
|
|
||||||
|
> 希望迟迟不来,苦死了等的人。
|
||||||
|
|
||||||
|
——萨缪尔·贝克特《等待戈多》
|
||||||
|
|
||||||
|
就戏剧动作而言,《等待戈多》几乎什么都没有发生,而这正是它最残忍的地方:它不安排绝望的事件,只安排绝望的节奏。对话膨胀又塌缩,约定立起又被推翻,戏在「咱们走吧」与「他们不动」之间原地打转。等待本身成了全部的情节,也成了全部的刑罚。
|
||||||
|
|
||||||
|
> 爱斯特拉冈:咱们走吧。
|
||||||
|
>
|
||||||
|
> 弗拉季米尔:咱们不能。
|
||||||
|
>
|
||||||
|
> 爱斯特拉冈:为什么不能?
|
||||||
|
>
|
||||||
|
> 弗拉季米尔:咱们在等待戈多。
|
||||||
|
|
||||||
|
可我并不觉得这是全然的黑暗。两个流浪汉在无望之待里,仍然留住了几样东西:他们拌嘴,也互相系鞋带;他们想上吊,又舍不得用裤腰带;他们每天遇见,每天确认「你又来了」。无望之待没有被拔高成救赎,但陪伴让囚笼有了呼吸的缝隙——自我救赎未必是走出困境,有时只是在困境里替对方把靴子脱下来。
|
||||||
|
|
||||||
|
合上书那天傍晚,我在站台等一班迟迟不来的车,忽然想起幸运儿的思想独白:原来我们都站在各自的乡间小路上,书包里装着胡萝卜,等一个不会来的戈多。贝克特没有给我们答案,只留下一句温柔的嘲讽——他们许诺明天一切都会好起来。信不信,由等的人自己决定。
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
"@fontsource/noto-sans-sc": "^5.3.1",
|
"@fontsource/noto-sans-sc": "^5.3.1",
|
||||||
"astro": "^7.3.8",
|
"astro": "^7.3.8",
|
||||||
|
"exifr": "^7.1.3",
|
||||||
"lxgw-wenkai-screen-webfont": "^1.7.0"
|
"lxgw-wenkai-screen-webfont": "^1.7.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+8
@@ -17,6 +17,9 @@ importers:
|
|||||||
astro:
|
astro:
|
||||||
specifier: ^7.3.8
|
specifier: ^7.3.8
|
||||||
version: 7.3.8(yaml@2.9.1)
|
version: 7.3.8(yaml@2.9.1)
|
||||||
|
exifr:
|
||||||
|
specifier: ^7.1.3
|
||||||
|
version: 7.1.3
|
||||||
lxgw-wenkai-screen-webfont:
|
lxgw-wenkai-screen-webfont:
|
||||||
specifier: ^1.7.0
|
specifier: ^1.7.0
|
||||||
version: 1.7.0
|
version: 1.7.0
|
||||||
@@ -686,6 +689,9 @@ packages:
|
|||||||
eventemitter3@5.0.4:
|
eventemitter3@5.0.4:
|
||||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||||
|
|
||||||
|
exifr@7.1.3:
|
||||||
|
resolution: {integrity: sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==}
|
||||||
|
|
||||||
extend@3.0.2:
|
extend@3.0.2:
|
||||||
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
||||||
|
|
||||||
@@ -1863,6 +1869,8 @@ snapshots:
|
|||||||
|
|
||||||
eventemitter3@5.0.4: {}
|
eventemitter3@5.0.4: {}
|
||||||
|
|
||||||
|
exifr@7.1.3: {}
|
||||||
|
|
||||||
extend@3.0.2: {}
|
extend@3.0.2: {}
|
||||||
|
|
||||||
fast-string-truncated-width@3.0.3: {}
|
fast-string-truncated-width@3.0.3: {}
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -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
@@ -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 } });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user