feat(about): 块式页面构建器——九类区块 + 三级回落零回归(5g T1/T2)
- schema:siteAbout 增 layout{showIdCard} + blocks(z.discriminatedUnion
九类:prose/tracks/cards/timeline/quote/figure/linklist/cta/divider,
图片字段走 Astro image());对未知块型用 z.union([knownUnion,
z.looseObject({type})]) 复合——已知块严格校验,未知块落宽松分支再由
访问器 WARN+丢弃(容错与严格校验的张力取舍)
- page-blocks.ts:enabled:false 过滤、未知 type WARN+skip、pickBi 回落
zh 并 WARN、上限(块≤24/单块文本≤4000,超限截断并 WARN)
- BlockRenderer 统一外壳 `<section class="blk" data-block={type}>`(E2E
抓手)+ 九个块组件(样式自 AboutPage 原 <style> 搬迁,只吃既有 token)
- AboutPage 三级回落:blocks → intro markdown → 内置默认;legacy
tracks/build 段整体挪入回退分支(文案逐字一致,零回归)
- 图标白名单 src/lib/icons.ts(12 键,未登记回退首字占位)
- 后台 about 单例四段结构(blocks/layout/intro/typography),块为
list+types+typeKey,每块带「显示」开关与中文标签
协调报备:车道修了一处跨车道插入事故(T3 把 typography 插进了 intro
的 widget 内导致字段错位),重写为四段正确结构;并发构建擦 dist 的
假失败改用「build→快照 dist→快照起服务跑 E2E」规避(37/37 稳定)
验证: 构建 48 页 exit 0;E2E 37/37(快照口径);九类块顺序与配置一致、
figure 走 /_astro/*.webp 管线、未知块与外链图被拦并 WARN、
终版 3 块零回归、en 同构、admin about 四段字段齐全
Spec: 2026-11-02_blog-5g-pages (T1/T2)
Co-authored-by: GLM-5.3
This commit is contained in:
1 parent
6096a9dafe
commit
2f7cbcf7bd
16 files changed
+1232
-15
No files matched your search
+65
-6
@@ -1,9 +1,17 @@
|
||||
# 关于页正文(后台「页面文案 → 关于页」可改,markdown 编辑)
|
||||
# intro = 多段 markdown 正文,渲染在「关于」标题下方、名片之前;留空回落默认文案
|
||||
# 支持的语法:段落、列表、粗斜体、行内代码、链接(渲染走 Astro 的 markdown 管线)
|
||||
intro:
|
||||
# 关于页(后台「页面文案 → 关于页」可改)
|
||||
# ── 一级:blocks 块式正文(按顺序渲染,可拖拽排序;某块 enabled: false 即隐藏)
|
||||
# ── 二级:intro 多段 markdown(仅在 blocks 为空时使用)
|
||||
# ── 三级:内置默认文案(两者都空时兜底,构建永不炸)
|
||||
layout:
|
||||
# 名片区(头像/名字/身份语/社交)开关:头像等数据事实源在「主页设置」
|
||||
showIdCard: true
|
||||
|
||||
blocks:
|
||||
- type: prose
|
||||
width: narrow
|
||||
body:
|
||||
zh: |
|
||||
写作者 · 读者 · 快门按压者,拥抱开源。
|
||||
<span class="font-huiwen c-red">写作者</span> · 读者 · 快门按压者,拥抱开源。
|
||||
|
||||
这里是我三条轨道的公开工作台:**读过的书**留下评注,**写过的代码**进项目墙,**按下的快门**收入图库。
|
||||
|
||||
@@ -15,7 +23,7 @@ intro:
|
||||
|
||||
本站本身即开源,构建与发布全链都在仓里。
|
||||
en: |
|
||||
Writer · Reader · Shutter-presser, embracing open source.
|
||||
<span class="font-huiwen c-red">Writer</span> · 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 that land in the gallery.
|
||||
|
||||
@@ -26,3 +34,54 @@ intro:
|
||||
- Images go to the gallery, with a bit of EXIF and one line of curation
|
||||
|
||||
This site is itself open source — the full build and deploy pipeline lives in the repo.
|
||||
|
||||
- type: tracks
|
||||
title:
|
||||
zh: 兴趣版图
|
||||
en: Interests
|
||||
items:
|
||||
- icon: book
|
||||
name:
|
||||
zh: 阅读
|
||||
en: Reading
|
||||
desc:
|
||||
zh: 文学与戏剧为主,读到哪写到哪。
|
||||
en: Mostly literature and drama; I write as I read.
|
||||
- icon: terminal
|
||||
name:
|
||||
zh: 技术
|
||||
en: Tech
|
||||
desc:
|
||||
zh: Linux、容器与自托管,把家里的小机器折腾成顺手的工具。
|
||||
en: Linux, containers, and self-hosting — turning the little machines at home into handy tools.
|
||||
- icon: camera
|
||||
name:
|
||||
zh: 摄影
|
||||
en: Photography
|
||||
desc:
|
||||
zh: 街拍与山野,随身一台无反,在等光的间隙按快门。
|
||||
en: Street and landscapes with a mirrorless camera, pressing the shutter while waiting for the light.
|
||||
|
||||
- type: prose
|
||||
title:
|
||||
zh: 本站如何构建
|
||||
en: How this site is built
|
||||
body:
|
||||
zh: 页面由 Astro 静态生成,不含运行时框架 JS;内容住在自托管 gitea——本地编辑器与 gitea 网页写的是同一个 git 仓库,push 之后由 homelab 自动构建发布;评论走自托管 Artalk。页脚 built from 一行链到本次构建对应的 commit,构建过程透明可追溯。
|
||||
en: Pages are statically generated by Astro with no runtime framework JS. Content lives on a self-hosted gitea — my local editor and the gitea web UI write to the same git repository, and each push is built and published automatically by the homelab. Comments run on self-hosted Artalk. The built-from line in the footer links to the commit behind this build, keeping the process transparent and traceable.
|
||||
|
||||
# ---- 二级回退(blocks 为空时使用)----
|
||||
intro:
|
||||
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 that land in the gallery.
|
||||
|
||||
# 页面级排版覆盖(层 2)
|
||||
typography:
|
||||
font: font-huiwen
|
||||
accent: c-red
|
||||
+294
-3
@@ -119,6 +119,23 @@ collections:
|
||||
fields:
|
||||
- label: 身份语
|
||||
name: tagline
|
||||
- label: 页面排版覆盖(可选)
|
||||
name: typography
|
||||
widget: object
|
||||
required: false
|
||||
hint: 本页正文默认字体 / 强调色 / 字号缩放(0.8–1.25);不填用全站默认,只影响本页
|
||||
fields:
|
||||
- label: 正文字体类
|
||||
name: font
|
||||
widget: select
|
||||
required: false
|
||||
options: [font-serif, font-kai, font-sans, font-mono, font-song, font-kai-sys, font-hei-sys, font-fangsong, font-huiwen, font-kuaile]
|
||||
- label: 强调色类
|
||||
name: accent
|
||||
widget: select
|
||||
required: false
|
||||
options: [ink-accent, ink-accent-soft, c-blue, c-blue-soft, c-teal, c-teal-soft, c-green, c-green-soft, c-yellow, c-yellow-soft, c-orange, c-orange-soft, c-red, c-red-soft, c-pink, c-pink-soft, c-purple, c-purple-soft]
|
||||
- { label: 字号缩放, name: scale, widget: number, required: false, value_type: float, min: 0.8, max: 1.25, step: 0.05 }
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string, required: false }
|
||||
@@ -183,20 +200,294 @@ collections:
|
||||
- name: about
|
||||
label: 关于页
|
||||
file: content/site/about.yaml
|
||||
# 块内图片上传落 content/site/assets/,yaml 写 ./assets/xxx(Astro 图片管线)
|
||||
media_folder: content/site/assets
|
||||
public_folder: ./assets
|
||||
fields:
|
||||
- label: 正文(markdown,多段)
|
||||
name: intro
|
||||
- label: 页面块(拖拽排序;每块可折叠)
|
||||
name: blocks
|
||||
widget: list
|
||||
typeKey: type
|
||||
required: false
|
||||
summary: '{{fields.type}}'
|
||||
hint: '点「+ 添加」选块型。关掉某块用该块内的「显示」开关'
|
||||
types:
|
||||
- label: 正文(markdown)
|
||||
name: prose
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
|
||||
- label: 小标题(可选)
|
||||
name: title
|
||||
widget: object
|
||||
required: false
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string, required: false }
|
||||
- { label: 英文, name: en, widget: string, required: false }
|
||||
- label: 正文(markdown)
|
||||
name: body
|
||||
widget: object
|
||||
hint: 渲染在「关于」标题下方、名片之上;支持段落/列表/粗斜体/链接;留空用默认文案
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: markdown, required: false }
|
||||
- { label: 英文, name: en, widget: markdown, required: false }
|
||||
- label: 宽度
|
||||
name: width
|
||||
widget: select
|
||||
required: false
|
||||
default: narrow
|
||||
options: [{ label: 版心宽度, value: narrow }, { label: 通栏, value: full }]
|
||||
- label: 版图(图标+名称+描述)
|
||||
name: tracks
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
|
||||
- label: 标题
|
||||
name: title
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string, required: false }
|
||||
- { label: 英文, name: en, widget: string, required: false }
|
||||
- label: 条目
|
||||
name: items
|
||||
widget: list
|
||||
summary: '{{fields.name.zh}}'
|
||||
fields:
|
||||
- label: 图标
|
||||
name: icon
|
||||
widget: select
|
||||
required: false
|
||||
options: [book, terminal, camera, code, git, rss, email, github, bilibili, x, star, link]
|
||||
hint: 白名单外或留空 → 显示首字占位
|
||||
- label: 名称
|
||||
name: name
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string, required: false }
|
||||
- { label: 英文, name: en, widget: string, required: false }
|
||||
- label: 描述
|
||||
name: desc
|
||||
widget: object
|
||||
required: false
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: text, required: false }
|
||||
- { label: 英文, name: en, widget: text, required: false }
|
||||
- label: 卡片(2/3 列)
|
||||
name: cards
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
|
||||
- label: 标题(可选)
|
||||
name: title
|
||||
widget: object
|
||||
required: false
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string, required: false }
|
||||
- { label: 英文, name: en, widget: string, required: false }
|
||||
- { label: 列数, name: columns, widget: number, required: false, default: 3, min: 2, max: 3, value_type: int }
|
||||
- label: 卡片条目
|
||||
name: items
|
||||
widget: list
|
||||
summary: '{{fields.title.zh}}'
|
||||
fields:
|
||||
- label: 标题
|
||||
name: title
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string, required: false }
|
||||
- { label: 英文, name: en, widget: string, required: false }
|
||||
- label: 描述
|
||||
name: desc
|
||||
widget: object
|
||||
required: false
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: text, required: false }
|
||||
- { label: 英文, name: en, widget: text, required: false }
|
||||
- { label: 链接(可选), name: href, widget: string, required: false }
|
||||
- label: 角标(可选)
|
||||
name: tag
|
||||
widget: object
|
||||
required: false
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string, required: false }
|
||||
- { label: 英文, name: en, widget: string, required: false }
|
||||
- label: 时间线
|
||||
name: timeline
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
|
||||
- label: 标题(可选)
|
||||
name: title
|
||||
widget: object
|
||||
required: false
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string, required: false }
|
||||
- { label: 英文, name: en, widget: string, required: false }
|
||||
- label: 条目
|
||||
name: items
|
||||
widget: list
|
||||
summary: '{{fields.time}} · {{fields.title.zh}}'
|
||||
fields:
|
||||
- { label: 时间, name: time, widget: string, hint: '自由文本,如 2024 — 2026' }
|
||||
- label: 标题
|
||||
name: title
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string, required: false }
|
||||
- { label: 英文, name: en, widget: string, required: false }
|
||||
- label: 描述(可选)
|
||||
name: desc
|
||||
widget: object
|
||||
required: false
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: text, required: false }
|
||||
- { label: 英文, name: en, widget: text, required: false }
|
||||
- label: 引文
|
||||
name: quote
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
|
||||
- label: 引文
|
||||
name: text
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: text, required: false }
|
||||
- { label: 英文, name: en, widget: text, required: false }
|
||||
- label: 出处(可选)
|
||||
name: cite
|
||||
widget: object
|
||||
required: false
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string, required: false }
|
||||
- { label: 英文, name: en, widget: string, required: false }
|
||||
- label: 图片
|
||||
name: figure
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
|
||||
- { label: 图片(必填,请用后台上传), name: image, widget: image, hint: '禁止填外链:只能上传(落 content/site/assets/),走 Astro 图片管线' }
|
||||
- { label: 替代文本(无障碍), name: alt, widget: string, required: false }
|
||||
- label: 图注(可选)
|
||||
name: caption
|
||||
widget: object
|
||||
required: false
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string, required: false }
|
||||
- { label: 英文, name: en, widget: string, required: false }
|
||||
- label: 链接清单
|
||||
name: linklist
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
|
||||
- label: 标题(可选)
|
||||
name: title
|
||||
widget: object
|
||||
required: false
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string, required: false }
|
||||
- { label: 英文, name: en, widget: string, required: false }
|
||||
- label: 链接条目
|
||||
name: items
|
||||
widget: list
|
||||
summary: '{{fields.label.zh}} → {{fields.href}}'
|
||||
fields:
|
||||
- label: 名称
|
||||
name: label
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string, required: false }
|
||||
- { label: 英文, name: en, widget: string, required: false }
|
||||
- { label: 链接, name: href, widget: string }
|
||||
- label: 备注(可选)
|
||||
name: note
|
||||
widget: object
|
||||
required: false
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string, required: false }
|
||||
- { label: 英文, name: en, widget: string, required: false }
|
||||
- label: 行动按钮
|
||||
name: cta
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
|
||||
- label: 说明文字
|
||||
name: text
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string, required: false }
|
||||
- { label: 英文, name: en, widget: string, required: false }
|
||||
- { label: 按钮链接, name: href, widget: string }
|
||||
- label: 按钮文字
|
||||
name: label
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string, required: false }
|
||||
- { label: 英文, name: en, widget: string, required: false }
|
||||
- label: 分隔
|
||||
name: divider
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 显示, name: enabled, widget: boolean, required: false, default: true }
|
||||
- label: 样式
|
||||
name: style
|
||||
widget: select
|
||||
required: false
|
||||
default: line
|
||||
options: [{ label: 细分隔线, value: line }, { label: 纯留白, value: space }]
|
||||
- label: 名片区开关
|
||||
name: layout
|
||||
widget: object
|
||||
required: false
|
||||
hint: 关掉则不显示头像/名字/社交(它们的数据在「主页设置」)
|
||||
fields:
|
||||
- { label: 显示名片区, name: showIdCard, widget: boolean, required: false, default: true }
|
||||
- label: 正文回退(blocks 为空时用,markdown,多段)
|
||||
name: intro
|
||||
widget: object
|
||||
required: false
|
||||
hint: 仅在「页面块」为空时渲染;与块同时存在时以块为准
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: markdown, required: false }
|
||||
- { label: 英文, name: en, widget: markdown, required: false }
|
||||
- label: 页面排版覆盖(可选)
|
||||
name: typography
|
||||
widget: object
|
||||
required: false
|
||||
hint: 本页正文默认字体 / 强调色 / 字号缩放(0.8–1.25);不填用全站默认,只影响本页
|
||||
fields:
|
||||
- label: 正文字体类
|
||||
name: font
|
||||
widget: select
|
||||
required: false
|
||||
options: [font-serif, font-kai, font-sans, font-mono, font-song, font-kai-sys, font-hei-sys, font-fangsong, font-huiwen, font-kuaile]
|
||||
- label: 强调色类
|
||||
name: accent
|
||||
widget: select
|
||||
required: false
|
||||
options: [ink-accent, ink-accent-soft, c-blue, c-blue-soft, c-teal, c-teal-soft, c-green, c-green-soft, c-yellow, c-yellow-soft, c-orange, c-orange-soft, c-red, c-red-soft, c-pink, c-pink-soft, c-purple, c-purple-soft]
|
||||
- { label: 字号缩放, name: scale, widget: number, required: false, value_type: float, min: 0.8, max: 1.25, step: 0.05 }
|
||||
- name: opensource
|
||||
label: 开源页
|
||||
file: content/site/opensource.yaml
|
||||
fields:
|
||||
- label: 叙述(链接前)
|
||||
name: blurb
|
||||
- label: 页面排版覆盖(可选)
|
||||
name: typography
|
||||
widget: object
|
||||
required: false
|
||||
hint: 本页正文默认字体 / 强调色 / 字号缩放(0.8–1.25);不填用全站默认,只影响本页
|
||||
fields:
|
||||
- label: 正文字体类
|
||||
name: font
|
||||
widget: select
|
||||
required: false
|
||||
options: [font-serif, font-kai, font-sans, font-mono, font-song, font-kai-sys, font-hei-sys, font-fangsong, font-huiwen, font-kuaile]
|
||||
- label: 强调色类
|
||||
name: accent
|
||||
widget: select
|
||||
required: false
|
||||
options: [ink-accent, ink-accent-soft, c-blue, c-blue-soft, c-teal, c-teal-soft, c-green, c-green-soft, c-yellow, c-yellow-soft, c-orange, c-orange-soft, c-red, c-red-soft, c-pink, c-pink-soft, c-purple, c-purple-soft]
|
||||
- { label: 字号缩放, name: scale, widget: number, required: false, value_type: float, min: 0.8, max: 1.25, step: 0.05 }
|
||||
widget: object
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string, required: false }
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
// 块统一渲染器:switch(type) → 子组件;外壳带 data-block 属性(E2E 断言抓手)
|
||||
import type { ResolvedBlock } from '../../lib/page-blocks';
|
||||
import Prose from './Prose.astro';
|
||||
import Tracks from './Tracks.astro';
|
||||
import Cards from './Cards.astro';
|
||||
import Timeline from './Timeline.astro';
|
||||
import Quote from './Quote.astro';
|
||||
import Figure from './Figure.astro';
|
||||
import LinkList from './LinkList.astro';
|
||||
import Cta from './Cta.astro';
|
||||
import Divider from './Divider.astro';
|
||||
|
||||
interface Props {
|
||||
block: ResolvedBlock;
|
||||
}
|
||||
const { block } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="blk" data-block={block.type}>
|
||||
{block.type === 'prose' && <Prose block={block} />}
|
||||
{block.type === 'tracks' && <Tracks block={block} />}
|
||||
{block.type === 'cards' && <Cards block={block} />}
|
||||
{block.type === 'timeline' && <Timeline block={block} />}
|
||||
{block.type === 'quote' && <Quote block={block} />}
|
||||
{block.type === 'figure' && <Figure block={block} />}
|
||||
{block.type === 'linklist' && <LinkList block={block} />}
|
||||
{block.type === 'cta' && <Cta block={block} />}
|
||||
{block.type === 'divider' && <Divider block={block} />}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.blk { margin-block: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
// cards 块:技能/资源卡(2 或 3 列;复用 .track-row 的边框与内距语言)
|
||||
import type { ResolvedBlock } from '../../lib/page-blocks';
|
||||
|
||||
interface Props {
|
||||
block: Extract<ResolvedBlock, { type: 'cards' }>;
|
||||
}
|
||||
const { block } = Astro.props;
|
||||
---
|
||||
|
||||
{block.title && <h2 class="sec-title">{block.title}</h2>}
|
||||
<ul class:list={['cards', `cols-${block.columns}`]}>
|
||||
{block.items.map((item) => {
|
||||
const body = (
|
||||
<>
|
||||
{item.tag && <span class="card-tag mono">{item.tag}</span>}
|
||||
<strong class="card-title">{item.title}</strong>
|
||||
{item.desc && <p class="card-desc">{item.desc}</p>}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li class="card">
|
||||
{item.href ? (
|
||||
<a class="card-link" href={item.href}>{body}</a>
|
||||
) : (
|
||||
<div class="card-link">{body}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.sec-title {
|
||||
font-size: 14px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--ink-3);
|
||||
letter-spacing: 0.08em;
|
||||
margin: var(--space-5) 0 var(--space-3);
|
||||
}
|
||||
.cards {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
@media (max-width: 640px) {
|
||||
.cols-2, .cols-3 { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
display: flex;
|
||||
}
|
||||
.card-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
}
|
||||
a.card-link:hover .card-title { color: var(--accent); }
|
||||
.card-tag {
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-4);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.card-title { font-size: 15px; color: var(--ink-1); transition: color var(--dur) var(--ease); }
|
||||
.card-desc { font-size: 14px; color: var(--ink-3); margin-top: 2px; }
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
// cta 块:单按钮行动点(克制:同屏最多 1 个,由编辑纪律保证)
|
||||
import type { ResolvedBlock } from '../../lib/page-blocks';
|
||||
|
||||
interface Props {
|
||||
block: Extract<ResolvedBlock, { type: 'cta' }>;
|
||||
}
|
||||
const { block } = Astro.props;
|
||||
const isExternal = block.href.startsWith('http');
|
||||
---
|
||||
|
||||
<div class="cta">
|
||||
{block.text && <p class="cta-text">{block.text}</p>}
|
||||
<a
|
||||
class="cta-btn tap mono"
|
||||
href={block.href}
|
||||
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||
target={isExternal ? '_blank' : undefined}
|
||||
>
|
||||
{block.label}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-4) 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
max-width: var(--measure);
|
||||
}
|
||||
.cta-text { font-size: 14.5px; color: var(--ink-2); }
|
||||
.cta-btn {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
color: var(--ink-1);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.32rem 0.9rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cta-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
// divider 块:节奏控制(line = hairline 分隔;space = 纯留白)
|
||||
import type { ResolvedBlock } from '../../lib/page-blocks';
|
||||
|
||||
interface Props {
|
||||
block: Extract<ResolvedBlock, { type: 'divider' }>;
|
||||
}
|
||||
const { block } = Astro.props;
|
||||
---
|
||||
|
||||
{block.style === 'space' ? <div class="gap" aria-hidden="true"></div> : <hr />}
|
||||
|
||||
<style>
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
margin: var(--space-5) 0;
|
||||
}
|
||||
.gap { height: var(--space-6); }
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
// figure 块:图片(Astro 图片管线,宽度自适应;caption 图注)
|
||||
import { Image } from 'astro:assets';
|
||||
import type { ResolvedBlock } from '../../lib/page-blocks';
|
||||
|
||||
interface Props {
|
||||
block: Extract<ResolvedBlock, { type: 'figure' }>;
|
||||
}
|
||||
const { block } = Astro.props;
|
||||
---
|
||||
|
||||
<figure class="fig">
|
||||
<Image
|
||||
src={block.image}
|
||||
alt={block.alt}
|
||||
widths={[480, 960]}
|
||||
sizes="(min-width: 820px) 640px, 92vw"
|
||||
loading="lazy"
|
||||
class="fig-img"
|
||||
/>
|
||||
{block.caption && <figcaption>{block.caption}</figcaption>}
|
||||
</figure>
|
||||
|
||||
<style>
|
||||
.fig { margin: var(--space-4) 0; }
|
||||
.fig-img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
figcaption {
|
||||
margin-top: var(--space-1);
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-4);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
// linklist 块:外链清单(比裸 markdown 链接可控:带 note 与统一行距)
|
||||
import type { ResolvedBlock } from '../../lib/page-blocks';
|
||||
|
||||
interface Props {
|
||||
block: Extract<ResolvedBlock, { type: 'linklist' }>;
|
||||
}
|
||||
const { block } = Astro.props;
|
||||
|
||||
const isExternal = (href: string) => href.startsWith('http');
|
||||
---
|
||||
|
||||
{block.title && <h2 class="sec-title">{block.title}</h2>}
|
||||
<ul class="links">
|
||||
{block.items.map((item) => (
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
rel={isExternal(item.href) ? 'noopener noreferrer' : undefined}
|
||||
target={isExternal(item.href) ? '_blank' : undefined}
|
||||
>
|
||||
<span class="link-label">{item.label}</span>
|
||||
{item.note && <span class="link-note">{item.note}</span>}
|
||||
{isExternal(item.href) && <span class="link-mark" aria-hidden="true">↗</span>}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.sec-title {
|
||||
font-size: 14px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--ink-3);
|
||||
letter-spacing: 0.08em;
|
||||
margin: var(--space-5) 0 var(--space-3);
|
||||
}
|
||||
.links {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-width: var(--measure);
|
||||
}
|
||||
.links li { border-bottom: 1px solid var(--line); }
|
||||
.links li:last-child { border-bottom: none; }
|
||||
.links a {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0;
|
||||
color: var(--ink-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
.links a:hover .link-label { color: var(--accent); }
|
||||
.link-label { font-size: 15px; transition: color var(--dur) var(--ease); }
|
||||
.link-note { font-size: 13px; color: var(--ink-4); flex: 1; }
|
||||
.link-mark { font-size: 12px; color: var(--ink-4); }
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
// prose 块:自由正文(markdown 已由读取层渲染为 HTML,净化在 site-content 的插件链)
|
||||
import type { ResolvedBlock } from '../../lib/page-blocks';
|
||||
|
||||
interface Props {
|
||||
block: Extract<ResolvedBlock, { type: 'prose' }>;
|
||||
}
|
||||
const { block } = Astro.props;
|
||||
---
|
||||
|
||||
{block.title && <h2 class="sec-title">{block.title}</h2>}
|
||||
<div class:list={['prose-body', { full: block.width === 'full' }]} set:html={block.bodyHtml} />
|
||||
|
||||
<style>
|
||||
.sec-title {
|
||||
font-size: 14px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--ink-3);
|
||||
letter-spacing: 0.08em;
|
||||
margin: var(--space-5) 0 var(--space-3);
|
||||
}
|
||||
.prose-body {
|
||||
font-size: 15.5px;
|
||||
line-height: 1.75;
|
||||
color: var(--ink-2);
|
||||
max-width: var(--measure);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.prose-body.full { max-width: none; }
|
||||
.prose-body :global(p) { margin-block: 0 var(--space-2); }
|
||||
.prose-body :global(ul) {
|
||||
margin: var(--space-1) 0 var(--space-2);
|
||||
padding-left: 1.2em;
|
||||
list-style: disc;
|
||||
}
|
||||
.prose-body :global(li) { margin-block: 0.2em; }
|
||||
.prose-body :global(a) { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.prose-body :global(strong) { color: var(--ink-1); font-weight: 600; }
|
||||
.prose-body :global(code) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.88em;
|
||||
background: var(--bg-soft);
|
||||
border-radius: 5px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
// quote 块:引文(复用 .quote-line 语言 + 楷体)
|
||||
import type { ResolvedBlock } from '../../lib/page-blocks';
|
||||
|
||||
interface Props {
|
||||
block: Extract<ResolvedBlock, { type: 'quote' }>;
|
||||
}
|
||||
const { block } = Astro.props;
|
||||
---
|
||||
|
||||
<blockquote class="quote-line">
|
||||
<p>{block.text}</p>
|
||||
{block.cite && <cite>— {block.cite}</cite>}
|
||||
</blockquote>
|
||||
|
||||
<style>
|
||||
.quote-line {
|
||||
margin: var(--space-4) 0;
|
||||
padding-left: var(--space-4);
|
||||
border-left: 2px solid var(--line);
|
||||
max-width: var(--measure);
|
||||
}
|
||||
.quote-line p {
|
||||
font-family: var(--font-kai, var(--font-serif));
|
||||
font-size: 16.5px;
|
||||
line-height: 1.8;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.quote-line cite {
|
||||
display: block;
|
||||
margin-top: var(--space-1);
|
||||
font-style: normal;
|
||||
font-size: 13px;
|
||||
color: var(--ink-4);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
// timeline 块:经历/项目时间线(竖线语言,仅用既有 token)
|
||||
import type { ResolvedBlock } from '../../lib/page-blocks';
|
||||
|
||||
interface Props {
|
||||
block: Extract<ResolvedBlock, { type: 'timeline' }>;
|
||||
}
|
||||
const { block } = Astro.props;
|
||||
---
|
||||
|
||||
{block.title && <h2 class="sec-title">{block.title}</h2>}
|
||||
<ol class="tl">
|
||||
{block.items.map((item) => (
|
||||
<li class="tl-item">
|
||||
<span class="tl-time mono">{item.time}</span>
|
||||
<div class="tl-body">
|
||||
<strong>{item.title}</strong>
|
||||
{item.desc && <p>{item.desc}</p>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<style>
|
||||
.sec-title {
|
||||
font-size: 14px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--ink-3);
|
||||
letter-spacing: 0.08em;
|
||||
margin: var(--space-5) 0 var(--space-3);
|
||||
}
|
||||
.tl {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 0 var(--space-3);
|
||||
border-left: 1px solid var(--line);
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.tl-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: baseline;
|
||||
}
|
||||
.tl-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: calc(-1 * var(--space-3) - 4px);
|
||||
top: 0.55em;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--line);
|
||||
border: 1px solid var(--bg-1, transparent);
|
||||
}
|
||||
.tl-time {
|
||||
flex: none;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-4);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 5.5em;
|
||||
}
|
||||
.tl-body strong { font-size: 15px; color: var(--ink-1); }
|
||||
.tl-body p { font-size: 14px; color: var(--ink-3); margin-top: 2px; }
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
// tracks 块:兴趣/能力版图(复用原 AboutPage 的 .track-row 视觉语言)
|
||||
import type { ResolvedBlock } from '../../lib/page-blocks';
|
||||
import { iconPath } from '../../lib/icons';
|
||||
|
||||
interface Props {
|
||||
block: Extract<ResolvedBlock, { type: 'tracks' }>;
|
||||
}
|
||||
const { block } = Astro.props;
|
||||
---
|
||||
|
||||
<h2 class="sec-title">{block.title}</h2>
|
||||
<ul>
|
||||
{block.items.map((item) => (
|
||||
<li class="track-row">
|
||||
<span class="track-icon" aria-hidden="true">
|
||||
{iconPath(item.icon) ? (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d={iconPath(item.icon)}></path>
|
||||
</svg>
|
||||
) : (
|
||||
<span class="track-icon-fallback">{item.name.slice(0, 1)}</span>
|
||||
)}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{item.name}</strong>
|
||||
{item.desc && <p>{item.desc}</p>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.sec-title {
|
||||
font-size: 14px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--ink-3);
|
||||
letter-spacing: 0.08em;
|
||||
margin: var(--space-5) 0 var(--space-3);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.track-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
.track-icon {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-soft);
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.track-icon-fallback { font-family: var(--font-mono); font-size: 13px; }
|
||||
.track-row strong { font-size: 15px; color: var(--ink-1); }
|
||||
.track-row p { font-size: 14px; color: var(--ink-3); margin-top: 2px; }
|
||||
</style>
|
||||
@@ -3,21 +3,34 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import IdCard from '../../components/IdCard.astro';
|
||||
import { t } from '../../i18n/ui';
|
||||
import type { Locale } from '../../lib/locale';
|
||||
import { aboutIntroMarkdown, renderMarkdown } from '../../lib/site-content';
|
||||
import { aboutIntroMarkdown, pageTypography, renderMarkdown } from '../../lib/site-content';
|
||||
import { getAboutPage } from '../../lib/page-blocks';
|
||||
import BlockRenderer from '../blocks/BlockRenderer.astro';
|
||||
|
||||
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
|
||||
interface Props {
|
||||
locale?: Locale;
|
||||
}
|
||||
const { locale = 'zh' } = Astro.props;
|
||||
// 身份陈述段改由 content/site/about.yaml 供给(多段 markdown,后台可改),缺值回落默认
|
||||
const introHtml = await renderMarkdown(await aboutIntroMarkdown(locale));
|
||||
// 三级回落(零回归):blocks 驱动 → intro markdown → 内置默认文案
|
||||
const { blocks, showIdCard } = await getAboutPage(locale);
|
||||
const introHtml =
|
||||
blocks.length === 0 ? await renderMarkdown(await aboutIntroMarkdown(locale)) : '';
|
||||
const typo = await pageTypography('siteAbout', 'about');
|
||||
---
|
||||
|
||||
<BaseLayout title={`${t('about_title', locale)} · wait4x`}>
|
||||
<h1 class="page-title">{t('about_title', locale)}</h1>
|
||||
|
||||
<div class="intro" set:html={introHtml} />
|
||||
<div class={typo.className || undefined} style={typo.style || undefined}>
|
||||
{blocks.length > 0 ? (
|
||||
<>
|
||||
{blocks.map((block) => <BlockRenderer block={block} />)}
|
||||
{showIdCard && <IdCard />}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div class="intro rich" set:html={introHtml} />
|
||||
|
||||
<IdCard />
|
||||
|
||||
@@ -67,6 +80,9 @@ const introHtml = await renderMarkdown(await aboutIntroMarkdown(locale));
|
||||
<h2 class="sec-title">{t('about_build_title', locale)}</h2>
|
||||
<p>{t('about_build_desc', locale)}</p>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
|
||||
+122
-2
@@ -18,6 +18,92 @@ export type PostTrack = (typeof POST_TRACKS)[PostCategory];
|
||||
/** 双语文案字段:两语言各自可选,缺一回落一(回退逻辑在 src/lib/site-content.ts) */
|
||||
const bilingual = z.object({ zh: z.string().optional(), en: z.string().optional() });
|
||||
|
||||
/** 块型清单(5g 页面构建器 v1 九类):访问器据此判未知 type */
|
||||
export const BLOCK_TYPES = [
|
||||
'prose',
|
||||
'tracks',
|
||||
'cards',
|
||||
'timeline',
|
||||
'quote',
|
||||
'figure',
|
||||
'linklist',
|
||||
'cta',
|
||||
'divider',
|
||||
] as const;
|
||||
|
||||
type ImageHelper = (opts?: unknown) => z.ZodTypeAny;
|
||||
|
||||
/** 关于页块型 union:图片字段强制 image()(外链字符串在 schema 层即失败,永不渲染) */
|
||||
const aboutBlockUnion = (image: ImageHelper) =>
|
||||
z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('prose'),
|
||||
enabled: z.boolean().optional(),
|
||||
title: bilingual.optional(),
|
||||
body: bilingual,
|
||||
width: z.enum(['narrow', 'full']).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('tracks'),
|
||||
enabled: z.boolean().optional(),
|
||||
title: bilingual,
|
||||
items: z.array(
|
||||
z.object({ icon: z.string().optional(), name: bilingual, desc: bilingual.optional() }),
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('cards'),
|
||||
enabled: z.boolean().optional(),
|
||||
title: bilingual.optional(),
|
||||
columns: z.union([z.literal(2), z.literal(3)]).optional(),
|
||||
items: z.array(
|
||||
z.object({
|
||||
title: bilingual,
|
||||
desc: bilingual.optional(),
|
||||
href: z.string().optional(),
|
||||
tag: bilingual.optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('timeline'),
|
||||
enabled: z.boolean().optional(),
|
||||
title: bilingual.optional(),
|
||||
items: z.array(z.object({ time: z.string(), title: bilingual, desc: bilingual.optional() })),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('quote'),
|
||||
enabled: z.boolean().optional(),
|
||||
text: bilingual,
|
||||
cite: bilingual.optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('figure'),
|
||||
enabled: z.boolean().optional(),
|
||||
image: image(),
|
||||
alt: z.string().optional(),
|
||||
caption: bilingual.optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('linklist'),
|
||||
enabled: z.boolean().optional(),
|
||||
title: bilingual.optional(),
|
||||
items: z.array(z.object({ label: bilingual, href: z.string(), note: bilingual.optional() })),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('cta'),
|
||||
enabled: z.boolean().optional(),
|
||||
text: bilingual,
|
||||
href: z.string(),
|
||||
label: bilingual,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('divider'),
|
||||
enabled: z.boolean().optional(),
|
||||
style: z.enum(['line', 'space']).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export function trackOf(category: PostCategory): PostTrack {
|
||||
return POST_TRACKS[category];
|
||||
}
|
||||
@@ -82,6 +168,14 @@ export const collections = {
|
||||
z.object({
|
||||
tagline: bilingual.optional(),
|
||||
avatar: image().optional(),
|
||||
/** 页面级排版覆盖(层 2):正文默认字体类 / 强调色类 / 字号缩放;不配置即全站默认 */
|
||||
typography: z
|
||||
.object({
|
||||
font: z.string().optional(),
|
||||
accent: z.string().optional(),
|
||||
scale: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
/** 站点图标:public 下的路径(.svg/.ico 直出,不经图片管线);缺省回退 /favicon.svg */
|
||||
favicon: z.string().optional(),
|
||||
/** 副标题(markdown 短文本,渲染在身份语下);空 = 不渲染 */
|
||||
@@ -113,14 +207,40 @@ export const collections = {
|
||||
}),
|
||||
siteAbout: defineCollection({
|
||||
loader: glob({ pattern: 'about.yaml', base: './content/site' }),
|
||||
// intro = 多段 markdown 正文(页面用 createMarkdownProcessor 渲染),字段级空值回落默认
|
||||
schema: z.object({ intro: bilingual.optional() }),
|
||||
// intro = 多段 markdown 正文(二级回退:blocks 为空时才用)
|
||||
// blocks = 块式页面构建器(5g):文本一律 {zh,en},图片字段必须 image();未知 type 落宽松分支
|
||||
// → src/lib/page-blocks.ts warn + skip,保证后台填错块型不炸构建
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
intro: bilingual.optional(),
|
||||
/** 页面级排版覆盖(层 2,T3 车道):正文默认字体类 / 强调色类 / 字号缩放 */
|
||||
typography: z
|
||||
.object({
|
||||
font: z.string().optional(),
|
||||
accent: z.string().optional(),
|
||||
scale: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
/** 版式:是否渲染名片区(头像/名字/社交),默认 true */
|
||||
layout: z.object({ showIdCard: z.boolean().optional() }).optional(),
|
||||
blocks: z
|
||||
.array(z.union([aboutBlockUnion(image), z.looseObject({ type: z.string() })]))
|
||||
.optional(),
|
||||
}),
|
||||
}),
|
||||
siteOpenSource: defineCollection({
|
||||
loader: glob({ pattern: 'opensource.yaml', base: './content/site' }),
|
||||
schema: z.object({
|
||||
blurb: bilingual.optional(),
|
||||
blurb_after: bilingual.optional(),
|
||||
/** 页面级排版覆盖(层 2):正文默认字体类 / 强调色类 / 字号缩放;不配置即全站默认 */
|
||||
typography: z
|
||||
.object({
|
||||
font: z.string().optional(),
|
||||
accent: z.string().optional(),
|
||||
scale: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
repo_url: z.string().optional(),
|
||||
repo_label: z.string().optional(),
|
||||
/** 置顶仓短名,顺序即展示顺序(原 projects.ts 硬编码名单迁于此) */
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// 图标白名单注册表(块式页面用):键 → 24×24 viewBox 的 path d 片段
|
||||
// 纪律:后台只能填白名单内的键,未登记 → 组件渲染纯文字(不破版、不引入 XSS 面)
|
||||
// 只在代码侧扩展;不要在 yaml 里塞任意 SVG
|
||||
export const ICON_PATHS: Record<string, string> = {
|
||||
book: 'M4 4.5h5.5A2.5 2.5 0 0 1 12 7v13a2 2 0 0 0-2-2H4zM20 4.5h-5.5A2.5 2.5 0 0 0 12 7v13a2 2 0 0 1 2-2h6z',
|
||||
terminal: 'M4 5.5h16a1.5 1.5 0 0 1 1.5 1.5v10A1.5 1.5 0 0 1 20 18.5H4A1.5 1.5 0 0 1 2.5 17V7A1.5 1.5 0 0 1 4 5.5Zm3 3.5 4 3.5-4 3.5M12.5 16h6',
|
||||
camera:
|
||||
'M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2zM12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z',
|
||||
code: 'm8 7-4 5 4 5M16 7l4 5-4 5M13.5 4.5l-3 15',
|
||||
git: 'M12 3v10.5M12 21a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm-4-12 4 4 4-4',
|
||||
rss: 'M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16M5 19a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z',
|
||||
email: 'M3 7.5A2.5 2.5 0 0 1 5.5 5h13A2.5 2.5 0 0 1 21 7.5v9A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5zM4 7.5l8 5.5 8-5.5',
|
||||
github:
|
||||
'M12 2C6.48 2 2 6.58 2 12.25c0 4.53 2.87 8.37 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-.88-.01-1.73-2.78.62-3.37-1.37-3.37-1.37-.45-1.18-1.11-1.5-1.11-1.5-.91-.63.07-.62.07-.62 1 .07 1.53 1.06 1.53 1.06.89 1.57 2.34 1.12 2.91.85.09-.66.35-1.11.63-1.37-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.7 0 0 .84-.28 2.75 1.05a9.3 9.3 0 0 1 5 0c1.91-1.33 2.75-1.05 2.75-1.05.55 1.4.2 2.44.1 2.7.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.8-4.57 5.06.36.32.68.94.68 1.9 0 1.37-.01 2.47-.01 2.81 0 .27.18.6.69.49A10.26 10.26 0 0 0 22 12.25C22 6.58 17.52 2 12 2Z',
|
||||
bilibili:
|
||||
'M6 4l2 2h8l2-2M4 8h16a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2Zm3 4v4m10-4v4',
|
||||
x: 'M4 4l7.5 9.5L4.5 20h2.2l6-6.7 5 6.7H22l-7.7-9.9L21 4h-2.2l-5.4 6L9 4z',
|
||||
star: 'm12 3.5 2.7 5.6 6.1.9-4.4 4.3 1 6.1-5.4-2.9-5.4 2.9 1-6.1L3.2 10l6.1-.9z',
|
||||
link: 'M10 13.5a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.3 1.3M14 10.5a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.3-1.3',
|
||||
};
|
||||
|
||||
/** 白名单内的图标 path;未登记返回 null(调用方回退纯文字) */
|
||||
export const iconPath = (key?: string): string | null =>
|
||||
key && ICON_PATHS[key] ? ICON_PATHS[key] : null;
|
||||
@@ -0,0 +1,216 @@
|
||||
// 页面块读取层(5g T1):siteAbout.blocks → 已本地化、已净化、已限长的渲染数据
|
||||
// 纪律(方案文档 §5.2/§7.4):
|
||||
// · enabled:false 过滤
|
||||
// · 未知 type / 缺必填文本 → console.warn + 跳过,绝不炸构建
|
||||
// · 块数 ≤24、单块文本 ≤4000 字(超限截断并 WARN)
|
||||
// · 文本 pickBi:en 空回落 zh(构建期缺译 WARN,见 §7.3 #8)
|
||||
import { getEntry } from 'astro:content';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { BLOCK_TYPES } from '../content.config';
|
||||
import { renderMarkdown } from './site-content';
|
||||
import type { Locale } from './locale';
|
||||
|
||||
const MAX_BLOCKS = 24;
|
||||
const MAX_TEXT = 4000;
|
||||
|
||||
type Bi = { zh?: string; en?: string } | undefined;
|
||||
|
||||
/** 双语取值:locale 优先,空则回落另一语言;再空返回 '' */
|
||||
function bi(value: Bi, locale: Locale, where: string): string {
|
||||
if (!value) return '';
|
||||
const primary = (locale === 'zh' ? value.zh : value.en)?.trim();
|
||||
const other = (locale === 'zh' ? value.en : value.zh)?.trim();
|
||||
if (primary) return primary;
|
||||
if (other) {
|
||||
console.warn(`[blocks] 缺译(${locale} 为空,回落另一语言):${where}`);
|
||||
return other;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const clampText = (text: string, where: string): string => {
|
||||
if (text.length <= MAX_TEXT) return text;
|
||||
console.warn(`[blocks] 文本超 ${MAX_TEXT} 字已截断:${where}`);
|
||||
return text.slice(0, MAX_TEXT);
|
||||
};
|
||||
|
||||
export type ResolvedBlock =
|
||||
| { type: 'prose'; title: string; bodyHtml: string; width: 'narrow' | 'full' }
|
||||
| { type: 'tracks'; title: string; items: { icon: string; name: string; desc: string }[] }
|
||||
| {
|
||||
type: 'cards';
|
||||
title: string;
|
||||
columns: 2 | 3;
|
||||
items: { title: string; desc: string; href: string; tag: string }[];
|
||||
}
|
||||
| { type: 'timeline'; title: string; items: { time: string; title: string; desc: string }[] }
|
||||
| { type: 'quote'; text: string; cite: string }
|
||||
| { type: 'figure'; image: ImageMetadata; alt: string; caption: string }
|
||||
| { type: 'linklist'; title: string; items: { label: string; href: string; note: string }[] }
|
||||
| { type: 'cta'; text: string; href: string; label: string }
|
||||
| { type: 'divider'; style: 'line' | 'space' };
|
||||
|
||||
export interface AboutPageData {
|
||||
blocks: ResolvedBlock[];
|
||||
showIdCard: boolean;
|
||||
}
|
||||
|
||||
type RawItem = Record<string, unknown>;
|
||||
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
|
||||
|
||||
/** 关于页渲染数据(三级回落由 AboutPage 处理:blocks → intro markdown → 默认文案) */
|
||||
export async function getAboutPage(locale: Locale): Promise<AboutPageData> {
|
||||
const entry = await getEntry('siteAbout', 'about');
|
||||
const rawBlocks = (entry?.data.blocks ?? []) as unknown[];
|
||||
const showIdCard = entry?.data.layout?.showIdCard ?? true;
|
||||
|
||||
if (rawBlocks.length > MAX_BLOCKS) {
|
||||
console.warn(`[blocks] 块数 ${rawBlocks.length} 超上限 ${MAX_BLOCKS},仅渲染前 ${MAX_BLOCKS} 个`);
|
||||
}
|
||||
|
||||
const blocks: ResolvedBlock[] = [];
|
||||
for (const raw of rawBlocks.slice(0, MAX_BLOCKS)) {
|
||||
if (!raw || typeof raw !== 'object') continue;
|
||||
const block = raw as RawItem & { type?: string; enabled?: boolean };
|
||||
if (block.enabled === false) continue;
|
||||
const type = block.type ?? '';
|
||||
if (!(BLOCK_TYPES as readonly string[]).includes(type)) {
|
||||
console.warn(`[blocks] 未知块型 "${type}",已跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tag = `${type}`;
|
||||
if (type === 'prose') {
|
||||
const md = clampText(bi(block.body as Bi, locale, `${tag}.body`), `${tag}.body`);
|
||||
if (!md) {
|
||||
console.warn(`[blocks] prose 缺正文,已跳过`);
|
||||
continue;
|
||||
}
|
||||
blocks.push({
|
||||
type: 'prose',
|
||||
title: bi(block.title as Bi, locale, `${tag}.title`),
|
||||
bodyHtml: await renderMarkdown(md),
|
||||
width: block.width === 'full' ? 'full' : 'narrow',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'tracks') {
|
||||
const items = ((block.items ?? []) as RawItem[])
|
||||
.map((item) => ({
|
||||
icon: str(item.icon),
|
||||
name: bi(item.name as Bi, locale, `${tag}.items.name`),
|
||||
desc: bi(item.desc as Bi, locale, `${tag}.items.desc`),
|
||||
}))
|
||||
.filter((item) => item.name);
|
||||
if (!items.length) {
|
||||
console.warn('[blocks] tracks 无有效条目,已跳过');
|
||||
continue;
|
||||
}
|
||||
blocks.push({ type: 'tracks', title: bi(block.title as Bi, locale, `${tag}.title`), items });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'cards') {
|
||||
const items = ((block.items ?? []) as RawItem[])
|
||||
.map((item) => ({
|
||||
title: bi(item.title as Bi, locale, `${tag}.items.title`),
|
||||
desc: bi(item.desc as Bi, locale, `${tag}.items.desc`),
|
||||
href: str(item.href),
|
||||
tag: bi(item.tag as Bi, locale, `${tag}.items.tag`),
|
||||
}))
|
||||
.filter((item) => item.title);
|
||||
if (!items.length) {
|
||||
console.warn('[blocks] cards 无有效条目,已跳过');
|
||||
continue;
|
||||
}
|
||||
blocks.push({
|
||||
type: 'cards',
|
||||
title: bi(block.title as Bi, locale, `${tag}.title`),
|
||||
columns: block.columns === 2 ? 2 : 3,
|
||||
items,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'timeline') {
|
||||
const items = ((block.items ?? []) as RawItem[])
|
||||
.map((item) => ({
|
||||
time: str(item.time),
|
||||
title: bi(item.title as Bi, locale, `${tag}.items.title`),
|
||||
desc: bi(item.desc as Bi, locale, `${tag}.items.desc`),
|
||||
}))
|
||||
.filter((item) => item.title);
|
||||
if (!items.length) {
|
||||
console.warn('[blocks] timeline 无有效条目,已跳过');
|
||||
continue;
|
||||
}
|
||||
blocks.push({ type: 'timeline', title: bi(block.title as Bi, locale, `${tag}.title`), items });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'quote') {
|
||||
const text = bi(block.text as Bi, locale, `${tag}.text`);
|
||||
if (!text) {
|
||||
console.warn('[blocks] quote 缺引用文本,已跳过');
|
||||
continue;
|
||||
}
|
||||
blocks.push({ type: 'quote', text, cite: bi(block.cite as Bi, locale, `${tag}.cite`) });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'figure') {
|
||||
const image = block.image as ImageMetadata | undefined;
|
||||
if (!image || typeof image !== 'object' || !('src' in image)) {
|
||||
// 外链字符串在此即被拦住:schema 的 image() 不允许字符串,落到宽松分支后在此丢弃
|
||||
console.warn('[blocks] figure 缺合法图片(image() 字段),已跳过——请用后台上传,勿填外链');
|
||||
continue;
|
||||
}
|
||||
blocks.push({
|
||||
type: 'figure',
|
||||
image,
|
||||
alt: str(block.alt) || bi(block.caption as Bi, locale, `${tag}.caption`) || '插图',
|
||||
caption: bi(block.caption as Bi, locale, `${tag}.caption`),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'linklist') {
|
||||
const items = ((block.items ?? []) as RawItem[])
|
||||
.filter((item) => str(item.href) && bi(item.label as Bi, locale, `${tag}.items.label`))
|
||||
.map((item) => ({
|
||||
label: bi(item.label as Bi, locale, `${tag}.items.label`),
|
||||
href: str(item.href),
|
||||
note: bi(item.note as Bi, locale, `${tag}.items.note`),
|
||||
}));
|
||||
if (!items.length) {
|
||||
console.warn('[blocks] linklist 无有效条目,已跳过');
|
||||
continue;
|
||||
}
|
||||
blocks.push({ type: 'linklist', title: bi(block.title as Bi, locale, `${tag}.title`), items });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'cta') {
|
||||
const href = str(block.href);
|
||||
const label = bi(block.label as Bi, locale, `${tag}.label`);
|
||||
if (!href || !label) {
|
||||
console.warn('[blocks] cta 缺 href/label,已跳过');
|
||||
continue;
|
||||
}
|
||||
blocks.push({
|
||||
type: 'cta',
|
||||
text: bi(block.text as Bi, locale, `${tag}.text`),
|
||||
href,
|
||||
label,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// divider
|
||||
blocks.push({ type: 'divider', style: block.style === 'space' ? 'space' : 'line' });
|
||||
}
|
||||
|
||||
return { blocks, showIdCard };
|
||||
}
|
||||
Reference in new issue
Block a user