Files
blog/src/content.config.ts
T
wait4xandGLM-5.3 6538311387 feat(content): 置顶契约与计数徽标组件(5h T1 契约部分)
置顶(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
2026-11-02 20:02:08 +08:00

338 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(),
/** 置顶:列表排序优先(只影响顺序,不改 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>;