- 首页:subtitle(markdown 短文本)|latestPhotos 0-6(0 隐藏)|
perTrackLimit 4-8|sections{tracks,photos,heatmap,projects} 开关,
访问器 homeConfig() 夹取越界值,字段级回落默认(零配置=原形态)
- 开源页项目可见性:mode all|only|exclude + include[]/exclude[] +
sort pinned-first|stars|updated + perSource both|github|gitea;
projects.ts 加 LoadProjectsOptions + arrange()(来源→可见性→排序)
- 开源页开篇支持 GitHub README:intro_from off|github-readme,构建期
取 wait4xx/wait4xx README(token 复用宿主 0600 文件)→ 缓存
content/.github-readme.md 入仓 → renderMarkdown;失败逐级回落
(缓存 → yaml blurb)。实测取到 3117 字符,现状仍 off 待用户开启
- 修既有 bug:orderPinned 内硬 slice(0,6) 把开源页 limit=99 也截到 6 条
→ 移除,开源页恢复全量 16 条
- 文档:写作指南加首页/项目墙两张字段表(含 YAML 1.1 `off` 需加引号坑、
README 行为与外部徽章风险说明)
车道:op-gitea(3/3);收编复验:构建 48 页 + E2E 37/37 + 开源页 16 条 +
首页默认形态(2 轨/热力图/项目墙)无回归
验证: node v26.13.0 构建 48 页 exit 0;E2E 37/37 PASS;playwright 实测
Spec: none (首页与开源页后台扩展,车道级交付)
Co-authored-by: GLM-5.3
181 lines
7.7 KiB
TypeScript
181 lines
7.7 KiB
TypeScript
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() });
|
||
|
||
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/<name>.mp4 */
|
||
video: z.string().optional(),
|
||
/** 视频封面(可选):同 public/media 下的图片 URL */
|
||
poster: z.string().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<typeof photoExifSchema>;
|
||
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(),
|
||
/** 站点图标: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(),
|
||
/** 区块开关:缺省全开 */
|
||
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(),
|
||
}),
|
||
}),
|
||
siteAbout: defineCollection({
|
||
loader: glob({ pattern: 'about.yaml', base: './content/site' }),
|
||
// intro = 多段 markdown 正文(页面用 createMarkdownProcessor 渲染),字段级空值回落默认
|
||
schema: z.object({ intro: bilingual.optional() }),
|
||
}),
|
||
siteOpenSource: defineCollection({
|
||
loader: glob({ pattern: 'opensource.yaml', base: './content/site' }),
|
||
schema: z.object({
|
||
blurb: bilingual.optional(),
|
||
blurb_after: bilingual.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/<slug>.jpg,
|
||
// frontmatter 写相对路径 ./assets/books/<slug>.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<typeof postsSchema>;
|