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

+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 } });
}),
);
},
};
}