Files
blog/src/lib/site-content.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

408 lines
18 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.
// 站点文案单例访问层(cms 车道 A):后台改 content/site/*.yaml → 页面读此处
// 兜底纪律:文件缺 → 用默认文案;字段缺/空串 → 用默认文案(构建绝不因文案炸)
// 注意:整文件被删会先在 file() loader 层报错(误删级),此处守的是字段级缺失/空值
import { getEntry } from 'astro:content';
import type { ImageMetadata } from 'astro';
import type { Locale } from './locale';
export interface Bi {
zh: string;
en: string;
}
/** 与仓库现状逐字一致的默认文案:yaml 全空时产物不变(零回归可视验证锚点) */
export const SITE_DEFAULTS = {
homeTagline: {
zh: '写作者 · 读者 · 快门按压者,拥抱开源。',
en: 'Writer · Reader · Shutter-presser — embracing open source.',
},
aboutIntro: {
zh: '写作者 · 读者 · 快门按压者,拥抱开源。这里是我三条轨道的公开工作台:读过的书留下评注,写过的代码进项目墙,按下的快门收入图库。',
en: 'Writer · Reader · Shutter-presser, embracing open source. This is the public workbench of my three tracks — books that left notes, code that reaches the project wall, and shutters I pressed.',
},
opensource: {
blurb: { zh: '本站本身即开源:', en: 'This site is itself open source: ' },
blurbAfter: {
zh: '—— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。',
en: '— the Astro SSG, dual-writing workflow, and the full build & deploy pipeline live in the repo. Below are all my public repositories on self-hosted gitea and GitHub, plus contributions from the past year.',
},
repoUrl: 'https://git.wait4x.online/wait4x/blog',
repoLabel: 'git.wait4x.online/wait4x/blog',
pinned: ['blog'] as string[],
},
} as const;
/** 字段级回落:yaml 值非空才采信(空白串视为空) */
const pick = (value: string | undefined, fallback: string): string =>
value && value.trim() ? value : fallback;
const pickBi = (value: { zh?: string; en?: string } | undefined, fallback: Bi, locale: Locale): string =>
pick(value?.[locale], fallback[locale]);
/** 主页 tagline(zh/en) */
export async function homeTagline(locale: Locale): Promise<string> {
const entry = await getEntry('siteHome', 'home');
return pickBi(entry?.data.tagline, SITE_DEFAULTS.homeTagline, locale);
}
/** 关于页身份陈述段(zh/en) */
export async function aboutIntro(locale: Locale): Promise<string> {
const entry = await getEntry('siteAbout', 'about');
return pickBi(entry?.data.intro, SITE_DEFAULTS.aboutIntro, locale);
}
export interface OpenSourceCopy {
blurb: string;
blurbAfter: string;
repoUrl: string;
repoLabel: string;
/** 置顶仓短名(顺序即顺序);yaml 空数组/缺省 → 默认 ['blog'] */
pinned: string[];
}
/** 开源页叙述 + 置顶名单 */
export async function openSourceCopy(locale: Locale): Promise<OpenSourceCopy> {
const entry = await getEntry('siteOpenSource', 'opensource');
const d = SITE_DEFAULTS.opensource;
const pinned = entry?.data.pinned?.filter((n) => n.trim()) ?? [];
return {
blurb: pickBi(entry?.data.blurb, d.blurb, locale),
blurbAfter: pickBi(entry?.data.blurb_after, d.blurbAfter, locale),
repoUrl: pick(entry?.data.repo_url, d.repoUrl),
repoLabel: pick(entry?.data.repo_label, d.repoLabel),
pinned: pinned.length ? pinned : [...d.pinned],
};
}
// ---- 车道「后台能力扩展」:社交链接 / 头像 / 站点图标 / markdown 正文 ----
export interface SocialLink {
label: string;
url: string;
/** 内置图标键:github / rss / email / bilibili / x;未知或空 → 纯文字按钮 */
icon?: string;
}
export const DEFAULT_SOCIALS: SocialLink[] = [
{ label: 'GitHub', url: 'https://github.com/wait4xx', icon: 'github' },
{ label: 'RSS', url: '/rss.xml', icon: 'rss' },
{ label: '邮件', url: 'mailto:admin@wait4x.online', icon: 'email' },
];
/** 社交链接(顺序即展示顺序);yaml 空/缺省 → 默认三条(与历史硬编码一致) */
export async function homeSocials(): Promise<SocialLink[]> {
const entry = await getEntry('siteHome', 'home');
const list = entry?.data.socials?.filter((s) => s.label?.trim() && s.url?.trim()) ?? [];
return list.length ? list : DEFAULT_SOCIALS;
}
/** 头像(Astro 图片管线的 ImageMetadata);未配置 → null,调用方回退渐变占位 */
export async function homeAvatar(): Promise<ImageMetadata | null> {
const entry = await getEntry('siteHome', 'home');
return entry?.data.avatar ?? null;
}
/** 站点图标路径:yaml 值非空即采信,否则 /favicon.svg;值不带前导 / 时自动补 */
export async function siteFavicon(): Promise<{ href: string; type: string }> {
const entry = await getEntry('siteHome', 'home');
const raw = pick(entry?.data.favicon, '/favicon.svg');
const href = raw.startsWith('/') || raw.startsWith('http') ? raw : `/${raw}`;
const type = href.endsWith('.svg')
? 'image/svg+xml'
: href.endsWith('.ico')
? 'image/x-icon'
: href.endsWith('.png')
? 'image/png'
: '';
return { href, type };
}
/** markdown 字符串 → HTML(进程内单例处理器,构建期复用) */
let mdProcessor: Promise<import('@astrojs/markdown-remark').MarkdownProcessor> | null = null;
export async function renderMarkdown(md: string): Promise<string> {
const { createMarkdownProcessor } = await import('@astrojs/markdown-remark');
// 与 astro.config.mjs 的文章管线同序:raw → 内容净化(标签/属性/协议)→
// class 白名单 → 链接卡片。此前这里 createMarkdownProcessor() 无参,页面
// 文案的 raw HTML 直通 set:html(方案 10 §7.1 实锤漏洞:script/style/on* 不剥)。
mdProcessor ??= (async () => {
const [{ default: rehypeRaw }, { default: rehypeContentSanitize }, { default: rehypeClassWhitelist }, { default: rehypeLinkCards }] =
await Promise.all([
import('rehype-raw'),
import('./rehype-content-sanitize'),
import('./rehype-class-whitelist'),
import('./rehype-link-cards'),
]);
return createMarkdownProcessor({
rehypePlugins: [rehypeRaw, rehypeContentSanitize, rehypeClassWhitelist, rehypeLinkCards],
});
})();
const { code } = await (await mdProcessor).render(md);
return code;
}
/**
* 页面文案富文本统一入口(层 1):所有站点单例文案字段(tagline/subtitle/blurb/blurb_after/intro…)
* 都走同一条 markdown + raw + 内容净化 + class 白名单 + 链接卡片管线,
* 后台因此可在这些字段里写 `<span class="font-kai c-blue">`。
* 硬编码界面外壳(导航/页脚按钮)不走此路径——它们属 UI 而非内容。
*/
export async function renderRich(md: string, opts: { inline?: boolean } = {}): Promise<string> {
const html = await renderMarkdown(md);
if (!opts.inline) return html;
// 行内场景(tagline / blurb 这类已在外层 <p> 里的短文本):剥掉 markdown 包的外层
// <p>,否则 <p> 套 <p> 会被浏览器解析拆散,富文本 span 逃出容器(实测坑)
return html.replace(/^\s*<p>/i, '').replace(/<\/p>\s*$/i, '').trim();
}
/**
* 页面级排版覆盖(层 2):读单例的 typography 字段,产出该页容器要用的
* 类名与 CSS 变量(--page-font / --accent / --page-scale)。
* 只作用于该页内容容器,不写全站 tokens——其它页面零影响。
*/
export interface PageTypography {
className: string;
style: string;
}
const ALLOWED_PAGE_FONTS = new Set([
'font-serif', 'font-kai', 'font-sans', 'font-mono',
'font-song', 'font-kai-sys', 'font-hei-sys', 'font-fangsong',
'font-huiwen', 'font-kuaile',
]);
const ALLOWED_PAGE_ACCENTS = new Set([
'ink-accent', 'ink-accent-soft',
...['blue', 'teal', 'green', 'yellow', 'orange', 'red', 'pink', 'purple'].flatMap((h) => [`c-${h}`, `c-${h}-soft`]),
]);
export async function pageTypography(
collection: 'siteHome' | 'siteAbout' | 'siteOpenSource',
id: string,
): Promise<PageTypography> {
const entry = await getEntry(collection, id);
const t = entry?.data?.typography as { font?: string; accent?: string; scale?: number } | undefined;
const font = t?.font && ALLOWED_PAGE_FONTS.has(t.font) ? t.font : '';
const accent = t?.accent && ALLOWED_PAGE_ACCENTS.has(t.accent) ? t.accent : '';
const scale = typeof t?.scale === 'number' && t.scale >= 0.8 && t.scale <= 1.25 ? t.scale : 1;
// 白名单外的值直接忽略(与 rehype 白名单同纪律:只认清单内的类)
const vars: string[] = [];
if (font) vars.push(`--page-font: ${fontVarFor(font)}`);
if (accent) vars.push(`--page-accent: ${accentVarFor(accent)}`);
if (scale !== 1) vars.push(`--page-scale: ${scale}`);
return { className: font || accent || scale !== 1 ? 'page-typo' : '', style: vars.join('; ') };
}
function fontVarFor(cls: string): string {
const map: Record<string, string> = {
'font-serif': 'var(--font-serif)', 'font-kai': 'var(--font-kai)',
'font-sans': 'var(--font-sans)', 'font-mono': 'var(--font-mono)',
'font-song': 'var(--font-serif)', 'font-kai-sys': 'var(--font-kai)',
'font-hei-sys': 'var(--font-sans)', 'font-fangsong': '"FangSong", STFangsong, serif',
'font-huiwen': '"WF-Hwmc", var(--font-serif)', 'font-kuaile': '"ZCOOL KuaiLe", var(--font-serif)',
};
return map[cls] ?? 'inherit';
}
function accentVarFor(cls: string): string {
if (cls === 'ink-accent') return 'var(--accent)';
if (cls === 'ink-accent-soft') return 'var(--accent-soft-text)';
const m = /^c-([a-z]+)(-soft)?$/.exec(cls);
return m ? `var(--c-${m[1]}${m[2] ? '-soft' : ''})` : 'var(--accent)';
}
/** 关于页正文(markdown,未渲染);空 → 默认文案 */
export async function aboutIntroMarkdown(locale: Locale): Promise<string> {
const entry = await getEntry('siteAbout', 'about');
return pickBi(entry?.data.intro, SITE_DEFAULTS.aboutIntro, locale);
}
// ---- 车道「首页+开源页后台扩展」:首页四项可配 + 项目墙可见性 + README 开篇 ----
export interface HomeSections {
tracks: boolean;
photos: boolean;
heatmap: boolean;
projects: boolean;
}
export interface HomeTracks {
reading: boolean;
tech: boolean;
photo: boolean;
}
export interface HomeConfig {
/** 副标题 markdown 原文;空串 = 不渲染 */
subtitle: string;
/** 公告 markdown 原文;空串 = 不渲染 */
announcement: string;
/** 首页项目墙条数(1-99,缺省 6) */
projectsLimit: number;
/** 轨细分开关(缺省全开) */
postsTracks: HomeTracks;
/** 最新影像张数:0 = 隐藏区块 */
latestPhotos: number;
/** 每轨文章条数 */
perTrackLimit: number;
sections: HomeSections;
}
const clampInt = (value: number | undefined, min: number, max: number, fallback: number): number => {
if (typeof value !== 'number' || Number.isNaN(value)) return fallback;
return Math.min(max, Math.max(min, Math.trunc(value)));
};
/** 首页可配项(字段级回落:零配置 = 现有形态;越界值夹取而非报错) */
export async function homeConfig(locale: Locale): Promise<HomeConfig> {
const entry = await getEntry('siteHome', 'home');
const d = entry?.data;
return {
subtitle: pickBi(d?.subtitle, { zh: '', en: '' }, locale),
announcement: pickBi(d?.announcement, { zh: '', en: '' }, locale),
projectsLimit: clampInt(d?.projectsLimit, 1, 99, 6),
postsTracks: {
reading: d?.postsTracks?.reading ?? true,
tech: d?.postsTracks?.tech ?? true,
photo: d?.postsTracks?.photo ?? true,
},
latestPhotos: clampInt(d?.latestPhotos, 0, 6, 4),
perTrackLimit: clampInt(d?.perTrackLimit, 4, 8, 6),
sections: {
tracks: d?.sections?.tracks ?? true,
photos: d?.sections?.photos ?? true,
heatmap: d?.sections?.heatmap ?? true,
projects: d?.sections?.projects ?? true,
},
};
}
export type ProjectMode = 'all' | 'only' | 'exclude';
export type ProjectSort = 'pinned-first' | 'stars' | 'updated';
export type ProjectSource = 'both' | 'github' | 'gitea';
export interface OpenSourceOptions {
mode: ProjectMode;
include: string[];
exclude: string[];
sort: ProjectSort;
perSource: ProjectSource;
introFrom: 'off' | 'github-readme';
}
/** 开源页项目墙选项(缺省 = 现状:all / pinned-first / 双源) */
export async function openSourceOptions(): Promise<OpenSourceOptions> {
const entry = await getEntry('siteOpenSource', 'opensource');
const d = entry?.data;
return {
mode: d?.mode ?? 'all',
include: (d?.include ?? []).filter((n) => n.trim()),
exclude: (d?.exclude ?? []).filter((n) => n.trim()),
sort: d?.sort ?? 'pinned-first',
perSource: d?.perSource ?? 'both',
introFrom: d?.intro_from ?? 'off',
};
}
/** GitHub profile README 缓存:取不到网就吃缓存,都无则调用方回落 yaml blurb */
const README_CACHE = 'content/.github-readme.md';
const PROFILE_REPO = 'wait4xx/wait4xx';
/** README 请求上限:构建期不能被单个慢端点或半开连接卡住 */
const README_TIMEOUT_MS = 10_000;
/**
* 构建期取 profile 仓 README(Accept: raw),成功后回写 content/.github-readme.md(入仓缓存)。
*
* 回落矩阵(四级,每一级都以 console.warn 点名原因,构建日志即可定位是哪级兜住的):
* 1. 新鲜网络 → 内容有变才回写缓存(console.log)。
* 2. token 缺位 / HTTP 非 2xx / 空内容 / 超时 / 网络异常 → 吃缓存 `content/.github-readme.md`。
* 3. 缓存缺失或不可读 → 返回 null,调用方回落 yaml blurb。
* 4. 缓存回写失败(构建仓只读挂载等)→ 仅告警,本次渲染照常。
*
* 已知限制:README 内的**相对链接不重写**(如 `./img/x.png`、`docs/foo.md`)——它们相对 GitHub
* 仓根解析,在本站没有对应产物,点到即 404。保原样比猜一个基址可信;该限制在 docs/管理台.md
* “开源页开篇”一节向用户明示。危险 HTML 标签已由 renderMarkdown 管线里的
* rehype-content-sanitize 统一剥离,此处不再重复过滤。
*
* @returns README markdown;四级回落见上
*/
export async function githubReadmeMarkdown(): Promise<string | null> {
const { readFileSync, writeFileSync, existsSync } = await import('node:fs');
const { join } = await import('node:path');
const cachePath = join(process.cwd(), README_CACHE);
/** 静默读缓存:缺失/不可读/空 → null(不打印,供回落函数与回写比对复用) */
const readCacheQuiet = (): string | null => {
try {
const md = existsSync(cachePath) ? readFileSync(cachePath, 'utf8') : null;
return md && md.trim() ? md : null;
} catch {
return null;
}
};
/** 第 2/3 级回落:先报原因,再报缓存有无 */
const fallback = (cause: string): string | null => {
const cached = readCacheQuiet();
if (!cached) {
console.warn(`[opensource] github-readme:${cause};缓存 ${README_CACHE} 亦缺,回落 yaml blurb`);
return null;
}
console.warn(`[opensource] github-readme:${cause};改用缓存 ${README_CACHE}(${cached.length} 字符,可能陈旧)`);
return hardenReadmeImages(cached);
};
let token: string | null = null;
try {
token = readFileSync(join(process.env.HOME ?? '', 'docker_apps/blog/github-token.txt'), 'utf8').trim() || null;
} catch {
token = null;
}
if (!token) return fallback('token 缺位(~/docker_apps/blog/github-token.txt 不存在)');
try {
const res = await fetch(`https://api.github.com/repos/${PROFILE_REPO}/readme`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github.raw', 'User-Agent': 'wait4x-blog-build' },
signal: AbortSignal.timeout(README_TIMEOUT_MS),
});
if (!res.ok) return fallback(`${PROFILE_REPO} 取 README 失败 HTTP ${res.status}`);
const md = (await res.text()).trim();
if (!md) return fallback('README 内容为空');
if (readCacheQuiet() !== md) {
try {
writeFileSync(cachePath, `${md}\n`, 'utf8');
console.log(`[opensource] github-readme:已刷新缓存 ${README_CACHE}(${md.length} 字符)`);
} catch (err) {
// 构建仓可能是只读挂载:缓存写不进不该拖垮本次渲染
console.warn(`[opensource] github-readme:缓存回写失败(${(err as Error).message}),本次渲染不受影响`);
}
}
return hardenReadmeImages(md);
} catch (err) {
const e = err as Error;
const cause = e.name === 'TimeoutError' || e.name === 'AbortError'
? `请求超时(>${README_TIMEOUT_MS}ms)`
: `请求异常(${e.message})`;
return fallback(cause);
}
}
/**
* README 里的 <img>(shields.io 徽章 / readme-typing-svg 之类)加固:惰性加载 + alt 兜底。
*
* 为何只动 `loading` 与 `alt`:README 走 renderMarkdown 管线,出的 HTML 还要过
* rehype-content-sanitize 的白名单(img 只保留 src/alt/width/height/loading),
* 写在 markdown 层的 `decoding`/`referrerpolicy` 会被剥掉。惰性加载是徽章不挤压首屏的
* 主力手段:下方徽章不占首屏带宽、不拖慢 loading 事件;破图时还有空 alt 兜底不留噪声。
* 尺寸约束已由页面 scoped 样式 `img { max-width: 100%; height: auto }` 扣住。
*
* 幂等:已存在 loading 属性时覆写为同一策略,不会重复叠加。
* @param md README markdown 原文
* @returns 加固后的 markdown(缓存仍写原始内容,保证缓存与上游可比对)
*/
function hardenReadmeImages(md: string): string {
return md.replace(/<img\b([^>]*)>/gi, (_match, attrs: string) => {
let out = attrs;
if (!/\salt=/i.test(out)) out += ' alt=""';
out = /\sloading\b/i.test(out) ? out.replace(/\sloading="[^"]*"/i, ' loading="lazy"') : `${out} loading="lazy"`;
return `<img${out}>`;
});
}