From 65383113872f98c55f62e93f3e8dcdc9bd184ede Mon Sep 17 00:00:00 2001 From: wait4xx Date: Mon, 2 Nov 2026 20:02:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(content):=20=E7=BD=AE=E9=A1=B6=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E4=B8=8E=E8=AE=A1=E6=95=B0=E5=BE=BD=E6=A0=87=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=EF=BC=885h=20T1=20=E5=A5=91=E7=BA=A6=E9=83=A8?= =?UTF-8?q?=E5=88=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 置顶(T1): - src/lib/post-order.ts 新增 sortPosts:置顶优先 → pubDate 倒序 → id 兜底, 把首页双轨/归档/书架四处排序口径收敛到一处 - postsSchema 增 pinned;PostList 三处渲染路径加灰阶 pin 小徽标;后台置顶开关 - 语义边界:只影响列表顺序,不改 pubDate/URL/RSS 时间语义 计数徽标(T4 契约组件): - src/components/geek/ViewMeta.astro:眼睛 = 浏览数、气泡 = 评论数, 本地 SVG + tokens 着色,不引第三方图片 - 数据只用 Artalk 匿名端点:GET comments?limit=1 一次拿到 count 与 page.pv; page 模式先 POST pages/pv 计一次浏览,readonly 模式零写入 - SSR 占位「–」:无 JS/接口失败不显示 0 误导读者 验证: pnpm astro build 48 页 exit 0;config-check 208 字段/65 hint 全过; sortPosts 四调用点替换后构建通过 Spec: 2026-11-02_blog-5h-content-social (T1 契约) Co-authored-by: GLM-5.3 --- .../2026-11-02_blog-5h-content-social/spec.md | 91 +++++++++ .../tasks.md | 44 +++++ public/admin/config.yml | 1 + src/components/PostList.astro | 21 ++ src/components/geek/ViewMeta.astro | 179 ++++++++++++++++++ src/components/pages/BooksPage.astro | 6 +- src/components/pages/HomePage.astro | 8 +- src/content.config.ts | 2 + src/i18n/ui.ts | 3 + src/lib/post-order.ts | 31 +++ src/pages/archives/index.astro | 6 +- src/pages/en/archives/index.astro | 5 +- 12 files changed, 388 insertions(+), 9 deletions(-) create mode 100644 .spec/specs/2026-11-02_blog-5h-content-social/spec.md create mode 100644 .spec/specs/2026-11-02_blog-5h-content-social/tasks.md create mode 100644 src/components/geek/ViewMeta.astro create mode 100644 src/lib/post-order.ts diff --git a/.spec/specs/2026-11-02_blog-5h-content-social/spec.md b/.spec/specs/2026-11-02_blog-5h-content-social/spec.md new file mode 100644 index 0000000..e6dc659 --- /dev/null +++ b/.spec/specs/2026-11-02_blog-5h-content-social/spec.md @@ -0,0 +1,91 @@ +# Phase 5h:内容表达能力与社交反馈 - 项目范围 + + + +## 1. 问题定义 +- **项目目标**:①文章支持置顶(作者指定优先)②瞬间的文字与文章同级表达力(字体可换、颜色可换、多段落/列表/强调)③瞬间每条可独立评论 ④浏览量与评论数用统一的「眼睛/气泡」计数徽标呈现(对齐开源页 GitHub 档案的眼图标语汇)⑤开源页开篇从 GitHub README 直接移植导致:`
` 内联对齐失效、shields/vercel/snake/komarev 等外链图在慢网下空白破版、README 的「About Me」与开源页主题无关——重构为原生组件并从 README 解耦 ⑥关于页承接读完 README 后仍想保留的个人叙述 ⑦新增一篇「写作模板」示例文章,把可用排版能力一次演示完 +- **目标用户**:站长(写得更自由,少打字面 HTML)、读者(读到更丰富且不破版的页) +- **核心价值**:内容表达力与后台编辑力对上,同时把外链依赖与版式事故面砍掉;社交反馈(浏览/评论)在灰阶体系内可量化可见 + +## 2. 假设与待确认 + +### 2.1 已确认事实 +- Artalk v2 匿名可读:`GET /artalk/api/v2/comments?site_name=…&page_key=…&limit=1` 返回 `{count, roots_count, page:{pv}}`(生产实测,2026-11-02);`POST /artalk/api/v2/pages/pv` 返回 `{pv}`(现有组件在用) +- 富文本管线已存在且已净化:`renderRich`(markdown + rehype-content-sanitize 标签/属性/协议三层白名单)与 `rehype-class-whitelist`(35 项排版类,单一事实源 `public/admin/typography-classes.js`) +- 现有 6 自托管字族(含 font-huiwen 子集化 420KB 单页)与 16 彩色语义类 + 4 灰阶语义类 +- 开源页 README 现由 `intro_from: "off"` 关闭,缓存文件 `content/.github-readme.md`(3138B)含 6 类外链图(readme-typing-svg / shields.io / github-readme-stats / streak-stats / activity-graph / snake / komarev) +- 首页/归档/书架三处列表排序均为 `pubDate` 降序,无置顶概念(`grep pinned` 只命中开源页仓库白名单) + +### 2.2 关键假设 +- 瞬间富文本走与全站文案同一条净化管线即可满足「字体/颜色可换」且不引入 XSS 面(危险标签整棵丢弃、协议白名单 http/https/mailto) +- 每条瞬间一个 Artalk page_key(`/moments/#`)不会与页面级 pv 冲突;Artalk 实例按需挂载(展开才 init)可避免 "one page one Artalk" 的全局实例冲突 +- 开源页可用本地数据(已有 repo 元数据 + GitHub GraphQL 拉取)自绘 followers/stars/语言占比,无需任何第三方徽章服务 + +### 2.3 待确认问题 +- [x] 瞬间文字是否允许 HTML 类名(字体/颜色)→ 用户原话「字体可换、颜色可换等」,按富文本管线放行 35 项白名单类 +- [x] 开源页要不要与 README 完全一致 → 用户原话「可以自行进行美化,无需完全一致」,按原生组件重构 +- [x] About Me 类内容放哪 → 用户原话「有些可以直接移植到关于页面中」,迁到 about.yaml blocks + +### 2.4 可选解释与取舍 +- 置顶只作用于列表排序与视觉标记,不改 URL / 不改 pubDate 语义(订阅源按时间仍正确) +- 计数徽标一次只拉当前页的 pv(POST 计一次)与评论数(GET 读);瞬间列表按可视区懒拉,避免 N 次请求把首屏拖慢 +- 开源页保留 `intro_from` 开关作为 compat,但默认 off 且文档标注「不推荐」;README 渲染通道不删除(后台仍可切回) + +### 2.5 已证伪路径 +- 直接渲染 README HTML 并试图用 CSS 修 `align="center"`:内联属性与 sanitize 的交互不可控,且外链图仍会空白破版 → 弃,改原生组件 +- 每条瞬间都 init 一个 Artalk 实例常驻:内存与请求成本高、且 Artalk 对多实例的支持未验证 → 改为展开时挂载 +- 用第三方徽章服务显示访客数(komarev 等):外链依赖 + 灰阶体系外颜色 → 弃,用 Artalk pv 自绘 + +## 3. 功能范围 + +### 3.1 核心功能(MVP) +- [ ] T1 文章置顶:posts/postsEn schema `pinned`、四处列表排序(首页双轨/归档/书架)、视觉标记(灰阶 pin)、后台开关 +- [ ] T2 瞬间富文本:moments `text` 支持 markdown + 白名单排版类(字体/颜色/强调/多段/列表)、净化管线复用、后台改 markdown 编辑器、旧纯文本零回归 +- [ ] T3 瞬间逐条评论:每条 moment 独立 page_key 的 Artalk 评论区(折叠、展开时挂载)、评论数联动、双语页共用同一 key +- [ ] T4 计数徽标:新增 `ViewMeta` 组件(眼睛图标 = 浏览数、气泡图标 = 评论数),接入文章页/瞬间页/关于页/开源页 +- [ ] T5 开源页重构:原生 GitHub 档案卡(头像/名字/关注者/星标/语言占比,本地数据自绘)替换 README 外链图;`align` 失效与破图问题消失;README 通道降级为可选 +- [ ] T6 关于页承接:把 README 的 About Me / Find Me 迁为 about.yaml blocks(prose + linklist),开源页只留开源相关 +- [ ] T7 写作模板示例:中英双语示例文章,演示标题层级/彩色强调/字族/引用/代码/图/表/链接卡片/折叠,并作为置顶功能的活样本;docs 与后台 hint 同步 + +### 3.2 扩展功能 +- 无 + +### 3.3 不在范围内 +- 评论回复通知/邮件推送、点赞/投票、阅读进度、站点级总访问量统计(Artalk 无匿名聚合端点) + +## 4. 最小实现路径 +- 最简单可行方案:置顶先用 boolean + 排序键;瞬间富文本直接把 `text` 交给既有 `renderRich`;评论用现有 Artalk 组件克隆一份带折叠的;计数徽标只做 pv + count 两个数 +- 暂不引入:置顶权重/多级置顶、瞬间的图片轮播、评论楼中楼定制、开源页的实时 Star 曲线 +- 不做的抽象:通用「内容社交层」框架(只服务现有页面) + +## 5. 技术决策 +- 技术栈:Astro 7.4.4 + zod schema + Artalk v2 HTTP API + GitHub GraphQL(已有 token) +- 证据协议:evidence: runs +- 本轮允许改动:src/content.config.ts、src/components/{PostList,ArtalkComments}.astro、src/components/pages/{HomePage,MomentsPage,BooksPage}.astro、src/components/geek/{ViewMeta,GithubProfile}.astro(新)、src/pages/archives/[...slug].astro、src/pages/en/archives/[...slug].astro、src/pages/{about,opensource,en/about,en/opensource}.astro、src/lib/{projects,site-content,heatmap}.ts、content/site/{about,opensource}.yaml、content/posts/*(新增示例文章)、content/posts-en/*、public/admin/{config.yml,typography-classes.js}、docs/*、tests/e2e.mjs +- 本轮不应触碰:nginx/ESA、灯箱与图库(GalleryPage/PhotoZoom)、Header/Footer 结构、构建链顺序、tokens.css/global.css 基础尺度 +- Git integration branch:`spec/2026-11-02_blog-5h-content-social` + +### 5.1 验证策略 +- 范围级别:package +- 变更对象:内容模型(置顶/瞬间富文本)、渲染组件(计数/评论/档案卡)、后台配置、示例内容 +- 快速检查:`pnpm astro build`(node26)+ `python3 scripts/build-perf/config-check.py .` +- 集成检查:`E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs`(37 项,本包新增置顶顺序/徽标存在/瞬间富文本断言)+ content-lint 0/0/0 +- 全项目检查:生产实测四条(锁屏外链零依赖、瞬间评论可发可见、徽标数值与 Artalk 后台一致、关于页承接内容渲染) +- 升级触发:若动到 BaseLayout/全局样式或 Artalk 主题接管层,升级为全站视觉复核(glm-5.3-flash:max,≤6 图/轮) + +## 6. 成功标准与验证方式 +- [ ] 置顶文章排在列表首位且带可见标记,取消置顶后恢复时间序 -> verify: task_0001 +- [ ] 瞬间文字可用字体类与彩色强调,危险标签被净化丢弃,旧纯文本渲染不变 -> verify: task_0002 +- [ ] 每条瞬间可展开评论区并成功发帖,双语页共用同一评论流 -> verify: task_0003 +- [ ] 文章/瞬间/关于/开源四页显示「浏览数 + 评论数」徽标且数值与 Artalk 一致 -> verify: task_0004 +- [ ] 开源页零第三方图片请求,档案卡数据来自本地/GraphQL,居中与破图问题不再出现 -> verify: task_0005 +- [ ] README 的个人叙述出现在关于页,开源页只保留开源相关内容 -> verify: task_0006 +- [ ] 示例文章渲染全部演示能力且构建通过,docs/hint 同步 -> verify: task_0007 + +## 7. 风险与约束 +- 灰阶外壳红线不变:徽标图标与数字用 tokens(`--ink-*`/`--line`),不引入第三方彩色徽章 +- 彩色仅正文强调(≤3 处/页)——示例文章本身要守纪律,成为正面样本而非反例 +- 瞬间富文本必须复用 `renderRich` 净化链,禁止新增 set:html 直通路径 +- Artalk 多实例风险:评论区折叠 + 展开挂载,若实测冲突则退回「逐条跳转到锚点页」 +- 双语链路:所有新文案走 `t()`/`pickBi`,en 轨不得出现中文硬编码 +- 多车道并行:content.config.ts / public/admin/config.yml 由 orchestrator 先落契约再放行,车道只改各自 boundary 内文件 diff --git a/.spec/specs/2026-11-02_blog-5h-content-social/tasks.md b/.spec/specs/2026-11-02_blog-5h-content-social/tasks.md new file mode 100644 index 0000000..5653736 --- /dev/null +++ b/.spec/specs/2026-11-02_blog-5h-content-social/tasks.md @@ -0,0 +1,44 @@ +# Phase 5h:内容表达能力与社交反馈 - 任务拆解 + +## 使用规则 +- boundary/verify 齐备;verify 只读断言;车道工作树开发禁 git,收编由 orchestrator 统一提交 +- 构建一律 `export PATH="$HOME/.local/node/bin:$PATH"`(Node 26)+ `pnpm astro build` +- **契约先行**:`src/content.config.ts` 与 `public/admin/config.yml` 由 orchestrator 先落契约(T1 时一并提交),车道不得再改这两个文件;需要新字段一律回报 orchestrator +- E2E 口径:`pnpm astro build → cp -a dist /tmp/dist-e2e → 用 18925 端口起快照服务 → E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs` + +## 阶段一:实现 + +- [ ] T1 文章置顶(契约 + 排序 + 标记 + 后台) + - claim: orchestrator + - boundary: src/content.config.ts(postsSchema 加 `pinned: z.boolean().optional()`,posts/postsEn 共用)、src/components/PostList.astro(置顶优先排序 + pin 标记)、src/pages/archives/index.astro(排序)、src/components/pages/{HomePage,BooksPage}.astro(排序)、public/admin/config.yml(posts 集合 pinned 开关 + 中文 hint)、src/i18n/ui.ts(置顶标签) + - verify: `E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && grep -q "pinned" src/content.config.ts && grep -q "pinned" src/components/PostList.astro && grep -q "pinned" public/admin/config.yml` + +- [ ] T2 瞬间富文本(字体可换 / 颜色可换 / 多形式) + - claim: worker-one + - boundary: src/components/pages/MomentsPage.astro(`text` 由纯文本 `

