diff --git a/.spec/specs/2026-11-02_blog-5g-pages/tasks.md b/.spec/specs/2026-11-02_blog-5g-pages/tasks.md index 29173df..c3f9b33 100644 --- a/.spec/specs/2026-11-02_blog-5g-pages/tasks.md +++ b/.spec/specs/2026-11-02_blog-5g-pages/tasks.md @@ -16,7 +16,7 @@ - boundary: 新增 src/components/blocks/{Cards,Timeline,Quote,Figure,LinkList,Cta}.astro + 图标键白名单(复用 SocialLinks 注册表或新建 icons 注册表)、schema 块型补全(图片字段用 image(),禁止外链字符串)、content/site/assets/ 目录、near docs 图表说明 - verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && ls src/components/blocks/ | grep -qC1 "Cards\|Timeline" && grep -q "image()" src/content.config.ts` -- [ ] T3 样式即数据(theme.yaml + 对比度闸门) +- [x] T3 样式即数据(theme.yaml + 对比度闸门) - claim: op-artalk - boundary: 新增 content/site/theme.yaml(12 个白名单旋钮:prose-size/measure/radius/accent/leading 等)、新增 src/lib/theme.ts(getTheme + themeVarsCss 生成 :root:root{…})、BaseLayout.astro head 内联 theme 变量(构建期静态)、新增 scripts/build-perf/contrast-check.mjs(断言 theme 结果下正文对比度 ≥4.5)、public/admin/config.yml 加 theme 单例、build.sh 守卫挂载 - verify: `E2E_BASE=http://127.0.0.1:18924 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && test -f content/site/theme.yaml && node scripts/build-perf/contrast-check.mjs dist` diff --git a/content/site/theme.yaml b/content/site/theme.yaml new file mode 100644 index 0000000..4174228 --- /dev/null +++ b/content/site/theme.yaml @@ -0,0 +1,37 @@ +# 主题旋钮(样式即数据 · 层 4):只放白名单内的 9 组旋钮。 +# 红线:--measure(版心)与断点/纸墨/code 配色不在此列——它们牵动版面结构与分轨身份。 +# 全部留空 = 沿用 src/styles/tokens.css 的既有值(零配置零变化)。 +# 钳制与单调性在 src/lib/theme.ts 里做:越界值被夹取而非报错,构建不会因主题配置失败。 + +# ① 强调色(明/暗两值;构建期闸门校验正文对比度 ≥4.5,不达标回落默认) +accent_light: '#3454d1' +accent_dark: '#8ea2ff' + +# ② 图片圆角(4–16;08 文档裁定上限 14) +radius_img: 10 + +# ③ 标题字号(22–32;必须 h1 > h2 > h3,否则整体回落默认) +text_h1: 27 +text_h2: 22 +text_h3: 19 + +# ④ 正文字号(16–22) +prose_size_reading: 20 +prose_size_tech: 19 + +# ⑤ 正文行高(1.5–2.0) +leading_reading: 1.75 +leading_tech: 1.7 + +# ⑥ 间距梯度(8–80;必须严格递增 3 < 4 < 5 < 6,否则回落默认) +space_3: 16 +space_4: 24 +space_5: 40 +space_6: 64 + +# ⑦ 段落流距(0–2em) +flow_p: 0.9 +flow_h2_post: 0.6 + +# ⑧ 过渡时长(150–600ms) +dur: 350 diff --git a/infra/deploy/build.sh b/infra/deploy/build.sh index 7d9d35b..c675fdd 100755 --- a/infra/deploy/build.sh +++ b/infra/deploy/build.sh @@ -33,6 +33,7 @@ pnpm astro build # 按页字体 CSS 子集化(车道 A 任务 1):裁 @font-face 声明数,构建仓与本地同链; # 存在性守卫:新文件未推送到 main 前构建仓无此脚本,绝不让发布链因车道在途文件断掉 [ -f scripts/build-perf/subset-font-css.mjs ] && node scripts/build-perf/subset-font-css.mjs dist || echo "[build] subset 脚本未就绪,跳过" +[ -f scripts/build-perf/contrast-check.mjs ] && node scripts/build-perf/contrast-check.mjs dist || echo "[build] contrast 脚本未就绪,跳过" # 首页阻塞 CSS 内联(车道 D):守卫先行同上——脚本未到 main 前绝不炸链 [ -f scripts/build-perf/gen-sw-precache.mjs ] && node scripts/build-perf/gen-sw-precache.mjs dist || echo "[build] sw-precache 生成器未就绪,跳过" [ -f scripts/build-perf/inline-home-css.mjs ] && node scripts/build-perf/inline-home-css.mjs || echo "[build] inline-home 脚本未就绪,跳过" diff --git a/public/admin/config.yml b/public/admin/config.yml index b49b7dd..8d8a618 100644 --- a/public/admin/config.yml +++ b/public/admin/config.yml @@ -58,6 +58,29 @@ collections: - { label: 草稿, name: draft, widget: boolean, required: false, default: false } - { label: 正文, name: body, widget: markdown } + # 主题旋钮(样式即数据 · 层 4):只放白名单内的 9 组,越界值会被构建期钳制 + - name: site_theme + label: 主题设置 + file: content/site/theme.yaml + fields: + - { label: 强调色(亮), name: accent_light, widget: color, required: false, hint: '亮色模式强调色,如 #3454d1;构建期校验正文对比度 ≥4.5' } + - { label: 强调色(暗), name: accent_dark, widget: color, required: false, hint: '暗色模式强调色,如 #8ea2ff' } + - { label: 图片圆角, name: radius_img, widget: number, required: false, value_type: int, min: 4, max: 16, hint: '范围 4–16(建议 ≤14)' } + - { label: 一级标题字号, name: text_h1, widget: number, required: false, value_type: int, min: 22, max: 32, hint: '22–32,且必须大于 h2' } + - { label: 二级标题字号, name: text_h2, widget: number, required: false, value_type: int, min: 22, max: 32, hint: '22–32,必须小于 h1、大于 h3' } + - { label: 三级标题字号, name: text_h3, widget: number, required: false, value_type: int, min: 16, max: 32, hint: '16–32,必须小于 h2' } + - { label: 书评正文字号, name: prose_size_reading, widget: number, required: false, value_type: int, min: 16, max: 22, hint: '16–22' } + - { label: 技术文正文字号, name: prose_size_tech, widget: number, required: false, value_type: int, min: 16, max: 22, hint: '16–22' } + - { label: 书评行高, name: leading_reading, widget: number, required: false, value_type: float, min: 1.5, max: 2.0, step: 0.05, hint: '1.5–2.0' } + - { label: 技术文行高, name: leading_tech, widget: number, required: false, value_type: float, min: 1.5, max: 2.0, step: 0.05, hint: '1.5–2.0' } + - { label: 间距 3, name: space_3, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 < 间距 4' } + - { label: 间距 4, name: space_4, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 > 间距 3 且 < 间距 5' } + - { label: 间距 5, name: space_5, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 > 间距 4 且 < 间距 6' } + - { label: 间距 6, name: space_6, widget: number, required: false, value_type: int, min: 8, max: 80, hint: '8–80,必须 > 间距 5' } + - { label: 段间距, name: flow_p, widget: number, required: false, value_type: float, min: 0, max: 2, step: 0.05, hint: '0–2(em)' } + - { label: 标题下间距, name: flow_h2_post, widget: number, required: false, value_type: float, min: 0, max: 2, step: 0.05, hint: '0–2(em)' } + - { label: 过渡时长, name: dur, widget: number, required: false, value_type: int, min: 150, max: 600, hint: '150–600(毫秒)' } + # 瞬间:随手发(text/image 至少其一——Sveltia 无法表达条件必填,两者都设可选, # 保存前自查;pubDate 必填)。图片走图库媒体库(content/photos 同源可复用) - name: moments @@ -140,6 +163,24 @@ collections: fields: - { label: 中文, name: zh, widget: string, required: false } - { label: 英文, name: en, widget: string, required: false } + - label: 公告(可选,markdown) + name: announcement + widget: object + required: false + hint: 渲染在名片区下方;留空则不显示公告块 + fields: + - { label: 中文, name: zh, widget: markdown, required: false } + - { label: 英文, name: en, widget: markdown, required: false } + - { label: 项目墙条数, name: projectsLimit, widget: number, required: false, value_type: int, min: 1, max: 99, hint: '首页项目墙显示几条(1–99,缺省 6)' } + - label: 轨细分开关(首页文章列表) + name: postsTracks + widget: object + required: false + hint: 关掉某轨则该轨文章不在首页列出(全开=现状) + fields: + - { label: 阅读轨, name: reading, widget: boolean, required: false, default: true } + - { label: 技术轨, name: tech, widget: boolean, required: false, default: true } + - { label: 照片轨, name: photo, widget: boolean, required: false, default: true } - label: 副标题(可选,markdown 短文本) name: subtitle widget: object diff --git a/scripts/build-perf/contrast-check.mjs b/scripts/build-perf/contrast-check.mjs new file mode 100644 index 0000000..0d12406 --- /dev/null +++ b/scripts/build-perf/contrast-check.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/** + * 主题对比度闸门(样式即数据 · 层 4):从**构建产物**里读实际生效的 CSS 变量, + * 断言正文与强调色在两态下的对比度都 ≥4.5:1。构建期跑,失败即 exit 1 拦住发布。 + * + * 为什么读产物而不是读 yaml:断言的对象必须是"用户最终看到的颜色"—— + * theme.yaml 经 theme.ts 钳制/回落后的真值只存在于 dist 内联样式里。 + * + * 用法:node scripts/build-perf/contrast-check.mjs [distRoot] + */ +import { readFileSync, existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const distRoot = process.argv[2] ?? 'dist'; +const MIN = 4.5; + +const read = (p) => readFileSync(p, 'utf8'); + +function normalize(hex) { + const h = hex.replace('#', ''); + return '#' + (h.length === 3 ? h.split('').map((c) => c + c).join('') : h); +} +function lum(hex) { + const h = normalize(hex).replace('#', ''); + const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255); + const f = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); +} +function ratio(a, b) { + const [la, lb] = [lum(a), lum(b)]; + const [hi, lo] = [Math.max(la, lb), Math.min(la, lb)]; + return (hi + 0.05) / (lo + 0.05); +} + +// ── 1. 从产物取主题变量(内联 style 的 :root:root / [data-theme=dark] 块) ── +let html = ''; +if (!existsSync(distRoot)) { + console.error(`[contrast] dist 不存在:${distRoot}(先 build)`); + process.exit(1); +} +const htmlFile = join(distRoot, 'index.html'); +if (existsSync(htmlFile)) html = read(htmlFile); +else { + // 兜底:任取一个页面 + const walk = (d) => { + for (const e of readdirSync(d, { withFileTypes: true })) { + const p = join(d, e.name); + if (e.isDirectory()) { + const r = walk(p); + if (r) return r; + } else if (e.name === 'index.html') return p; + } + return null; + }; + const f = walk(distRoot); + if (f) html = read(f); +} +if (!html) { + console.error('[contrast] 产物里找不到任何页面'); + process.exit(1); +} + +const varOf = (scope, name) => { + const re = new RegExp(`${scope.replace(/[[\]]/g, '\\$&')}[^{]*\\{([^}]*)\\}`, 'g'); + let m; + while ((m = re.exec(html))) { + const hit = new RegExp(`${name}\\s*:\\s*(#[0-9a-fA-F]{3,6})`).exec(m[1]); + if (hit) return hit[1]; + } + return null; +}; + +// tokens.css 的底色与正文色(不在主题旋钮白名单内,故从产物 CSS 取) +const cssFiles = []; +const walkCss = (d) => { + for (const e of readdirSync(d, { withFileTypes: true })) { + const p = join(d, e.name); + if (e.isDirectory()) walkCss(p); + else if (e.name.endsWith('.css')) cssFiles.push(p); + } +}; +walkCss(distRoot); +let tokensCss = ''; +for (const f of cssFiles) { + const c = read(f); + // 必须是**定义**(--bg:#xxx),而不是 var(--bg-soft) 这类引用—— + // 后者会误命中只用到变量名、并不定义 base token 的页面级 CSS + if (/--ink-2\s*:\s*#/.test(c) && /--bg\s*:\s*#/.test(c)) { tokensCss = c; break; } +} +const tokenVar = (name) => { + const m = new RegExp(`${name}\\s*:\\s*(#[0-9a-fA-F]{3,6})`).exec(tokensCss); + return m ? m[1] : null; +}; + +const checks = []; +const bgLight = tokenVar('--bg'); +const bgDark = (() => { + // 暗态 --bg 在 [data-theme=dark] 块里 + const m = /\[data-theme=["']?dark["']?\][^{]*\{([^}]*)\}/.exec(tokensCss); + if (!m) return null; + const h = /--bg\s*:\s*(#[0-9a-fA-F]{3,6})/.exec(m[1]); + return h ? h[1] : null; +})(); +const ink2Light = tokenVar('--ink-2'); +const ink2Dark = (() => { + const m = /\[data-theme=["']?dark["']?\][^{]*\{([^}]*)\}/.exec(tokensCss); + if (!m) return null; + const h = /--ink-2\s*:\s*(#[0-9a-fA-F]{3,6})/.exec(m[1]); + return h ? h[1] : null; +})(); + +const accentLight = varOf(':root:root', '--accent'); +const accentDark = (() => { + const m = /\[data-theme=["']?dark["']?\]:root:root\{([^}]*)\}/.exec(html); + if (!m) return null; + const h = /--accent\s*:\s*(#[0-9a-fA-F]{3,6})/.exec(m[1]); + return h ? h[1] : null; +})(); + +if (bgLight && ink2Light) checks.push(['正文(亮)', ink2Light, bgLight]); +if (bgDark && ink2Dark) checks.push(['正文(暗)', ink2Dark, bgDark]); +if (accentLight && bgLight) checks.push(['强调色(亮)', accentLight, bgLight]); +if (accentDark && bgDark) checks.push(['强调色(暗)', accentDark, bgDark]); + +if (checks.length === 0) { + console.error('[contrast] 未能从产物解析出主题/正文色变量,请检查注入是否生效'); + process.exit(1); +} + +let failed = 0; +for (const [label, fg, bg] of checks) { + const r = ratio(fg, bg); + const ok = r >= MIN; + if (!ok) failed++; + console.log(`[contrast] ${ok ? 'PASS' : 'FAIL'} ${label} ${fg} on ${bg} = ${r.toFixed(2)}:1`); +} +if (failed > 0) { + console.error(`[contrast] ${failed} 项低于 ${MIN}:1 —— 主题配色不可读,请调亮/调暗后再构建`); + process.exit(1); +} +console.log(`[contrast] 全部通过(${checks.length} 项 ≥ ${MIN}:1)`); diff --git a/src/components/pages/HomePage.astro b/src/components/pages/HomePage.astro new file mode 100644 index 0000000..aad9192 --- /dev/null +++ b/src/components/pages/HomePage.astro @@ -0,0 +1,214 @@ +--- +// 首页(zh/en 共用):名片区 / 文章列表 / 最新影像 / 热力图 / 项目墙 / 心跳 +// 两个 index.astro 只是薄壳(传 locale),区块逻辑与样式集中在这里,避免双份漂移。 +import { Image } from 'astro:assets'; +import { getCollection } from 'astro:content'; +import BaseLayout from '../../layouts/BaseLayout.astro'; +import IdCard from '../IdCard.astro'; +import PostList from '../PostList.astro'; +import ProjectWall from '../geek/ProjectWall.astro'; +import Heatmap from '../geek/Heatmap.astro'; +import SocialLinks from '../SocialLinks.astro'; +import { t } from '../../i18n/ui'; +import type { Locale } from '../../lib/locale'; +import { + homeConfig, + homeTagline, + pageTypography, + renderMarkdown, + renderRich, +} from '../../lib/site-content'; +import { fetchHeartbeat, agoZh } from '../../lib/heartbeat'; + +interface Props { + locale?: Locale; +} +const { locale = 'zh' } = Astro.props; + +// 首页可配项(后台主页设置):副标题/影像张数/区块开关/每轨条数/项目墙条数/公告/轨细分 +const cfg = await homeConfig(locale); +const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : ''; +const announcementHtml = cfg.announcement ? await renderRich(cfg.announcement) : ''; + +const posts = ( + await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')) +).sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()); + +// en 轨列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略) +const enTitle = locale === 'en' + ? Object.fromEntries((await getCollection('postsEn')).map((e) => [e.id, e.data.title])) + : undefined; + +// 最新影像取集合前 N 张;照片时间序由照片管线治理,此处只裁切 +const photos = (await getCollection('photos')).slice(0, Math.max(1, cfg.latestPhotos)); + +const tagline = await homeTagline(locale); +const taglineHtml = await renderRich(tagline, { inline: true }); +const typo = await pageTypography('siteHome', 'home'); +// 心跳只出现在中文首页(en 轨历史形态如此,保持零回归) +const heartbeat = locale === 'zh' ? await fetchHeartbeat() : null; +const galleryHref = locale === 'en' ? '/en/gallery/' : '/gallery/'; + +// 轨细分开关:只保留开启的轨道(全开时等价于不过滤,零回归) +const activeTracks = Object.entries(cfg.postsTracks) + .filter(([, on]) => on) + .map(([k]) => k); +const allTracksOn = activeTracks.length === Object.keys(cfg.postsTracks).length; +const visiblePosts = allTracksOn + ? posts + : posts.filter((p) => activeTracks.includes(p.data.category)); +--- + + +
+ +

