【T3 样式即数据】
- content/site/theme.yaml 九组白名单旋钮(accent 明暗两值 / radius_img /
text_h1-h3 / prose_size_reading|tech / leading_reading|tech /
space_3-6 / flow_p 与 flow_h2_post / dur);--measure、断点、纸墨、
code 配色按方案 §3.6 红线不开放
- src/lib/theme.ts:getTheme() 钳制 + 单调性校验(h1>h2>h3、
space3<4<5<6 任一不满足整组回落默认);themeVarsCss() 产出
`:root:root{…}` 与 `[data-theme="dark"]:root:root{…}`,靠特异性压过
tokens 的 :root,不依赖注入顺序
- BaseLayout head 内联 `<style is:inline set:html={themeStyle}>`:构建期
静态、运行时零 JS 零请求
- scripts/build-perf/contrast-check.mjs 读产物断言正文/强调 × 亮暗四项
≥4.5:1,不达标 exit 1;build.sh 守卫挂载([ -f ] 守卫先行)
实测:默认 12.40/10.53/6.30/7.06 全过;负例 accent #cccccc → 1.61:1 exit 1
两坑入档:tokens 的 --bg 是 3 位简写需归一化;选 tokens CSS 要用
`--bg:#` 定义断言(includes('--bg') 会误命中 var(--bg-soft) 引用)
- 后台 theme 单例 17 字段全中文 hint + 取值范围(分组约束写进 hint)
【T4 首页去重与细化】
- 新增 src/components/pages/HomePage.astro 承接全部区块(名片区富文本
tagline/subtitle/社交、公告、文章列表、最新影像、热力图、项目墙、
心跳),locale 一统;en 轨吃 postsEn 译题映射、图库链走 /en/gallery/,
心跳仍仅 zh(零回归)
- 两 index.astro 缩成 4 行薄壳,双份漂移隐患消除
- home.yaml 三字段:projectsLimit(1-99 夹取,缺省 6)、announcement
(双语 markdown 走 renderRich,空则不渲染)、postsTracks(三轨开关,
在 HomePage 内过滤而非改 PostList,守边界)
验证: 构建 48 页 exit 0;contrast-check 四项 PASS;E2E 37/37(快照口径);
实测 theme 变量内联、公告 markdown 粗体渲染、projectsLimit 限流、
photo 轨关闭生效;验收数据已还原
Spec: 2026-11-02_blog-5g-pages (T3/T4)
Co-authored-by: GLM-5.3
336 lines
13 KiB
TypeScript
336 lines
13 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() });
|
||
|
||
/** 块型清单(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/<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(),
|
||
/** 页面级排版覆盖(层 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/<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>;
|