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]; /** 双语文案字段:两语言各自可选,缺一回落一(回退逻辑在 src/lib/site-content.ts) */ const bilingual = z.object({ zh: z.string().optional(), en: z.string().optional() }); /** 块型清单(5g 页面构建器 v1 九类):访问器据此判未知 type */ export const BLOCK_TYPES = [ 'prose', 'tracks', 'cards', 'timeline', 'quote', 'figure', 'linklist', 'cta', 'divider', ] as const; type ImageHelper = (opts?: unknown) => z.ZodTypeAny; /** 关于页块型 union:图片字段强制 image()(外链字符串在 schema 层即失败,永不渲染) */ const aboutBlockUnion = (image: ImageHelper) => z.discriminatedUnion('type', [ z.object({ type: z.literal('prose'), enabled: z.boolean().optional(), title: bilingual.optional(), body: bilingual, width: z.enum(['narrow', 'full']).optional(), }), z.object({ type: z.literal('tracks'), enabled: z.boolean().optional(), title: bilingual, items: z.array( z.object({ icon: z.string().optional(), name: bilingual, desc: bilingual.optional() }), ), }), z.object({ type: z.literal('cards'), enabled: z.boolean().optional(), title: bilingual.optional(), columns: z.union([z.literal(2), z.literal(3)]).optional(), items: z.array( z.object({ title: bilingual, desc: bilingual.optional(), href: z.string().optional(), tag: bilingual.optional(), }), ), }), z.object({ type: z.literal('timeline'), enabled: z.boolean().optional(), title: bilingual.optional(), items: z.array(z.object({ time: z.string(), title: bilingual, desc: bilingual.optional() })), }), z.object({ type: z.literal('quote'), enabled: z.boolean().optional(), text: bilingual, cite: bilingual.optional(), }), z.object({ type: z.literal('figure'), enabled: z.boolean().optional(), image: image(), alt: z.string().optional(), caption: bilingual.optional(), }), z.object({ type: z.literal('linklist'), enabled: z.boolean().optional(), title: bilingual.optional(), items: z.array(z.object({ label: bilingual, href: z.string(), note: bilingual.optional() })), }), z.object({ type: z.literal('cta'), enabled: z.boolean().optional(), text: bilingual, href: z.string(), label: bilingual, }), z.object({ type: z.literal('divider'), enabled: z.boolean().optional(), style: z.enum(['line', 'space']).optional(), }), ]); 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(), /** 一句话策展评语(/books 书架卡;缺省回退正文首段) */ description: z.string().optional(), /** 原书作者(书架卡署名) */ bookAuthor: z.string().optional(), /** 文章附视频(单文件):存 public/media,frontmatter 写 /media/.mp4 */ video: z.string().optional(), /** 视频封面(可选):同 public/media 下的图片 URL */ poster: z.string().optional(), /** 置顶:列表排序优先(只影响顺序,不改 pubDate/URL/RSS 时间语义) */ pinned: z.boolean().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']), // 人为描述:展示在灯箱 caption 层的策展文案(区别于 alt 的无障碍用途) caption: z.string().optional(), 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 = { // 站点文案单例(后台「页面文案」可改):一文件一集合 // 用 glob(pattern 精确到单文件) 而非 file():Astro 的 file() 把 YAML 顶层对象当“条目映射” // (每个顶层键各自成一条 entry,见 astro/dist/content/loaders/file.js),根级字段会被拆碎; // glob 每文件一条 entry(id=文件名)、data=文件根对象,正是单例语义且 Sveltia 可直接编根字段 // 双语文案用 {zh,en} 对象字段(各自可选):任一语言空/缺字段 → src/lib/site-content.ts 回落默认 siteHome: defineCollection({ loader: glob({ pattern: 'home.yaml', base: './content/site' }), // avatar 走 image():yaml 写 ./assets/xxx.jpg(相对 yaml 文件),构建期入 Astro 图片管线 schema: ({ image }) => z.object({ tagline: bilingual.optional(), avatar: image().optional(), /** 页面级排版覆盖(层 2):正文默认字体类 / 强调色类 / 字号缩放;不配置即全站默认 */ typography: z .object({ font: z.string().optional(), accent: z.string().optional(), scale: z.number().optional(), }) .optional(), /** 站点图标:public 下的路径(.svg/.ico 直出,不经图片管线);缺省回退 /favicon.svg */ favicon: z.string().optional(), /** 副标题(markdown 短文本,渲染在身份语下);空 = 不渲染 */ subtitle: bilingual.optional(), /** 最新影像张数:0 隐藏区块,1-6 有效(越界由访问器夹取) */ latestPhotos: z.number().int().optional(), /** 首页每轨条数:4-8(越界由访问器夹取,缺省 6) */ perTrackLimit: z.number().int().optional(), /** 首页项目墙条数:1-99(缺省 6;越界夹取) */ projectsLimit: z.number().int().optional(), /** 首页公告(markdown 富文本,走富文本管线);空 = 不渲染 */ announcement: bilingual.optional(), /** 首页轨细分开关:分开关阅读/技术/照片三轨(缺省全开) */ postsTracks: z .object({ reading: z.boolean().optional(), tech: z.boolean().optional(), photo: z.boolean().optional(), }) .optional(), /** 区块开关:缺省全开 */ sections: z .object({ tracks: z.boolean().optional(), photos: z.boolean().optional(), heatmap: z.boolean().optional(), projects: z.boolean().optional(), }) .optional(), /** 社交链接,顺序即展示顺序;icon 为内置图标集键(github/rss/email/...) */ socials: z .array( z.object({ label: z.string(), url: z.string(), icon: z.string().optional(), }), ) .optional(), }), }), // 主题旋钮(样式即数据 · 层 4):只放 9 组白名单旋钮,钳制与单调性在 src/lib/theme.ts siteTheme: defineCollection({ loader: glob({ pattern: 'theme.yaml', base: './content/site' }), schema: z.object({ accent_light: z.string().optional(), accent_dark: z.string().optional(), radius_img: z.number().optional(), text_h1: z.number().optional(), text_h2: z.number().optional(), text_h3: z.number().optional(), prose_size_reading: z.number().optional(), prose_size_tech: z.number().optional(), leading_reading: z.number().optional(), leading_tech: z.number().optional(), space_3: z.number().optional(), space_4: z.number().optional(), space_5: z.number().optional(), space_6: z.number().optional(), flow_p: z.number().optional(), flow_h2_post: z.number().optional(), dur: z.number().optional(), }), }), siteAbout: defineCollection({ loader: glob({ pattern: 'about.yaml', base: './content/site' }), // intro = 多段 markdown 正文(二级回退:blocks 为空时才用) // blocks = 块式页面构建器(5g):文本一律 {zh,en},图片字段必须 image();未知 type 落宽松分支 // → src/lib/page-blocks.ts warn + skip,保证后台填错块型不炸构建 schema: ({ image }) => z.object({ intro: bilingual.optional(), /** 页面级排版覆盖(层 2,T3 车道):正文默认字体类 / 强调色类 / 字号缩放 */ typography: z .object({ font: z.string().optional(), accent: z.string().optional(), scale: z.number().optional(), }) .optional(), /** 版式:是否渲染名片区(头像/名字/社交),默认 true */ layout: z.object({ showIdCard: z.boolean().optional() }).optional(), blocks: z .array(z.union([aboutBlockUnion(image), z.looseObject({ type: z.string() })])) .optional(), }), }), siteOpenSource: defineCollection({ loader: glob({ pattern: 'opensource.yaml', base: './content/site' }), schema: z.object({ blurb: bilingual.optional(), blurb_after: bilingual.optional(), /** 页面级排版覆盖(层 2):正文默认字体类 / 强调色类 / 字号缩放;不配置即全站默认 */ typography: z .object({ font: z.string().optional(), accent: z.string().optional(), scale: z.number().optional(), }) .optional(), repo_url: z.string().optional(), repo_label: z.string().optional(), /** 置顶仓短名,顺序即展示顺序(原 projects.ts 硬编码名单迁于此) */ pinned: z.array(z.string()).optional(), /** 项目墙可见性:all=全部 / only=仅 include / exclude=排除 exclude */ mode: z.enum(['all', 'only', 'exclude']).optional(), include: z.array(z.string()).optional(), exclude: z.array(z.string()).optional(), /** 排序:pinned-first(默认)/ stars / updated */ sort: z.enum(['pinned-first', 'stars', 'updated']).optional(), /** 来源过滤:both(默认)/ github / gitea */ perSource: z.enum(['both', 'github', 'gitea']).optional(), /** 开篇文案来源:off(默认,用本文件 blurb)/ github-readme(构建期取 profile 仓 README) */ intro_from: z.enum(['off', 'github-readme']).optional(), }), }), // slug 即文件名(glob loader 默认按文件名生成 id) posts: defineCollection({ loader: glob({ pattern: '**/*.md', base: './content/posts' }), // cover:书封图(仅阅读轨消费;放 content/posts/assets/books/.jpg, // frontmatter 写相对路径 ./assets/books/.jpg,走 Astro 图片优化管线) schema: ({ image }) => postsSchema.extend({ cover: image().optional() }), }), postsEn: defineCollection({ loader: glob({ pattern: '**/*.md', base: './content/posts-en' }), schema: postsSchema.extend({ locked: z.boolean().optional() }), }), // 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;