置顶(T1):
- src/lib/post-order.ts 新增 sortPosts:置顶优先 → pubDate 倒序 → id 兜底,
把首页双轨/归档/书架四处排序口径收敛到一处
- postsSchema 增 pinned;PostList 三处渲染路径加灰阶 pin 小徽标;后台置顶开关
- 语义边界:只影响列表顺序,不改 pubDate/URL/RSS 时间语义
计数徽标(T4 契约组件):
- src/components/geek/ViewMeta.astro:眼睛 = 浏览数、气泡 = 评论数,
本地 SVG + tokens 着色,不引第三方图片
- 数据只用 Artalk 匿名端点:GET comments?limit=1 一次拿到 count 与 page.pv;
page 模式先 POST pages/pv 计一次浏览,readonly 模式零写入
- SSR 占位「–」:无 JS/接口失败不显示 0 误导读者
验证: pnpm astro build 48 页 exit 0;config-check 208 字段/65 hint 全过;
sortPosts 四调用点替换后构建通过
Spec: 2026-11-02_blog-5h-content-social (T1 契约)
Co-authored-by: GLM-5.3
338 lines
13 KiB
TypeScript
338 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(),
|
||
/** 置顶:列表排序优先(只影响顺序,不改 pubDate/URL/RSS 时间语义) */
|
||
pinned: z.boolean().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>;
|