diff --git a/.spec/specs/2026-10-24_add-blog-skeleton/tasks.md b/.spec/specs/2026-10-24_add-blog-skeleton/tasks.md index 5019867..17348ef 100644 --- a/.spec/specs/2026-10-24_add-blog-skeleton/tasks.md +++ b/.spec/specs/2026-10-24_add-blog-skeleton/tasks.md @@ -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 布局/导航/页脚/明暗不闪烁 diff --git a/content/moments/2026-10-02-doctor-issue.md b/content/moments/2026-10-02-doctor-issue.md new file mode 100644 index 0000000..cb67573 --- /dev/null +++ b/content/moments/2026-10-02-doctor-issue.md @@ -0,0 +1,5 @@ +--- +pubDate: 2026-10-02 +tags: [开源] +text: 给上游提的 doctor 误报 issue 当天被采纳,第三天随新版本发布——开源最好的正反馈。 +--- diff --git a/content/moments/2026-10-12-ren-shu-zhi-jian.md b/content/moments/2026-10-12-ren-shu-zhi-jian.md new file mode 100644 index 0000000..d222057 --- /dev/null +++ b/content/moments/2026-10-12-ren-shu-zhi-jian.md @@ -0,0 +1,5 @@ +--- +pubDate: 2026-10-12 +tags: [阅读, 一句话书评] +text: 《人鼠之间》——梦越简单,碎的时候越响。 +--- diff --git a/content/moments/2026-10-19-bang-wan-de-yun.md b/content/moments/2026-10-19-bang-wan-de-yun.md new file mode 100644 index 0000000..363c40b --- /dev/null +++ b/content/moments/2026-10-19-bang-wan-de-yun.md @@ -0,0 +1,6 @@ +--- +pubDate: 2026-10-19 +tags: [随手拍, 手机] +text: 傍晚的云压得很低,路灯提前亮了。 +image: ./bang-wan-de-yun.jpg +--- diff --git a/content/moments/bang-wan-de-yun.jpg b/content/moments/bang-wan-de-yun.jpg new file mode 100644 index 0000000..a425564 Binary files /dev/null and b/content/moments/bang-wan-de-yun.jpg differ diff --git a/content/photos/chen-guang-jie-kou.jpg b/content/photos/chen-guang-jie-kou.jpg new file mode 100644 index 0000000..a80d40e Binary files /dev/null and b/content/photos/chen-guang-jie-kou.jpg differ diff --git a/content/photos/chen-guang-jie-kou.json b/content/photos/chen-guang-jie-kou.json new file mode 100644 index 0000000..c2cb027 --- /dev/null +++ b/content/photos/chen-guang-jie-kou.json @@ -0,0 +1,7 @@ +{ + "title": "晨光街口", + "image": "./chen-guang-jie-kou.jpg", + "group": "street", + "alt": "晨光斜切进街口,行人拖着长影子过马路", + "exif": {} +} diff --git a/content/photos/xue-xian.jpg b/content/photos/xue-xian.jpg new file mode 100644 index 0000000..93c497e Binary files /dev/null and b/content/photos/xue-xian.jpg differ diff --git a/content/photos/xue-xian.json b/content/photos/xue-xian.json new file mode 100644 index 0000000..c8a6b10 --- /dev/null +++ b/content/photos/xue-xian.json @@ -0,0 +1,6 @@ +{ + "title": "雪线", + "image": "./xue-xian.jpg", + "group": "landscape", + "alt": "山脊上的积雪线在蓝天下格外清晰" +} diff --git a/content/photos/yu-hou-wu-yan.jpg b/content/photos/yu-hou-wu-yan.jpg new file mode 100644 index 0000000..d924dc5 Binary files /dev/null and b/content/photos/yu-hou-wu-yan.jpg differ diff --git a/content/photos/yu-hou-wu-yan.json b/content/photos/yu-hou-wu-yan.json new file mode 100644 index 0000000..bead62f --- /dev/null +++ b/content/photos/yu-hou-wu-yan.json @@ -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" + } +} diff --git a/content/posts/cs-ge-lei-pei-zhi.md b/content/posts/cs-ge-lei-pei-zhi.md new file mode 100644 index 0000000..db351a2 --- /dev/null +++ b/content/posts/cs-ge-lei-pei-zhi.md @@ -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 是否挂载成功。 diff --git a/content/posts/deng-dai-ge-duo.md b/content/posts/deng-dai-ge-duo.md new file mode 100644 index 0000000..2a42f7a --- /dev/null +++ b/content/posts/deng-dai-ge-duo.md @@ -0,0 +1,29 @@ +--- +title: 等待戈多 +pubDate: 2026-03-31 +category: reading +tags: [read] +mood: '#b45309' +--- + +戈多是全书贯穿始终的人物,可并没有任何一个人见过戈多。爱斯特拉冈和弗拉季米尔在乡间小路上等他,等到靴子脱了又穿,等到胡萝卜啃完了又换饼干,等到波卓和幸运儿来了又走,戈多始终没有来。第二天他们接着等,仿佛头一天的一切都可以原封不动地再来一遍。 + +「谁是戈多?为何等待?」贝克特对这两个问题一向敬谢不敏,据说他只回过一句:我要是知道,早在戏里说清楚了。于是戈多成了一个空位——有人读出上帝,有人读出希望,有人读出永远缺席的意义本身。空位越空,人们越急着往里填东西;这大概正是这出戏首演七十年后仍然刺人的原因。 + +> 希望迟迟不来,苦死了等的人。 + +——萨缪尔·贝克特《等待戈多》 + +就戏剧动作而言,《等待戈多》几乎什么都没有发生,而这正是它最残忍的地方:它不安排绝望的事件,只安排绝望的节奏。对话膨胀又塌缩,约定立起又被推翻,戏在「咱们走吧」与「他们不动」之间原地打转。等待本身成了全部的情节,也成了全部的刑罚。 + +> 爱斯特拉冈:咱们走吧。 +> +> 弗拉季米尔:咱们不能。 +> +> 爱斯特拉冈:为什么不能? +> +> 弗拉季米尔:咱们在等待戈多。 + +可我并不觉得这是全然的黑暗。两个流浪汉在无望之待里,仍然留住了几样东西:他们拌嘴,也互相系鞋带;他们想上吊,又舍不得用裤腰带;他们每天遇见,每天确认「你又来了」。无望之待没有被拔高成救赎,但陪伴让囚笼有了呼吸的缝隙——自我救赎未必是走出困境,有时只是在困境里替对方把靴子脱下来。 + +合上书那天傍晚,我在站台等一班迟迟不来的车,忽然想起幸运儿的思想独白:原来我们都站在各自的乡间小路上,书包里装着胡萝卜,等一个不会来的戈多。贝克特没有给我们答案,只留下一句温柔的嘲讽——他们许诺明天一切都会好起来。信不信,由等的人自己决定。 diff --git a/package.json b/package.json index 705bd0f..9c4efd9 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 064927b..19b4c18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/scripts/check-responsive.sh b/scripts/check-responsive.sh new file mode 100644 index 0000000..12ade6c --- /dev/null +++ b/scripts/check-responsive.sh @@ -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; } diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 0000000..1fd7f97 --- /dev/null +++ b/src/content.config.ts @@ -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; +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; diff --git a/src/lib/exif.ts b/src/lib/exif.ts new file mode 100644 index 0000000..a3a107e --- /dev/null +++ b/src/lib/exif.ts @@ -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 | 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 { + 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 | undefined, + imageInput: string | URL | Uint8Array, +): Promise { + 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; + }; + 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 } }); + }), + ); + }, + }; +}