Files
blog/src/lib/site-content.ts
T
wait4xandGLM-5.3 9df86cb05b feat(fonts): 新增六款自托管字族(体积闸门内)(5i T9)
用户拍板「字族扩展都补」。六款按三类各两款落地,全部单页增量 ≤150KB:
小薇(文学·宋体气质)、新晰黑(文学·现代黑)、得意黑(标题)、庆科黄油(标题·圆润)、
马善政毛笔与柳建毛草(书法),许可证均 OFL。

关键取舍:@fontsource 官方中文切片粒度太粗(每片约 33 字、单片 40KB+),一句话命中
7 片即 308–442KB/页,四款全部超闸门;即便用 subset-fonts.py 按站内用字重裁仍剩
205–346KB(笔画类字族单片就 50KB)。故改为从 google/fonts 取原始 TTF,按**字频序
细切**(约 19 字/片、单片 2–12KB),每族 99–100 片、族总量 0.5–1.2MB,浏览器只拉
命中的片。四个 @fontsource 依赖随之移除,package.json 净零变更。

实测单页增量(一句话场景):xiaowei 44.9KB / huangyou 27.1KB / mashanzheng 45.7KB /
liujianmaocao 30.0KB / smiley 27.1KB / neoxihei 30.0KB——全部 PASS;
六族同页共 105KB。

接线(一处漏就静默失效,踩过两次):
- rehype-class-whitelist 放行新类(不放行则 span 的 class 被整条剥掉,页面有字没脸)
- site-content 的 ALLOWED_PAGE_FONTS 与后台排版下拉同源补齐(三处下拉 + hint)
- font-needs 扩六个 key,保证「内容用到才加载」的按需契约继续成立
- global.css 六组规则含 .mom-text 作用域(5h 补的瞬间作用域不能漏)
- typography-classes.js(后台可视化面板单一事实源)35→42 项
- subset-fonts.py 新增 --split-ttf / --slices / --order-from(hwmc 的 --css 用法未动)

覆盖边界(已在 docs 与后台 hint 写明):切片只含站内用字(约 1900 字),
新写生僻字会回落系统字族,需按 docs 命令重切。

验证: 构建 50 页 exit 0;content-lint 51 页 0/0/0;config-check 211 字段/66 hint;
      六类逐项 getComputedStyle 首项 = ZCOOL XiaoWei / ZCOOL QingKe HuangYou /
      Ma Shan Zheng / Liu Jian Mao Cao / Smiley Sans / LXGW Neo XiHei(6/6);
      六族全部出现在 document.fonts;首页字体请求数保持 17(零回归)

Spec: 2026-11-02_blog-5i-fonts-polish (T9)

Co-authored-by: GLM-5.3
2026-11-03 12:35:20 +08:00

424 lines
19 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 = {
/** 首页项目墙条数(旧 yaml 无此字段时的默认:6 条) */
homeProjectsLimit: 6,
/** 首页公告(默认空 = 不渲染) */
homeAnnouncement: { zh: '', en: '' },
/** 首页轨细分(默认全开 = 现状) */
homePostsTracks: { reading: true, tech: true, photo: true },
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',
// 5i T9 字族扩展:页面级排版覆盖(typography.font)也放行这六款
'font-xiaowei', 'font-huangyou', 'font-mashanzheng',
'font-liujianmaocao', 'font-smiley', 'font-neoxihei',
]);
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)',
// 5i T9 字族扩展:字体族名与 global.css / @font-face 三处完全一致
'font-xiaowei': '"ZCOOL XiaoWei", var(--font-serif)',
'font-huangyou': '"ZCOOL QingKe HuangYou", var(--font-sans)',
'font-mashanzheng': '"Ma Shan Zheng", var(--font-kai)',
'font-liujianmaocao': '"Liu Jian Mao Cao", var(--font-kai)',
'font-smiley': '"Smiley Sans", var(--font-sans)',
'font-neoxihei': '"LXGW Neo XiHei", var(--font-sans)',
};
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, SITE_DEFAULTS.homeAnnouncement, locale),
projectsLimit: clampInt(d?.projectsLimit, 1, 99, SITE_DEFAULTS.homeProjectsLimit),
postsTracks: {
reading: d?.postsTracks?.reading ?? SITE_DEFAULTS.homePostsTracks.reading,
tech: d?.postsTracks?.tech ?? SITE_DEFAULTS.homePostsTracks.tech,
photo: d?.postsTracks?.photo ?? SITE_DEFAULTS.homePostsTracks.photo,
},
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}>`;
});
}