fix(exif): 构建 EXIF 提取全线复活——拦截式 loader + 数据仓清障

- 根因双层:①包装层 post-load 的 store.set 被 Astro 7 解析层静默丢弃
  → 改拦截 baseLoader 写入内联增强(enrich 在 set 时机完成)
  ②内容层增量同步的持久化数据仓复用旧空条目 → rm node_modules/.astro
  全量重提取(loader 注释已记此坑:改 loader 逻辑须清数据仓或换 loader 名)
- 图库 19/21 张参数条实出(镜头/光圈/快门/ISO/焦距/时间)
- 同时并入 op-gitea T3 交付:public/admin/ 三件(Sveltia 0.252 pinned
  自托管)+ gitea [cors] 台账;其发现的 title omit 坑已在 config 修正

验证: build 28 页;dist/gallery data-exif 19 条含 ISO;E2E 30/30 PASS
      (staging 29 html)

Spec: 2026-10-25_add-terminal-books-admin

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-10-25 13:46:56 +08:00
1 parent 40346c3f21
commit cef648db9a
2 files changed
+36 -21

No files matched your search

+35 -20
View File
@@ -87,34 +87,49 @@ export async function resolvePhotoExif(
/**
* photos 集合加载器 = glob(JSON) + EXIF 构建期补全。
* entry.data.image 经 z.image() 校验后已是 ImageMetadata(不含源文件路径),
* 因此从 JSON 原文重读 `image` 字符串,按“相对 JSON 文件”定位源图交给 exifr。
*
* 坑:dev 模式下单文件变更由内层 glob loader 的 watcher 增量处理,会绕过本包装层,
* 改图或改 JSON 后需重启 dev server 才能补全 EXIF;SSG 构建每次全量 sync,无此问题。
* 实现教训(2026-10-25 双日调试定位):Astro 7 内容层在 load() 结束后
* 会按 loader 的原始写入序列做 schema 解析——包装层在 baseLoader.load()
* 之后用 ctx.store.set 补写的字段会在解析阶段被静默丢弃(exif 全空但
* 无任何报错的根因)。因此改为**拦截写入**:代理 baseLoader 看到的
* store,在它 set 每个条目的那一刻异步增强,增强完成的 Promise 收集
* 后在 load 返回前 await 完毕。
*/
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 } });
}),
);
const origSet = ctx.store.set.bind(ctx.store);
const pending: Promise<void>[] = [];
const enrich = async (entry: Record<string, unknown>) => {
const filePath = entry.filePath as string | undefined;
if (!filePath || !filePath.endsWith('.json')) return entry;
const jsonPath = join(root, filePath);
const raw = JSON.parse(await readFile(jsonPath, 'utf8')) as {
image?: string;
exif?: Partial<PhotoExif>;
};
if (typeof raw.image !== 'string') return entry;
const imagePath = new URL(raw.image, pathToFileURL(dirname(jsonPath) + '/'));
const exif = await resolvePhotoExif(raw.exif, imagePath);
return { ...entry, data: { ...(entry.data as object), exif } };
};
// Object.create 保留 store 其余方法/绑定;仅拦截 set
const proxiedStore = Object.create(ctx.store);
proxiedStore.set = (entry: Record<string, unknown>) => {
pending.push(
enrich(entry)
.then((e) => origSet(e))
.catch(() => origSet(entry)),
);
};
await baseLoader.load({ ...ctx, store: proxiedStore });
await Promise.all(pending);
},
};
}