` 改为走 `renderRich` 净化+排版类管线,保留 inline 语义;空文本老数据零回归)、public/admin/config.yml 中 moments 集合 `text` 字段改 markdown widget + 提示可用排版类(**仅在 orchestrator 提交的字段块内改 widget 类型,不改结构**)、docs 提示留给 T7 + - verify: `E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && grep -qE "renderRich|renderMarkdown" src/components/pages/MomentsPage.astro && python3 -c "import yaml;d=yaml.safe_load(open('public/admin/config.yml'));m=[c for c in d['collections'] if c['name']=='moments'][0];t=[f for f in m['fields'] if f['name']=='text'][0];assert t['widget']=='markdown',t"` + +- [ ] T3 瞬间逐条评论(Artalk 独立 page_key + 折叠懒挂载) + - claim: worker-one + - boundary: 新增 src/components/MomentComments.astro(折叠 disclosure,展开时首次挂载 Artalk;page_key 约定 `/moments/#`,en 页同一 key 不分裂;评论数徽标复用 T4 的 ViewMeta 计数接口)、src/components/pages/MomentsPage.astro(每条卡片挂评论区) + - verify: `test -f src/components/MomentComments.astro && grep -q "MomentComments" src/components/pages/MomentsPage.astro && grep -q "moments/#" src/components/MomentComments.astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"` + +- [ ] T4 计数徽标(眼睛图标 = 浏览数,气泡图标 = 评论数) + - claim: op-artalk + - boundary: src/components/geek/ViewMeta.astro(**契约组件,由 orchestrator 在 T1 提交时一并落盘**,车道只消费不改语义)、src/components/ArtalkComments.astro(pv 小字行改为 ViewMeta,评论数取 `comments?limit=1` 的 count)、src/pages/archives/[...slug].astro、src/pages/en/archives/[...slug].astro(挂 ViewMeta) + - verify: `test -f src/components/geek/ViewMeta.astro && grep -q "ViewMeta" src/components/ArtalkComments.astro && grep -q "ViewMeta" src/pages/archives/[...slug].astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"` + +- [ ] T5 开源页重构(原生 GitHub 档案卡,去外链图依赖) + - claim: op-gitea + - boundary: 新增 src/components/geek/GithubProfile.astro(头像/名字/简介/关注者/总星标/语言占比,数据来自 projects.ts 与 GraphQL;零第三方图片与徽章)、src/lib/projects.ts(补统计聚合:followers/stars/language 汇总,失败降级不炸构建)、src/pages/opensource.astro、src/pages/en/opensource.astro(用 GithubProfile 替换 readme 渲染段;README 通道保留开关但默认 off 且标注不推荐)、content/site/opensource.yaml(注释说明 intro_from 现状) + - verify: `grep -q "GithubProfile" src/pages/opensource.astro && python3 -c "import re,pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();bad=[u for u in ['img.shields.io','readme-typing-svg','github-readme-stats','streak-stats','komarev','github-readme-activity-graph'] if u in p];assert not bad,bad"` + +- [ ] T6 关于页承接 README 的个人叙述 + - claim: op-gitea + - boundary: content/site/about.yaml(新增 blocks:关于/兴趣 prose、站点与项目 linklist、Find Me 社交块;双语 zh/en;测试值不得留在文件里)、src/pages/about.astro 与 en(仅当块渲染需要时改,优先零改动) + - verify: `python3 -c "import yaml;d=yaml.safe_load(open('content/site/about.yaml'));types=[b['type'] for b in d.get('blocks') or []];assert 'prose' in types and ('linklist' in types or 'cards' in types),types" && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"` + +- [ ] T7 写作模板示例文章 + 文档与 hint 收尾 + - claim: orchestrator + - boundary: content/posts/writing-template.md、content/posts-en/writing-template.md(en 手写 locked:true 直译,不交给 TMT 重译)、docs/写作指南.md(模板用法 + 排版类清单去重指引)、docs/管理台.md(瞬间富文本/逐条评论/置顶/徽标说明)、public/admin/config.yml(moments text hint、posts pinned 已含) + - verify: `test -f content/posts/writing-template.md && test -f content/posts-en/writing-template.md && grep -q "pinned: true" content/posts/writing-template.md && grep -qE "font-kai|c-blue" content/posts/writing-template.md && grep -q "写作模板" docs/写作指南.md` diff --git a/public/admin/config.yml b/public/admin/config.yml index ccaabfa..0d7156d 100644 --- a/public/admin/config.yml +++ b/public/admin/config.yml @@ -38,6 +38,7 @@ collections: - { label: 日期, name: pubDate, widget: datetime, date_format: 'YYYY-MM-DD', time_format: false, format: 'YYYY-MM-DD' } - { label: 分轨, name: category, widget: select, options: [{label: 阅读, value: reading}, {label: 技术, value: tech}, {label: 照片, value: photo}] } - { label: 标签, name: tags, widget: list, required: false } + - { label: 置顶, name: pinned, widget: boolean, required: false, default: false, hint: '置顶文章排在首页/归档/书架最前;只影响顺序,不改日期、URL 与 RSS 时间语义' } - { label: 情绪色(书评色脊), name: mood, widget: color, required: false, hint: '#RRGGBB,仅阅读轨使用' } - { label: 原书作者, name: bookAuthor, widget: string, required: false } - { label: 一句话描述(书架策展语), name: description, widget: text, required: false } diff --git a/src/components/PostList.astro b/src/components/PostList.astro index ca23a24..50f39f6 100644 --- a/src/components/PostList.astro +++ b/src/components/PostList.astro @@ -89,6 +89,9 @@ const byMonth = (list: CollectionEntry<'posts'>[]) => { /> )} {titleOverrides?.[post.id] ?? post.data.title} + {post.data.pinned && ( + {t('pinned_label', locale)} + )} {key !== 'reading' && post.data.tags.map((tag) => {`#${tag}`})}

