- 层 1 所有页面文案改富文本:site-content 新增 renderRich(md,{inline})
复用既有全管线(raw→内容净化→class 白名单→链接卡片);接入
home.tagline/subtitle、opensource.blurb、about.intro、blocks prose;
新增 .rich 容器作用域并与 .prose-body 合并(77 处选择器覆盖四类容器)
坑:renderRich 输出 <p> 塞进 <p class="tagline"> 会被浏览器拆散 →
行内场景用 {inline:true} 剥外层 <p>
- 层 2 页面级排版覆盖:三单例加 typography{font?,accent?,scale?},读取器
pageTypography() 走白名单(10 字族 / 18 色类,scale 夹 0.8–1.25,清单外
忽略);页面容器挂 .page-typo + CSS 变量 --page-font/--page-accent/
--page-scale,不动全站 tokens;后台三页加「页面排版覆盖」下拉
- 层 3 艺术字体:新增 font-art-kuaile(快乐体专用类,字距微调),白名单与
可视化面板同步;站酷高端黑/阿里妈妈东方大楷/演示悠然小楷因无 npm 包或
嵌入条款风险进候补清单
- 默认值清理:about.yaml 的 typography 测试值(整页汇文)清为注释示例——
整页换字族会带来 694KB 字体成本,默认留空=全站默认排版
验证: 构建 48 页 exit 0;E2E 37/37;三页 page-typo 挂载与变量实测、四页
(图库/书架/文章/装备)隔离为 0、font-art-kuaile 生效、about 清空后
无覆盖
Spec: none (排版三层,先于 5g 的任务;T3/T4 另行交付)
Co-authored-by: GLM-5.3
324 lines
12 KiB
TypeScript
324 lines
12 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(),
|
||
/** 区块开关:缺省全开 */
|
||
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>;
|