Files
blog/src/content.config.ts
T
wait4xandGLM-5.3 8debd99e61 feat(theme-home): 样式即数据 + 首页抽组件去重(5g T3/T4)
【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
2026-11-02 18:58:11 +08:00

336 lines
13 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() });
/** 块型清单(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>;