Files
blog/src/content.config.ts
T
wait4xandGLM-5.3 859cac19a2 feat(cms): 首页四项可配 + 开源页项目可见性 + GitHub README 开篇
- 首页: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
2026-11-02 16:45:01 +08:00

181 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;