+ {subtitleHtml &&

} + + + + {announcementHtml && ( +
+ )} + + {cfg.sections.tracks && ( + + )} + + {cfg.sections.photos && cfg.latestPhotos > 0 && ( +
+
+ {t('latest_photos', locale)} + {t('enter_gallery', locale)} +
+ +
+ )} + + {cfg.sections.heatmap && ( + + )} + {cfg.sections.projects && } + {heartbeat && ( +

+ + 最近心跳:{agoZh(heartbeat.when)} · {heartbeat.msg} +

+ )} +
+ + + diff --git a/src/content.config.ts b/src/content.config.ts index 28b222f..86b843f 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -184,6 +184,18 @@ export const collections = { 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({ diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index e4cb051..df17650 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -18,6 +18,7 @@ import { siteFavicon } from '../lib/site-content'; import SearchDialog from '../components/SearchDialog.astro'; import BuildInfo from '../components/geek/BuildInfo.astro'; import { ClientRouter } from 'astro:transitions'; +import { themeCss } from '../lib/theme'; interface Props { title: string; @@ -36,6 +37,7 @@ const withSlash = (p: string) => (p.endsWith('/') ? p : `${p}/`); const favicon = await siteFavicon(); // 自指 canonical(T2):SEO 权重收敛到当前页自身,与 hreflang 组合覆盖双轨 const canonicalUrl = new URL(withSlash(rawPath), Astro.site).href; +const themeStyle = await themeCss(); --- @@ -100,7 +102,9 @@ const canonicalUrl = new URL(withSlash(rawPath), Astro.site).href; - + + + diff --git a/src/lib/site-content.ts b/src/lib/site-content.ts index 07c2f11..21f02df 100644 --- a/src/lib/site-content.ts +++ b/src/lib/site-content.ts @@ -223,9 +223,21 @@ export interface HomeSections { 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; /** 每轨文章条数 */ @@ -244,6 +256,13 @@ export async function homeConfig(locale: Locale): Promise { 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: { diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..d587938 --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,143 @@ +/** + * 样式即数据(层 4):把 content/site/theme.yaml 的白名单旋钮编译成 CSS 变量注入串。 + * + * 三条红线(docs/research/10 方案 §3.6): + * ① 只认 9 组白名单旋钮;--measure / 断点 / 纸墨 / code 配色一律不可配(牵动版面结构与分轨身份)。 + * ② 注入用 `:root:root{…}`(0,2,0)与 `[data-theme="dark"]:root:root{…}`(0,3,0)—— + * 必须靠特异性压过 tokens.css 的 `:root`(0,1,0),**不能靠 DOM 顺序**(link 与内联 style 的相对次序不可控)。 + * ③ 值全部钳制 + 单调性校验:越界只回落默认,绝不让构建失败(主题配错不该炸站)。 + * + * 构建期静态:BaseLayout 直接把返回串内联进 + diff --git a/src/pages/index.astro b/src/pages/index.astro index fbc45ad..a26c8d2 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,167 +1,5 @@ --- -import { Image } from 'astro:assets'; -import { getCollection } from 'astro:content'; -import BaseLayout from '../layouts/BaseLayout.astro'; -import IdCard from '../components/IdCard.astro'; -import PostList from '../components/PostList.astro'; -import ProjectWall from '../components/geek/ProjectWall.astro'; -import Heatmap from '../components/geek/Heatmap.astro'; -import { pageTypography, renderRich } from '../lib/site-content'; -import { fetchHeartbeat, agoZh } from '../lib/heartbeat'; -import { t } from '../i18n/ui'; -import { homeConfig, homeTagline, renderMarkdown } from '../lib/site-content'; -import SocialLinks from '../components/SocialLinks.astro'; - -// 首页可配项(后台主页设置):副标题/影像张数/区块开关/每轨条数,字段级回落 -const cfg = await homeConfig('zh'); -const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : ''; -const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort( - (a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(), -); -// 最新影像取集合前 4 张;照片时间序由照片管线(exif 车道)治理,此处只裁切 -const photos = (await getCollection('photos')).slice(0, Math.max(1, cfg.latestPhotos)); -const heartbeat = await fetchHeartbeat(); -// 身份语改由 content/site/home.yaml 供给(后台可改),缺值回落默认文案 -const tagline = await homeTagline('zh'); -// 层 1:tagline 走富文本(后台可写 ) -const taglineHtml = await renderRich(tagline, { inline: true }); -const typo = await pageTypography('siteHome', 'home'); +// 首页薄壳(逻辑与样式在 src/components/pages/HomePage.astro,zh/en 共用) +import HomePage from '../components/pages/HomePage.astro'; --- - - -
- -

- {subtitleHtml &&

} - - - - {cfg.sections.tracks && } - - {cfg.sections.photos && cfg.latestPhotos > 0 && ( -
-
- 最新影像 - 进入图库 → -
- -
- )} - - {cfg.sections.heatmap && } - {cfg.sections.projects && } - {heartbeat && ( -

- - 最近心跳:{agoZh(heartbeat.when)} · {heartbeat.msg} -

- )} -
- - - +