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
+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