// 站点文案单例访问层(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 { const entry = await getEntry('siteHome', 'home'); return pickBi(entry?.data.tagline, SITE_DEFAULTS.homeTagline, locale); } /** 关于页身份陈述段(zh/en) */ export async function aboutIntro(locale: Locale): Promise { 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 { 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 { 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 { 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 | null = null; export async function renderMarkdown(md: string): Promise { 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 白名单 + 链接卡片管线, * 后台因此可在这些字段里写 ``。 * 硬编码界面外壳(导航/页脚按钮)不走此路径——它们属 UI 而非内容。 */ export async function renderRich(md: string, opts: { inline?: boolean } = {}): Promise { const html = await renderMarkdown(md); if (!opts.inline) return html; // 行内场景(tagline / blurb 这类已在外层

里的短文本):剥掉 markdown 包的外层 //

,否则

套

会被浏览器解析拆散,富文本 span 逃出容器(实测坑) return html.replace(/^\s*

/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 { 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 = { '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 { 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 { 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 { 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 { 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 里的 (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(/]*)>/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 ``; }); }