From 80ff67e8faef3355d1454dcb1c30baac6a22bf48 Mon Sep 17 00:00:00 2001 From: wait4xx Date: Sat, 24 Oct 2026 23:36:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(content):=20=E4=B8=89=E9=9B=86=E5=90=88=20?= =?UTF-8?q?schema=20=E4=B8=8E=20EXIF=20=E6=9E=84=E5=BB=BA=E6=9C=9F?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=EF=BC=88T4=20=E8=BD=A6=E9=81=93=E6=94=B6?= =?UTF-8?q?=E7=BC=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../2026-10-24_add-blog-skeleton/tasks.md | 4 +- content/moments/2026-10-02-doctor-issue.md | 5 + .../moments/2026-10-12-ren-shu-zhi-jian.md | 5 + content/moments/2026-10-19-bang-wan-de-yun.md | 6 + content/moments/bang-wan-de-yun.jpg | Bin 0 -> 3217 bytes content/photos/chen-guang-jie-kou.jpg | Bin 0 -> 5640 bytes content/photos/chen-guang-jie-kou.json | 7 + content/photos/xue-xian.jpg | Bin 0 -> 6013 bytes content/photos/xue-xian.json | 6 + content/photos/yu-hou-wu-yan.jpg | Bin 0 -> 4201 bytes content/photos/yu-hou-wu-yan.json | 16 +++ content/posts/cs-ge-lei-pei-zhi.md | 37 ++++++ content/posts/deng-dai-ge-duo.md | 29 +++++ package.json | 1 + pnpm-lock.yaml | 8 ++ scripts/check-responsive.sh | 34 +++++ src/content.config.ts | 94 ++++++++++++++ src/lib/exif.ts | 120 ++++++++++++++++++ 18 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 content/moments/2026-10-02-doctor-issue.md create mode 100644 content/moments/2026-10-12-ren-shu-zhi-jian.md create mode 100644 content/moments/2026-10-19-bang-wan-de-yun.md create mode 100644 content/moments/bang-wan-de-yun.jpg create mode 100644 content/photos/chen-guang-jie-kou.jpg create mode 100644 content/photos/chen-guang-jie-kou.json create mode 100644 content/photos/xue-xian.jpg create mode 100644 content/photos/xue-xian.json create mode 100644 content/photos/yu-hou-wu-yan.jpg create mode 100644 content/photos/yu-hou-wu-yan.json create mode 100644 content/posts/cs-ge-lei-pei-zhi.md create mode 100644 content/posts/deng-dai-ge-duo.md create mode 100644 scripts/check-responsive.sh create mode 100644 src/content.config.ts create mode 100644 src/lib/exif.ts 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 0000000000000000000000000000000000000000..a4255641e52f43d2a429596648134e3cd65b8df3 GIT binary patch literal 3217 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..93c497e7fcf6efee3cab206e3f9759fd730d4281 GIT binary patch literal 6013 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d924dc523954fee775fb4734eda795bf8a967336 GIT binary patch literal 4201 zcmb_eO>fjl5bgHZp2oun8%;uH0+HoKXhbGxLCJaTC5Z&5AR#2~9QYa5>>i@r_8*Km z!zI6f0|$;dvdd-v0KZ{))m?43lgYp^&WDn+yQg2jdiAR2U*`Xd`1$d(VhD{z=>cR08XK z$!>@jgShz=3-EM{RyWs6&ktyQa&`TG;$}zUtWF~S5Z_LA{`$KRx%5T+LijQM^?L{#2x;7o}n7OtKP4V;kO~`A@619^C|Y`o z-!rA&DUPubpVOD}H9!RQNx;jf=vZL`$?$ohG>8xJ^vL&L_#ES^`Uc^2x|^o&%*h6# zMMcLd8!&teiLX}Tdb9Ejx2vwIFY9%3|7FWa4Y%H`MNakQkdBL!Bcz%r1S$BBI5K>4 zVBe8CY6ZktK8TY;trpq}wK!YlZ1zx5vcU~8362ryY?{>aJ8nydF&&*wK3zf*Fq;qktHf~=(AM+h7VgXhT9fYBum?Z>!vKfb)XW#`|;76CQOpWYE>nz^mXgitzr~;=T+i~ zArSmiuIg4*-KI6ckWuGP)L`i$1X4`-DHwrc%~WD6-# zNVei>oscgKLGF^NYKd355Vez4<~@qU=jBt?%18lRU~YVKY5KQBF>g*+T~Pnpto86? z%-0wqgsUo7sQJ;pN#Sgf{j&>@K#_Hx7!cTgs4zByAAJsuwpT|gx1}!6J-x+8QJ({` zQ(YDHp*p1q>oLWGa-7Pf+$sS1X9dvxXdW*tk9-bK<942M`l-R;T#4G4X)L5(6A<9A z1SVU=z{i6)4}JuwIcDkiR}~6`SZb~Uem?~Mur3TbnCfI5oYacLQa!nhXAM4au?UFa W1>qqV@*4VNPY^K;;^?{af4>87`s 希望迟迟不来,苦死了等的人。 + +——萨缪尔·贝克特《等待戈多》 + +就戏剧动作而言,《等待戈多》几乎什么都没有发生,而这正是它最残忍的地方:它不安排绝望的事件,只安排绝望的节奏。对话膨胀又塌缩,约定立起又被推翻,戏在「咱们走吧」与「他们不动」之间原地打转。等待本身成了全部的情节,也成了全部的刑罚。 + +> 爱斯特拉冈:咱们走吧。 +> +> 弗拉季米尔:咱们不能。 +> +> 爱斯特拉冈:为什么不能? +> +> 弗拉季米尔:咱们在等待戈多。 + +可我并不觉得这是全然的黑暗。两个流浪汉在无望之待里,仍然留住了几样东西:他们拌嘴,也互相系鞋带;他们想上吊,又舍不得用裤腰带;他们每天遇见,每天确认「你又来了」。无望之待没有被拔高成救赎,但陪伴让囚笼有了呼吸的缝隙——自我救赎未必是走出困境,有时只是在困境里替对方把靴子脱下来。 + +合上书那天傍晚,我在站台等一班迟迟不来的车,忽然想起幸运儿的思想独白:原来我们都站在各自的乡间小路上,书包里装着胡萝卜,等一个不会来的戈多。贝克特没有给我们答案,只留下一句温柔的嘲讽——他们许诺明天一切都会好起来。信不信,由等的人自己决定。 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 } }); + }), + ); + }, + }; +}