+ {showPv && ( + + + – + + )} + {showComments && ( + + + – + + )} +

+ + + + diff --git a/src/components/pages/BooksPage.astro b/src/components/pages/BooksPage.astro index f8348b4..eb77ced 100644 --- a/src/components/pages/BooksPage.astro +++ b/src/components/pages/BooksPage.astro @@ -5,13 +5,15 @@ import { getCollection } from 'astro:content'; import BaseLayout from '../../layouts/BaseLayout.astro'; import { t } from '../../i18n/ui'; import type { Locale } from '../../lib/locale'; +import { sortPosts } from '../../lib/post-order'; import { Image } from 'astro:assets'; interface Props { locale?: Locale } const { locale = 'zh' } = Astro.props; -const books = (await getCollection('posts', ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden'))) - .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); +const books = sortPosts( + await getCollection('posts', ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden')), +); // 回退契约(schema 注释声称而此前未实现——视觉复核抓到的缺口): // description 缺省取正文第一个非空段落(去 markdown 记号)前 60 字 diff --git a/src/components/pages/HomePage.astro b/src/components/pages/HomePage.astro index aad9192..b801f94 100644 --- a/src/components/pages/HomePage.astro +++ b/src/components/pages/HomePage.astro @@ -10,6 +10,7 @@ import ProjectWall from '../geek/ProjectWall.astro'; import Heatmap from '../geek/Heatmap.astro'; import SocialLinks from '../SocialLinks.astro'; import { t } from '../../i18n/ui'; +import { sortPosts } from '../../lib/post-order'; import type { Locale } from '../../lib/locale'; import { homeConfig, @@ -30,9 +31,10 @@ 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()); +// 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts) +const posts = sortPosts( + await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')), +); // en 轨列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略) const enTitle = locale === 'en' diff --git a/src/content.config.ts b/src/content.config.ts index 86b843f..255be34 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -123,6 +123,8 @@ const postsSchema = z.object({ video: z.string().optional(), /** 视频封面(可选):同 public/media 下的图片 URL */ poster: z.string().optional(), + /** 置顶:列表排序优先(只影响顺序,不改 pubDate/URL/RSS 时间语义) */ + pinned: z.boolean().optional(), draft: z.boolean().optional(), }); diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts index 42bbdbc..7cd1675 100644 --- a/src/i18n/ui.ts +++ b/src/i18n/ui.ts @@ -112,6 +112,9 @@ const dict = { // ===== F 车道:访问量小字(Artalk pv,组件内独立计数) ===== pv_viewed: { zh: '人看过', en: 'views' }, + pinned_label: { zh: '置顶', en: 'Pinned' }, + comment_count: { zh: '条评论', en: 'comments' }, + moment_comments: { zh: '评论', en: 'Comments' }, lb_hint: { zh: '滚轮缩放 · 双击放大 · Esc 关闭', en: 'Wheel to zoom · Double-click · Esc' }, lb_hint_touch: { zh: '双指缩放 · 双击放大', en: 'Pinch to zoom · Double-tap' }, diff --git a/src/lib/post-order.ts b/src/lib/post-order.ts new file mode 100644 index 0000000..9faa1a9 --- /dev/null +++ b/src/lib/post-order.ts @@ -0,0 +1,31 @@ +/** + * 文章列表排序契约(置顶功能,Phase 5h T1) + * + * 为什么集中在这里:首页双轨 / 归档折叠 / 书架 / en 归档四处列表各自写 + * `sort((a,b)=>b.pubDate-a.pubDate)`,加置顶必须四处同步,否则同一篇文章 + * 在不同页面位置不一致。排序键固定为「置顶优先 → 时间倒序」,时间相等时 + * 用 id 兜底保证构建确定(同一份内容两次构建产出一致,便于快照比对)。 + * + * 置顶语义:`pinned: true` 只影响列表展示顺序,不改 pubDate、不改 URL、 + * 不影响 RSS/订阅源的时间语义。 + */ +import type { CollectionEntry } from 'astro:content'; + +type SortablePost = { id: string; data: { pubDate: Date; pinned?: boolean } }; + +/** + * 置顶优先、其次按发布日期倒序。 + * + * @param posts 任意文章条目数组(不修改入参,返回新数组) + * @returns 新数组;`pinned` 为真者在前,其内部与其余条目均按 pubDate 倒序, + * 同日按 id 升序保证确定性 + */ +export function sortPosts(posts: T[]): T[] { + return [...posts].sort((a, b) => { + const pin = Number(b.data.pinned === true) - Number(a.data.pinned === true); + if (pin !== 0) return pin; + const dt = b.data.pubDate.getTime() - a.data.pubDate.getTime(); + if (dt !== 0) return dt; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }); +} diff --git a/src/pages/archives/index.astro b/src/pages/archives/index.astro index 80592a3..1b0fdb1 100644 --- a/src/pages/archives/index.astro +++ b/src/pages/archives/index.astro @@ -3,9 +3,11 @@ import { getCollection } from 'astro:content'; import BaseLayout from '../../layouts/BaseLayout.astro'; import PostList from '../../components/PostList.astro'; +import { sortPosts } from '../../lib/post-order'; -const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort( - (a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(), +// 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts) +const posts = sortPosts( + await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')), ); --- diff --git a/src/pages/en/archives/index.astro b/src/pages/en/archives/index.astro index 8dea9a2..2ebf30b 100644 --- a/src/pages/en/archives/index.astro +++ b/src/pages/en/archives/index.astro @@ -3,9 +3,10 @@ import { getCollection } from 'astro:content'; import BaseLayout from '../../../layouts/BaseLayout.astro'; import PostList from '../../../components/PostList.astro'; +import { sortPosts } from '../../../lib/post-order'; -const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort( - (a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(), +const posts = sortPosts( + await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')), ); const enTitle = Object.fromEntries( (await getCollection('postsEn')).map((e) => [e.id, e.data.title]),