// 站点文案单例访问层(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 { 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; } /** 关于页正文(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 HomeConfig { /** 副标题 markdown 原文;空串 = 不渲染 */ subtitle: string; /** 最新影像张数: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), 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'; /** * 构建期取 profile 仓 README(Accept: raw),成功后回写 content/.github-readme.md(入仓缓存) * @returns README markdown;网络/权限/仓无 README 时回落缓存,仍无 → null */ 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); const readCache = (): string | null => { try { return existsSync(cachePath) ? readFileSync(cachePath, 'utf8') : null; } catch { return null; } }; 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) { console.warn('[opensource] github-readme:token 缺位,用缓存/回落 yaml blurb'); return readCache(); } 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' }, }); if (!res.ok) { console.warn(`[opensource] github-readme:${PROFILE_REPO} 取 README 失败 HTTP ${res.status},用缓存/回落`); return readCache(); } const md = (await res.text()).trim(); if (!md) { console.warn('[opensource] github-readme:README 为空,用缓存/回落'); return readCache(); } const cached = readCache(); if (cached !== md) { writeFileSync(cachePath, `${md}\n`, 'utf8'); console.log(`[opensource] github-readme:已刷新缓存 ${README_CACHE}(${md.length} 字符)`); } return md; } catch (err) { console.warn(`[opensource] github-readme:请求异常(${(err as Error).message}),用缓存/回落`); return readCache(); } }