Compare commits
4
Commits
5bf0c327bf
...
0b0307b12f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b0307b12f | ||
|
|
3e377adc7f | ||
|
|
63c9c8ad4a | ||
|
|
8e74bead62 |
No files matched your search
@@ -13,27 +13,27 @@
|
|||||||
- 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(置顶标签)
|
- 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`
|
- 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 瞬间富文本(字体可换 / 颜色可换 / 多形式)
|
- [x] T2 瞬间富文本(字体可换 / 颜色可换 / 多形式)
|
||||||
- claim: worker-one
|
- claim: worker-one
|
||||||
- boundary: src/components/pages/MomentsPage.astro(`text` 由纯文本 `<p>` 改为走 `renderRich` 净化+排版类管线,保留 inline 语义;空文本老数据零回归)、public/admin/config.yml 中 moments 集合 `text` 字段改 markdown widget + 提示可用排版类(**仅在 orchestrator 提交的字段块内改 widget 类型,不改结构**)、docs 提示留给 T7
|
- boundary: src/components/pages/MomentsPage.astro(`text` 由纯文本 `<p>` 改为走 `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"`
|
- 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 + 折叠懒挂载)
|
- [x] T3 瞬间逐条评论(Artalk 独立 page_key + 折叠懒挂载)
|
||||||
- claim: worker-one
|
- claim: worker-one
|
||||||
- boundary: 新增 src/components/MomentComments.astro(折叠 disclosure,展开时首次挂载 Artalk;page_key 约定 `/moments/#<slug>`,en 页同一 key 不分裂;评论数徽标复用 T4 的 ViewMeta 计数接口)、src/components/pages/MomentsPage.astro(每条卡片挂评论区)
|
- boundary: 新增 src/components/MomentComments.astro(折叠 disclosure,展开时首次挂载 Artalk;page_key 约定 `/moments/#<slug>`,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"`
|
- 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 计数徽标(眼睛图标 = 浏览数,气泡图标 = 评论数)
|
- [x] T4 计数徽标(眼睛图标 = 浏览数,气泡图标 = 评论数)
|
||||||
- claim: op-artalk
|
- 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)
|
- 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"`
|
- 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 档案卡,去外链图依赖)
|
- [x] T5 开源页重构(原生 GitHub 档案卡,去外链图依赖)
|
||||||
- claim: op-gitea
|
- 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 现状)
|
- 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"`
|
- 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 的个人叙述
|
- [x] T6 关于页承接 README 的个人叙述
|
||||||
- claim: op-gitea
|
- claim: op-gitea
|
||||||
- boundary: content/site/about.yaml(新增 blocks:关于/兴趣 prose、站点与项目 linklist、Find Me 社交块;双语 zh/en;测试值不得留在文件里)、src/pages/about.astro 与 en(仅当块渲染需要时改,优先零改动)
|
- 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"`
|
- 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"`
|
||||||
|
|||||||
@@ -27,5 +27,12 @@
|
|||||||
"image": "https://astro.build/og/astro.jpg",
|
"image": "https://astro.build/og/astro.jpg",
|
||||||
"favicon": "https://astro.build/favicon.svg",
|
"favicon": "https://astro.build/favicon.svg",
|
||||||
"ts": 1793593759816
|
"ts": 1793593759816
|
||||||
|
},
|
||||||
|
"https://astro.build": {
|
||||||
|
"title": "Astro",
|
||||||
|
"desc": "Astro builds fast content sites, powerful web applications, dynamic server APIs, and everything in-between.",
|
||||||
|
"image": "https://astro.build/og/astro.jpg",
|
||||||
|
"favicon": "https://astro.build/favicon.svg",
|
||||||
|
"ts": 1793621147252
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,6 +62,69 @@ blocks:
|
|||||||
zh: 街拍与山野,随身一台无反,在等光的间隙按快门。
|
zh: 街拍与山野,随身一台无反,在等光的间隙按快门。
|
||||||
en: Street and landscapes with a mirrorless camera, pressing the shutter while waiting for the light.
|
en: Street and landscapes with a mirrorless camera, pressing the shutter while waiting for the light.
|
||||||
|
|
||||||
|
- type: prose
|
||||||
|
title:
|
||||||
|
zh: 关于我
|
||||||
|
en: About me
|
||||||
|
body:
|
||||||
|
zh: |
|
||||||
|
气象是我起点的专业——一个想做预报的人,依旧把云图当日常读物。
|
||||||
|
|
||||||
|
💻 写代码是第二条主线:Linux、容器、自托管,把日常工具一件件磨顺手。
|
||||||
|
|
||||||
|
🌐 作品大多在 [wait4x.online](https://wait4x.online/)。
|
||||||
|
|
||||||
|
⚡ 信条:**拥抱开源,热爱开源**。
|
||||||
|
en: |
|
||||||
|
Meteorology is where I started — an aspiring forecaster who still reads weather charts for fun.
|
||||||
|
|
||||||
|
💻 Coding is the second thread: Linux, containers, self-hosting — polishing everyday tools one by one.
|
||||||
|
|
||||||
|
🌐 Most of my work lives at [wait4x.online](https://wait4x.online/).
|
||||||
|
|
||||||
|
⚡ Motto: **embrace open source, love open source**.
|
||||||
|
|
||||||
|
- type: linklist
|
||||||
|
title:
|
||||||
|
zh: 找到我
|
||||||
|
en: Find me
|
||||||
|
items:
|
||||||
|
- label:
|
||||||
|
zh: 本站
|
||||||
|
en: Website
|
||||||
|
href: 'https://wait4x.online/'
|
||||||
|
note:
|
||||||
|
zh: 文章、图库与瞬间
|
||||||
|
en: Posts, gallery and moments
|
||||||
|
- label:
|
||||||
|
zh: 邮件
|
||||||
|
en: Email
|
||||||
|
href: 'mailto:admin@wait4x.online'
|
||||||
|
note:
|
||||||
|
zh: 有话直说
|
||||||
|
en: Say hello directly
|
||||||
|
- label:
|
||||||
|
zh: GitHub
|
||||||
|
en: GitHub
|
||||||
|
href: 'https://github.com/wait4xx'
|
||||||
|
note:
|
||||||
|
zh: 开源项目与提交
|
||||||
|
en: Open-source projects and commits
|
||||||
|
- label:
|
||||||
|
zh: 自托管 gitea
|
||||||
|
en: Self-hosted gitea
|
||||||
|
href: 'https://git.wait4x.online/wait4x/blog'
|
||||||
|
note:
|
||||||
|
zh: 本站源码
|
||||||
|
en: Source of this site
|
||||||
|
- label:
|
||||||
|
zh: RSS
|
||||||
|
en: RSS
|
||||||
|
href: /rss.xml
|
||||||
|
note:
|
||||||
|
zh: 订阅更新
|
||||||
|
en: Subscribe to updates
|
||||||
|
|
||||||
- type: prose
|
- type: prose
|
||||||
title:
|
title:
|
||||||
zh: 本站如何构建
|
zh: 本站如何构建
|
||||||
|
|||||||
@@ -18,5 +18,9 @@ exclude: []
|
|||||||
sort: pinned-first
|
sort: pinned-first
|
||||||
# 来源过滤:both=双源合并(默认) | github | gitea
|
# 来源过滤:both=双源合并(默认) | github | gitea
|
||||||
perSource: both
|
perSource: both
|
||||||
# 开篇文案来源:off=用本文件 blurb(默认) | github-readme=构建期取 wait4xx/wait4xx 的 README 渲染
|
# 开篇来源(compat,**不推荐开启**):off=用本文件 blurb + 原生档案卡(默认)
|
||||||
|
# github-readme = 构建期取 wait4xx/wait4xx 的 README 原样渲染在档案卡下方
|
||||||
|
# ⚠ 开启会引入 7 类第三方外链图(readme-typing-svg / shields.io 徽章 / github-readme-stats /
|
||||||
|
# streak-stats / activity-graph / 贡献蛇 / komarev 访客徽章),慢网或被墙时整片空白;
|
||||||
|
# 且 README 用 <div align="center"> 居中,本站渲染不认该属性 → 居中失效。内容已由档案卡承接。
|
||||||
intro_from: "off"
|
intro_from: "off"
|
||||||
+38
@@ -24,6 +24,27 @@
|
|||||||
- **文章英文版**:不在后台写。跑 `python3 scripts/translate/translate_posts.py` 生成 `content/posts-en/` 同名文件;手写润色过的译文在 frontmatter 写 `locked: true`,翻译脚本会无条件跳过它。
|
- **文章英文版**:不在后台写。跑 `python3 scripts/translate/translate_posts.py` 生成 `content/posts-en/` 同名文件;手写润色过的译文在 frontmatter 写 `locked: true`,翻译脚本会无条件跳过它。
|
||||||
- **保存即提交**:后台保存等于向仓库提交,push 后约 10 秒自动构建上线。
|
- **保存即提交**:后台保存等于向仓库提交,push 后约 10 秒自动构建上线。
|
||||||
|
|
||||||
|
## 文章置顶(5h 新增)
|
||||||
|
|
||||||
|
文章编辑页有一个「置顶」开关。打开后这篇文章排在首页分轨、归档、书架三处列表的最前面,列表里显示一个灰阶「置顶」小徽标。
|
||||||
|
|
||||||
|
语义边界(刻意如此,别当 bug 报):
|
||||||
|
- **只影响列表顺序**:`pubDate` 不变、URL 不变、RSS 里的时间序列不变,订阅者不会看到时间错乱
|
||||||
|
- 多篇同时置顶时,置顶组内部仍按日期倒序
|
||||||
|
- 关掉开关立即恢复时间序,没有"冷却期"
|
||||||
|
|
||||||
|
## 瞬间(5h 起与文章同级)
|
||||||
|
|
||||||
|
「瞬间」集合的「文字」字段是 **markdown 编辑器**,与文章同一套表达力:
|
||||||
|
|
||||||
|
- markdown:`**加粗**`、`*斜体*`、列表、`> 引用`、`` `行内代码` ``
|
||||||
|
- 排版类 HTML:`<span class="c-blue font-kai">彩色 + 楷体</span>`(可用类清单见写作指南「文章排版类」一节,与文章完全同一份白名单)
|
||||||
|
- 仍守外壳灰阶纪律:彩色是**强调**不是装饰,一页 ≤3 处
|
||||||
|
|
||||||
|
**每条瞬间自带评论区**:时间线卡片下方有「评论」折叠条,读者点开才加载评论(不点开不为它付任何加载成本)。中文页与英文页共用同一条评论流,语言切换不会丢评论。
|
||||||
|
|
||||||
|
**每条的浏览 / 评论数字是只读的**:读者点开评论区不会虚增浏览数(只有整页访问才计一次)。
|
||||||
|
|
||||||
## 照片上传流
|
## 照片上传流
|
||||||
|
|
||||||
1. 图库照片 → New Entry:填标题(**必填**,空标题会让构建炸——实测坑)、选分组(street/landscape)、可写 alt
|
1. 图库照片 → New Entry:填标题(**必填**,空标题会让构建炸——实测坑)、选分组(street/landscape)、可写 alt
|
||||||
@@ -118,6 +139,12 @@ blocks:
|
|||||||
|
|
||||||
### 开源页(opensource.yaml)
|
### 开源页(opensource.yaml)
|
||||||
|
|
||||||
|
**开篇是原生「GitHub 档案卡」(5h 起)**:头像 / 名字@handle / 定位叙述 / 社交图标 / 统计四格(关注者、公开仓、总星标)/ 语言占比灰阶条(只有第一名用强调色,其余灰阶)。**零第三方图片、零 iframe**。数据失败只隐藏对应数字,不会破版。
|
||||||
|
|
||||||
|
想换那段「定位叙述」,改本文件的 `blurb` + `blurb_after`(双语,后台可改)——它们现在同时供给档案卡与项目墙下方的说明。
|
||||||
|
|
||||||
|
下面的 `intro_from: "github-readme"` **仍可用但不推荐**:那会把 GitHub 专用 README 原样渲染,带 7 类外链图(shields.io、readme-typing-svg、github-readme-stats、streak-stats、activity-graph、snake、komarev 访客徽章)与 `<div align="center">` 居中——本站按普通块渲染,故居中失效;外链图在慢网或被墙时全空白。这也是当初"部分内容没居中、有些图片没显示"的根因。
|
||||||
|
|
||||||
| 字段 | 取值 | 说明 |
|
| 字段 | 取值 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `pinned` | 仓短名数组 | 置顶顺序即展示顺序 |
|
| `pinned` | 仓短名数组 | 置顶顺序即展示顺序 |
|
||||||
@@ -143,6 +170,17 @@ token 位在 `~/docker_apps/blog/github-token.txt`(本机,0600)。
|
|||||||
- **README 里的相对链接不重写**(如 `./img/x.png`、`docs/foo.md`):它们相对 GitHub 仓根解析,在本站没有对应产物,点了多半 404。保原样比猜一个基址可信,所以不做改写。
|
- **README 里的相对链接不重写**(如 `./img/x.png`、`docs/foo.md`):它们相对 GitHub 仓根解析,在本站没有对应产物,点了多半 404。保原样比猜一个基址可信,所以不做改写。
|
||||||
- **外部徽章图片加固但不参与构建**:shields.io、readme-typing-svg 这类第三方徽章在构建期**不抓取**,渲染时统一补 `loading="lazy"`(缺 alt 补空),它们慢、被墙或 404 都不影响页面渲染;尺寸已由样式限制在内容宽度内。
|
- **外部徽章图片加固但不参与构建**:shields.io、readme-typing-svg 这类第三方徽章在构建期**不抓取**,渲染时统一补 `loading="lazy"`(缺 alt 补空),它们慢、被墙或 404 都不影响页面渲染;尺寸已由样式限制在内容宽度内。
|
||||||
|
|
||||||
|
## 浏览与评论计数(5h 新增)
|
||||||
|
|
||||||
|
文章页、瞬间页、开源页、关于页都用同一枚灰阶徽标:**眼睛图标 = 浏览数,气泡图标 = 条评论**,数据来自自建 Artalk,不依赖任何第三方徽章服务。
|
||||||
|
|
||||||
|
几条行为约定(省得当成 bug):
|
||||||
|
|
||||||
|
- **一页只计一次浏览**:「计一次」发生在这页唯一那个 `page` 模式徽标(文章页在标题下方),同一页其余徽标都是只读镜像,显示的是同一个数字——不会出现"页头 17、评论区 16"
|
||||||
|
- **开评论不算浏览**:瞬间逐条徽标是只读的,读者展开评论区不会给那条动态虚增浏览量
|
||||||
|
- **接口挂掉显示「–」而不是 0**:宁可留白,也不给读者看一个错误的零
|
||||||
|
- **换页重计、同页刷新重计**:这是浏览量的定义,不是重复计数的 bug(软导航回同一页也算新一次访问)
|
||||||
|
|
||||||
## 纪律(与写作指南同源)
|
## 纪律(与写作指南同源)
|
||||||
|
|
||||||
- **图片体积**:上传前自行压缩(长边 ≤2400px)——Sveltia 内建优化**刻意关闭**(会抹 EXIF 断构建期提取管线)
|
- **图片体积**:上传前自行压缩(长边 ≤2400px)——Sveltia 内建优化**刻意关闭**(会抹 EXIF 断构建期提取管线)
|
||||||
|
|||||||
@@ -7,15 +7,14 @@ interface Props {
|
|||||||
}
|
}
|
||||||
const { pageKey, pageTitle } = Astro.props;
|
const { pageKey, pageTitle } = Astro.props;
|
||||||
import { t } from '../i18n/ui';
|
import { t } from '../i18n/ui';
|
||||||
|
import ViewMeta from './geek/ViewMeta.astro';
|
||||||
import { localeOf } from '../lib/locale';
|
import { localeOf } from '../lib/locale';
|
||||||
const locale = localeOf(Astro.url.pathname);
|
const locale = localeOf(Astro.url.pathname);
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- pv 小字:计数由本组件下方脚本独立 POST(页面加载即计),与 Artalk init 的 pvAdd 解耦 -->
|
<!-- 浏览/评论徽标(Phase 5h T4):评论区这枚是 **readonly** —— 整页的计数点唯一在
|
||||||
<p class="atk-pv-line" aria-label={t('pv_viewed', locale)}>
|
详情页标题下的 ViewMeta(mode="page"),此处只读展示,避免同页两处各计一次。 -->
|
||||||
<span data-artalk-pv data-page-key={pageKey} data-page-title={pageTitle}>–</span>
|
<ViewMeta pageKey={pageKey} locale={locale} mode="readonly" variant="chip" />
|
||||||
{' '}{t('pv_viewed', locale)}
|
|
||||||
</p>
|
|
||||||
<div id="artalk-container" data-page-key={pageKey} data-page-title={pageTitle}></div>
|
<div id="artalk-container" data-page-key={pageKey} data-page-title={pageTitle}></div>
|
||||||
<p class="atk-load-hint" id="artalk-retry" hidden>
|
<p class="atk-load-hint" id="artalk-retry" hidden>
|
||||||
<span>评论区加载失败。</span>
|
<span>评论区加载失败。</span>
|
||||||
@@ -100,37 +99,11 @@ const locale = localeOf(Astro.url.pathname);
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.atk-retry-btn:hover { border-color: var(--accent); }
|
.atk-retry-btn:hover { border-color: var(--accent); }
|
||||||
.atk-pv-line {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ink-4);
|
|
||||||
margin: var(--space-2) 0 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// 访问量:POST /artalk/api/v2/pages/pv 计数并取数(POST 天然绕过 ESA 边缘缓存;
|
// 浏览计数已移交 ViewMeta 组件(Phase 5h T4),此处只管评论挂载。
|
||||||
// GET /stats/* 会被 ESA 缓存出冻结数字,勿改查询式)。page-load 兼容软导航换页重计。
|
//
|
||||||
const bumpPv = async () => {
|
|
||||||
const el = document.querySelector('[data-artalk-pv]');
|
|
||||||
if (!el) return;
|
|
||||||
const key = el.dataset.pageKey ?? '';
|
|
||||||
if (!key) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/artalk/api/v2/pages/pv', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ page_key: key, page_title: el.dataset.pageTitle ?? '', site_name: 'wait4x.online' }),
|
|
||||||
});
|
|
||||||
const d = await res.json();
|
|
||||||
el.textContent = String(d.pv ?? '–');
|
|
||||||
} catch {
|
|
||||||
el.textContent = '–'; // 计数失败静默:小字不可见性优于报错
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('astro:page-load', () => void bumpPv());
|
|
||||||
void bumpPv();
|
|
||||||
|
|
||||||
// ── 挂载健壮化(用户实测「总没加载出来」)──
|
// ── 挂载健壮化(用户实测「总没加载出来」)──
|
||||||
// 触发路径三条:IO 进入视口(主)/ 点击评论区锚点(兜底)/ 滚到页底 2s 后(兜底);
|
// 触发路径三条:IO 进入视口(主)/ 点击评论区锚点(兜底)/ 滚到页底 2s 后(兜底);
|
||||||
// 失败路径:脚本 5s 未 onload → 灰阶提示 + 重试钮;成功后容器打 data-artalk-mounted 供 E2E 断言。
|
// 失败路径:脚本 5s 未 onload → 灰阶提示 + 重试钮;成功后容器打 data-artalk-mounted 供 E2E 断言。
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* 瞬间逐条评论(Phase 5h T3):每条瞬间一个 `<details>` 折叠区,**展开才首次挂载 Artalk**。
|
||||||
|
*
|
||||||
|
* 为什么惰性挂载:时间线一屏能有十几条,若每张卡常驻一个 Artalk 实例,内存与请求
|
||||||
|
* 都白付;Artalk 官方也未背书"同页多实例长期并存"。于是「展开才挂」是折中:
|
||||||
|
* 读者点了才付成本,未点零开销(连 Artalk.js 都不下载)。
|
||||||
|
*
|
||||||
|
* 评论流约定:`page_key = /moments/#<slug>`,**中英双语共用同一键**(评论不分裂,
|
||||||
|
* 与文章页 `/archives/<slug>/` 双语共用同理);site 恒为 wait4x.online。
|
||||||
|
*
|
||||||
|
* 计数不在这里自己 fetch:复用 geek/ViewMeta(mode="readonly" 只读,不写 pv),
|
||||||
|
* 放在卡片底部让读者不开评论也能看见条数。
|
||||||
|
*/
|
||||||
|
import ViewMeta from './geek/ViewMeta.astro';
|
||||||
|
import { t } from '../i18n/ui';
|
||||||
|
import type { Locale } from '../lib/locale';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 瞬间条目 id(即文件名 slug),用于生成评论流键与容器 id */
|
||||||
|
slug: string;
|
||||||
|
locale?: Locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug, locale = 'zh' } = Astro.props;
|
||||||
|
|
||||||
|
/** 评论流键:双语共用;`#<slug>` 与文章页 page_key 形状保持一致的可读性 */
|
||||||
|
const pageKey = `/moments/#${slug}`;
|
||||||
|
/** 容器 id 只允许 CSS 安全字符(slug 理论上是文件名,仍兜一层) */
|
||||||
|
const panelId = `mom-atk-${slug.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
|
||||||
|
const label = t('moment_comments', locale);
|
||||||
|
const failText = locale === 'zh' ? '评论加载失败' : 'Comments failed to load';
|
||||||
|
const retryText = locale === 'zh' ? '重试' : 'Retry';
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="mom-cm">
|
||||||
|
<details class="mom-cm-box" data-mom-comments data-page-key={pageKey} data-page-title={`瞬间 · ${slug}`}>
|
||||||
|
<summary class="mom-cm-sum">
|
||||||
|
<span class="mom-cm-label">{label}</span>
|
||||||
|
<svg class="mom-cm-chev" viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
|
||||||
|
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
<div class="mom-cm-body">
|
||||||
|
<div class="mom-cm-panel" id={panelId} data-artalk-panel></div>
|
||||||
|
<p class="mom-cm-fail" hidden>
|
||||||
|
<span>{failText}</span>
|
||||||
|
<button type="button" class="mom-cm-retry">{retryText}</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div class="mom-cm-meta">
|
||||||
|
<ViewMeta pageKey={pageKey} locale={locale} mode="readonly" showPv={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── 惰性挂载:details 首次展开才拉 Artalk 库并 init ──
|
||||||
|
// toggle 事件不冒泡,但捕获阶段能拿到;astro:page-load 后重新扫描(软导航换页)
|
||||||
|
const SITE = 'wait4x.online';
|
||||||
|
const SERVER = 'https://wait4x.online/artalk';
|
||||||
|
const LIB_CSS = 'https://wait4x.online/artalk/dist/Artalk.css';
|
||||||
|
const LIB_JS = 'https://wait4x.online/artalk/dist/Artalk.js';
|
||||||
|
|
||||||
|
type ArtalkLib = { init: (opts: Record<string, unknown>) => unknown };
|
||||||
|
interface Mounted {
|
||||||
|
/** 已 init 的容器 id,避免重复 init(详情反复开合只在首次付成本) */
|
||||||
|
[key: string]: boolean;
|
||||||
|
}
|
||||||
|
const mounted: Mounted = {};
|
||||||
|
const pending: { [key: string]: boolean } = {};
|
||||||
|
/** 同页多实例:记录已 init 的容器,供排障(window.__momArtalk 可查) */
|
||||||
|
const instances: string[] = [];
|
||||||
|
(window as unknown as { __momArtalk?: string[] }).__momArtalk = instances;
|
||||||
|
|
||||||
|
const lib = () => (window as unknown as { Artalk?: ArtalkLib }).Artalk;
|
||||||
|
|
||||||
|
const loadAssets = () =>
|
||||||
|
new Promise<void>((done) => {
|
||||||
|
// 复用全站同一对资产 id:文章页已加载过就不再重复拉
|
||||||
|
if (document.getElementById('artalk-lib') && lib()) return done();
|
||||||
|
const cssReady = new Promise<void>((cssDone) => {
|
||||||
|
if (document.getElementById('artalk-css')) return cssDone();
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.id = 'artalk-css';
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = LIB_CSS;
|
||||||
|
link.onload = () => cssDone();
|
||||||
|
link.onerror = () => cssDone(); // CSS 失败不阻塞挂载(降级 UA 样式)
|
||||||
|
document.head.appendChild(link);
|
||||||
|
});
|
||||||
|
const existing = document.getElementById('artalk-lib') as HTMLScriptElement | null;
|
||||||
|
if (existing) {
|
||||||
|
// 脚本标签已在但库还没就绪:等它 onload
|
||||||
|
existing.addEventListener('load', () => void cssReady.then(() => done()));
|
||||||
|
if (lib()) void cssReady.then(() => done());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.id = 'artalk-lib';
|
||||||
|
s.src = LIB_JS;
|
||||||
|
s.onload = () => void cssReady.then(() => done());
|
||||||
|
s.onerror = () => done(); // 失败也让调用方走"库不存在"分支显示重试
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
const mountOne = async (box: HTMLDetailsElement) => {
|
||||||
|
const key = box.dataset.pageKey ?? '';
|
||||||
|
const panel = box.querySelector<HTMLElement>('[data-artalk-panel]');
|
||||||
|
const fail = box.querySelector<HTMLElement>('.mom-cm-fail');
|
||||||
|
if (!key || !panel) return;
|
||||||
|
const id = panel.id;
|
||||||
|
if (mounted[id] || pending[id]) return;
|
||||||
|
pending[id] = true;
|
||||||
|
|
||||||
|
const showFail = (show: boolean) => {
|
||||||
|
if (fail) fail.hidden = !show;
|
||||||
|
};
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
if (!mounted[id]) showFail(true);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
await loadAssets();
|
||||||
|
const a = lib();
|
||||||
|
if (!a) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
pending[id] = false;
|
||||||
|
showFail(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
a.init({
|
||||||
|
el: `#${id}`,
|
||||||
|
server: SERVER,
|
||||||
|
site: SITE,
|
||||||
|
pageKey: key,
|
||||||
|
pageTitle: box.dataset.pageTitle ?? '',
|
||||||
|
locale: 'zh-CN',
|
||||||
|
// 明暗交给站点 tokens 接管(与文章页同策略),pv 不计(ViewMeta 只读)
|
||||||
|
darkMode: false,
|
||||||
|
pvAdd: false,
|
||||||
|
});
|
||||||
|
mounted[id] = true;
|
||||||
|
panel.dataset.artalkMounted = '1';
|
||||||
|
instances.push(id);
|
||||||
|
showFail(false);
|
||||||
|
} catch {
|
||||||
|
showFail(true);
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
pending[id] = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 展开时挂载;重复展开只挂一次 */
|
||||||
|
const onToggle = (target: EventTarget | null) => {
|
||||||
|
const box = target as HTMLDetailsElement | null;
|
||||||
|
if (!box || box.tagName !== 'DETAILS' || !box.hasAttribute('data-mom-comments')) return;
|
||||||
|
if (box.open) void mountOne(box);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('toggle', (e) => onToggle(e.target), true);
|
||||||
|
|
||||||
|
// 重试钮:失败后点一下重新挂(清掉挂载标记再走一遍)
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const btn = (e.target as HTMLElement)?.closest?.('.mom-cm-retry');
|
||||||
|
if (!btn) return;
|
||||||
|
const box = btn.closest<HTMLDetailsElement>('details[data-mom-comments]');
|
||||||
|
const panel = box?.querySelector<HTMLElement>('[data-artalk-panel]');
|
||||||
|
if (!box || !panel) return;
|
||||||
|
delete mounted[panel.id];
|
||||||
|
void mountOne(box);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 软导航回到本页时,已展开的(浏览器可能保留 open 状态)补挂一次
|
||||||
|
document.addEventListener('astro:page-load', () => {
|
||||||
|
for (const box of document.querySelectorAll<HTMLDetailsElement>('details[data-mom-comments][open]')) {
|
||||||
|
void mountOne(box);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.mom-cm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
border-top: 1px dashed var(--line);
|
||||||
|
}
|
||||||
|
.mom-cm-box {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.mom-cm-sum {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.mom-cm-sum::-webkit-details-marker { display: none; }
|
||||||
|
.mom-cm-sum:hover { color: var(--ink-2); }
|
||||||
|
.mom-cm-chev { transition: rotate var(--dur) var(--ease); }
|
||||||
|
details[open] > .mom-cm-sum .mom-cm-chev { rotate: 180deg; }
|
||||||
|
.mom-cm-body { margin-top: var(--space-2); }
|
||||||
|
.mom-cm-panel { font-size: 13px; }
|
||||||
|
.mom-cm-meta { flex: none; }
|
||||||
|
.mom-cm-fail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
}
|
||||||
|
.mom-cm-retry {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mom-cm-retry:hover { border-color: var(--accent); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
---
|
||||||
|
// 开源页原生档案卡(5h T5):零第三方图片、零 iframe
|
||||||
|
// 数据来源:①content/site/home.yaml 的头像/社交(单一事实源)②content/site/opensource.yaml 的
|
||||||
|
// 定位叙述(blurb/blurb_after,双语可后台改)③GitHub REST 的 followers/公开仓数(token 在宿主
|
||||||
|
// 0600 文件,缺位或失败即隐藏该数字)④src/lib/projects.ts 的仓库列表(语言占比与星标汇总)
|
||||||
|
// 降级纪律:任何一项取数失败只隐藏该项,绝不炸构建、不出现空图片框
|
||||||
|
import { Image } from 'astro:assets';
|
||||||
|
import { homeAvatar, homeSocials, openSourceCopy } from '../../lib/site-content';
|
||||||
|
import { githubUserStats, loadProjects } from '../../lib/projects';
|
||||||
|
import { iconPath } from '../../lib/icons';
|
||||||
|
import type { Locale } from '../../lib/locale';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
locale?: Locale;
|
||||||
|
}
|
||||||
|
const { locale = 'zh' } = Astro.props;
|
||||||
|
|
||||||
|
const avatar = await homeAvatar();
|
||||||
|
const socials = await homeSocials();
|
||||||
|
const copy = await openSourceCopy(locale);
|
||||||
|
|
||||||
|
// 仓库数据:档案卡只关心"语言占比 + 星标汇总",取全量(不受项目墙可见性影响)
|
||||||
|
let langs: { name: string; count: number; pct: number }[] = [];
|
||||||
|
let totalStars: number | null = null;
|
||||||
|
try {
|
||||||
|
const repos = await loadProjects({ mode: 'all', sort: 'stars' });
|
||||||
|
if (repos.length) {
|
||||||
|
totalStars = repos.reduce((sum, r) => sum + (r.stars ?? 0), 0);
|
||||||
|
const tally = new Map<string, number>();
|
||||||
|
for (const r of repos) {
|
||||||
|
if (!r.language) continue;
|
||||||
|
tally.set(r.language, (tally.get(r.language) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const total = [...tally.values()].reduce((a, b) => a + b, 0);
|
||||||
|
langs = [...tally.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(([name, count]) => ({
|
||||||
|
name,
|
||||||
|
count,
|
||||||
|
pct: total ? Math.round((count / total) * 100) : 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.warn('[profile] 仓库数据不可用,语言占比与星标汇总已隐藏');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await githubUserStats();
|
||||||
|
const L = {
|
||||||
|
zh: { followers: '关注者', repos: '公开仓库', stars: '总星标', langs: '语言占比' },
|
||||||
|
en: { followers: 'Followers', repos: 'Public repos', stars: 'Total stars', langs: 'Languages' },
|
||||||
|
}[locale];
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="profile" aria-label={locale === 'zh' ? 'GitHub 档案' : 'GitHub profile'}>
|
||||||
|
<div class="who">
|
||||||
|
{avatar ? (
|
||||||
|
<Image class="pf-avatar" src={avatar} alt="wait4x" width={64} height={64} format="webp" quality={84} />
|
||||||
|
) : (
|
||||||
|
<span class="pf-avatar pf-avatar-fallback" aria-hidden="true">w4</span>
|
||||||
|
)}
|
||||||
|
<div class="who-main">
|
||||||
|
<p class="pf-name">
|
||||||
|
wait4x<span class="pf-handle mono">@wait4xx</span>
|
||||||
|
</p>
|
||||||
|
<p class="pf-bio">
|
||||||
|
{copy.blurb}<a class="pf-link" href={copy.repoUrl}>{copy.repoLabel}</a>{copy.blurbAfter}
|
||||||
|
</p>
|
||||||
|
<ul class="pf-socials">
|
||||||
|
{socials.map((s) => (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="tap"
|
||||||
|
href={s.url}
|
||||||
|
aria-label={s.label}
|
||||||
|
title={s.label}
|
||||||
|
rel={s.url.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||||
|
>
|
||||||
|
{iconPath(s.icon) ? (
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d={iconPath(s.icon)} />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<span class="pf-social-text mono">{s.label}</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(stats || totalStars !== null) && (
|
||||||
|
<dl class="pf-stats mono">
|
||||||
|
{stats && (
|
||||||
|
<>
|
||||||
|
<div><dt>{L.followers}</dt><dd>{stats.followers}</dd></div>
|
||||||
|
<div><dt>{L.repos}</dt><dd>{stats.publicRepos}</dd></div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{totalStars !== null && (
|
||||||
|
<div><dt>{L.stars}</dt><dd>{totalStars}</dd></div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{langs.length > 0 && (
|
||||||
|
<div class="pf-langs">
|
||||||
|
<p class="pf-langs-title mono">{L.langs}</p>
|
||||||
|
<ul>
|
||||||
|
{langs.map((l, i) => (
|
||||||
|
<li class:list={['pf-lang', { top: i === 0 }]}>
|
||||||
|
<span class="pf-lang-name mono">{l.name}</span>
|
||||||
|
<span class="pf-bar" aria-hidden="true">
|
||||||
|
<span class="pf-bar-fill" style={`width:${Math.max(l.pct, 2)}%`} />
|
||||||
|
</span>
|
||||||
|
<span class="pf-lang-pct mono">{l.pct}%</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.profile {
|
||||||
|
margin: var(--space-3) 0 var(--space-5);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
max-width: var(--measure);
|
||||||
|
}
|
||||||
|
.who {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.pf-avatar {
|
||||||
|
flex: none;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 14px;
|
||||||
|
object-fit: cover;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.pf-avatar-fallback {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-2);
|
||||||
|
background: var(--bg-soft);
|
||||||
|
}
|
||||||
|
.who-main { min-width: 0; }
|
||||||
|
.pf-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-1);
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.pf-handle { font-size: 12.5px; font-weight: 400; color: var(--ink-4); }
|
||||||
|
.pf-bio {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: 14.5px;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
.pf-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
.pf-socials {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
list-style: none;
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.pf-socials a { color: var(--ink-3); display: inline-flex; }
|
||||||
|
.pf-socials a:hover { color: var(--accent); }
|
||||||
|
.pf-social-text { font-size: 12px; }
|
||||||
|
|
||||||
|
.pf-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin: var(--space-4) 0 0;
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.pf-stats div { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.pf-stats dt {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.pf-stats dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--ink-1);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-langs { margin-top: var(--space-3); }
|
||||||
|
.pf-langs-title {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
.pf-langs ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
max-width: 32rem;
|
||||||
|
}
|
||||||
|
.pf-lang {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 7.5rem 1fr 3rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.pf-lang-name { font-size: 12px; color: var(--ink-3); }
|
||||||
|
.pf-bar {
|
||||||
|
display: block;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pf-bar-fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--ink-3);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
/* 灰阶体系:仅第一名用 accent 强调,其余走 ink 灰阶(不沿用 GitHub 彩色语言点) */
|
||||||
|
.pf-lang.top .pf-bar-fill { background: var(--accent); }
|
||||||
|
.pf-lang.top .pf-lang-name { color: var(--ink-2); }
|
||||||
|
.pf-lang-pct {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.pf-lang { grid-template-columns: 5.5rem 1fr 2.6rem; }
|
||||||
|
.pf-stats { gap: var(--space-3); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -111,6 +111,12 @@ const L = {
|
|||||||
const loadOne = async (root: HTMLElement) => {
|
const loadOne = async (root: HTMLElement) => {
|
||||||
const key = root.dataset.pageKey;
|
const key = root.dataset.pageKey;
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
|
// 每元素只处理一次(2026-11-02 实测教训):首屏 astro:page-load 与本脚本的
|
||||||
|
// 立即调用会双触发,page 模式每元素会 POST 两次 → 浏览量翻倍(实测
|
||||||
|
// 15→17→19 每载 +2,加此守卫后 1/2/3 每载 +1)。软导航新页面元素是新的,
|
||||||
|
// 故不牺牲「换页重计」语义。
|
||||||
|
if (root.dataset.vmDone === '1') return;
|
||||||
|
root.dataset.vmDone = '1';
|
||||||
const mode = root.dataset.mode ?? 'page';
|
const mode = root.dataset.mode ?? 'page';
|
||||||
try {
|
try {
|
||||||
// page 模式先计一次浏览(POST 绕 ESA 缓存),再读评论数;
|
// page 模式先计一次浏览(POST 绕 ESA 缓存),再读评论数;
|
||||||
@@ -126,7 +132,15 @@ const L = {
|
|||||||
`${API}/comments?site_name=${encodeURIComponent(SITE)}&page_key=${encodeURIComponent(key)}&limit=1`,
|
`${API}/comments?site_name=${encodeURIComponent(SITE)}&page_key=${encodeURIComponent(key)}&limit=1`,
|
||||||
);
|
);
|
||||||
const meta = (await res.json()) as Meta;
|
const meta = (await res.json()) as Meta;
|
||||||
fill(root, { count: meta.count, page: { pv: data.pv ?? meta.page?.pv } });
|
const merged: Meta = { count: meta.count, page: { pv: data.pv ?? meta.page?.pv } };
|
||||||
|
fill(root, merged);
|
||||||
|
// 同页同键的其它实例(如评论区 chip)复用本次结果:否则它独立 GET 到的
|
||||||
|
// pv 可能比计过数的这份少 1,两个徽标同页显示不同数字会像 bug。
|
||||||
|
for (const other of document.querySelectorAll<HTMLElement>('[data-viewmeta]')) {
|
||||||
|
if (other === root || other.dataset.pageKey !== key) continue;
|
||||||
|
other.dataset.vmDone = '1';
|
||||||
|
fill(other, merged);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
@@ -134,12 +148,27 @@ const L = {
|
|||||||
);
|
);
|
||||||
fill(root, (await res.json()) as Meta);
|
fill(root, (await res.json()) as Meta);
|
||||||
} catch {
|
} catch {
|
||||||
|
// 页模式失败:同键 readonly 兄弟此前被跳过,这里放它们自己读,避免永远「–」
|
||||||
|
for (const other of document.querySelectorAll<HTMLElement>('[data-viewmeta]')) {
|
||||||
|
if (other === root || other.dataset.pageKey !== key || other.dataset.vmDone === '1') continue;
|
||||||
|
delete other.dataset.vmDone;
|
||||||
|
void loadOne(other);
|
||||||
|
}
|
||||||
/* 接口不可用:保留占位「–」,不显示 0 误导读者 */
|
/* 接口不可用:保留占位「–」,不显示 0 误导读者 */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const run = () => {
|
const run = () => {
|
||||||
for (const root of document.querySelectorAll<HTMLElement>('[data-viewmeta]')) {
|
const all = [...document.querySelectorAll<HTMLElement>('[data-viewmeta]')];
|
||||||
|
// 同键存在 page 实例时,readonly 实例等它回填(它拿到的是计过数的 pv,
|
||||||
|
// 与 section 内另一枚徽标必然一致);否则同页两枚会显示差 1 的两个数字。
|
||||||
|
const pageKeys = new Set(
|
||||||
|
all
|
||||||
|
.filter((el) => (el.dataset.mode ?? 'page') === 'page' && el.dataset.pageKey)
|
||||||
|
.map((el) => el.dataset.pageKey as string),
|
||||||
|
);
|
||||||
|
for (const root of all) {
|
||||||
|
if ((root.dataset.mode ?? 'page') === 'readonly' && pageKeys.has(root.dataset.pageKey ?? '')) continue;
|
||||||
void loadOne(root);
|
void loadOne(root);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
import { Image } from 'astro:assets';
|
import { Image } from 'astro:assets';
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import MomentComments from '../MomentComments.astro';
|
||||||
|
import ViewMeta from '../geek/ViewMeta.astro';
|
||||||
import { t } from '../../i18n/ui';
|
import { t } from '../../i18n/ui';
|
||||||
|
import { renderRich } from '../../lib/site-content';
|
||||||
import type { Locale } from '../../lib/locale';
|
import type { Locale } from '../../lib/locale';
|
||||||
|
|
||||||
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
|
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
|
||||||
@@ -14,16 +17,25 @@ const { locale = 'zh' } = Astro.props;
|
|||||||
const moments = (await getCollection('moments')).sort(
|
const moments = (await getCollection('moments')).sort(
|
||||||
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 富文本预渲染(Phase 5h T2):text 走与页面文案同一条管线(markdown → 内容净化 →
|
||||||
|
// class 白名单 → 链接卡片),因此瞬间支持 **加粗**、<span class="c-blue font-kai">、列表与换行。
|
||||||
|
// 预渲染成 map 再进模板:renderRich 是 async,模板里不能 await。
|
||||||
|
// 纯文本老数据零回归:无 markdown 的文本会得到单个 <p>,与旧的 {text} 直出视觉等价。
|
||||||
|
const richText = new Map<string, string>();
|
||||||
|
for (const m of moments) {
|
||||||
|
if (!m.data.text) continue;
|
||||||
|
richText.set(m.id, await renderRich(m.data.text));
|
||||||
|
}
|
||||||
const iso = (d: Date) => d.toISOString();
|
const iso = (d: Date) => d.toISOString();
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={`${t('moments_title', locale)} · wait4x`}>
|
<BaseLayout title={`${t('moments_title', locale)} · wait4x`}>
|
||||||
<h1 class="page-title">{t('moments_title', locale)}</h1>
|
<h1 class="page-title">{t('moments_title', locale)}</h1>
|
||||||
<p class="page-note">{t('moments_note', locale)}</p>
|
<p class="page-note">{t('moments_note', locale)}</p>
|
||||||
<p class="pv-line" aria-label={t('pv_viewed', locale)}>
|
<!-- 整页计数(Phase 5h T4):这是本页唯一的计数点(page 模式);
|
||||||
<span data-artalk-pv data-page-key={locale === 'en' ? '/en/moments/' : '/moments/'}>–</span>
|
每条瞬间卡片底部的徽标是 readonly,读者开评论不会虚增浏览数 -->
|
||||||
{' '}{t('pv_viewed', locale)}
|
<ViewMeta class="mom-page-meta" pageKey={locale === 'en' ? '/en/moments/' : '/moments/'} {locale} variant="chip" />
|
||||||
</p>
|
|
||||||
<ol class="tl">
|
<ol class="tl">
|
||||||
{moments.map((m) => (
|
{moments.map((m) => (
|
||||||
<li class="tl-item">
|
<li class="tl-item">
|
||||||
@@ -46,12 +58,13 @@ const iso = (d: Date) => d.toISOString();
|
|||||||
/>
|
/>
|
||||||
</figure>
|
</figure>
|
||||||
)}
|
)}
|
||||||
{m.data.text && <p>{m.data.text}</p>}
|
{m.data.text && <div class="mom-text" set:html={richText.get(m.id) ?? ''} />}
|
||||||
{m.data.tags.length > 0 && (
|
{m.data.tags.length > 0 && (
|
||||||
<p class="mom-tags">
|
<p class="mom-tags">
|
||||||
{m.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
|
{m.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<MomentComments slug={m.id} locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -67,25 +80,6 @@ const iso = (d: Date) => d.toISOString();
|
|||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// 访问量:与 ArtalkComments 同款独立 POST(双轨 pageKey 分开计数;POST 绕 ESA 缓存)
|
|
||||||
const bumpPv = async () => {
|
|
||||||
const el = document.querySelector('[data-artalk-pv]');
|
|
||||||
if (!el) return;
|
|
||||||
const key = el.dataset.pageKey ?? '';
|
|
||||||
if (!key) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/artalk/api/v2/pages/pv', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ page_key: key, site_name: 'wait4x.online' }),
|
|
||||||
});
|
|
||||||
const d = await res.json();
|
|
||||||
el.textContent = String(d.pv ?? '–');
|
|
||||||
} catch {
|
|
||||||
el.textContent = '–';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 时间线渐显:html[data-mom-anim] 由 JS 前置标记——无 JS / reduced-motion 时
|
// 时间线渐显:html[data-mom-anim] 由 JS 前置标记——无 JS / reduced-motion 时
|
||||||
// 标记缺席,CSS 初始隐藏规则整体不生效(渐进降级,元素永远可见)
|
// 标记缺席,CSS 初始隐藏规则整体不生效(渐进降级,元素永远可见)
|
||||||
let io: IntersectionObserver | null = null;
|
let io: IntersectionObserver | null = null;
|
||||||
@@ -109,11 +103,9 @@ const iso = (d: Date) => d.toISOString();
|
|||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('astro:page-load', () => {
|
document.addEventListener('astro:page-load', () => {
|
||||||
void bumpPv();
|
|
||||||
armAnim();
|
armAnim();
|
||||||
refreshInstallCta();
|
refreshInstallCta();
|
||||||
});
|
});
|
||||||
void bumpPv();
|
|
||||||
armAnim();
|
armAnim();
|
||||||
|
|
||||||
// —— PWA 安装引导:beforeinstallprompt 早在页面加载初期触发,
|
// —— PWA 安装引导:beforeinstallprompt 早在页面加载初期触发,
|
||||||
@@ -208,6 +200,41 @@ const iso = (d: Date) => d.toISOString();
|
|||||||
background: var(--bg-soft);
|
background: var(--bg-soft);
|
||||||
}
|
}
|
||||||
.mom-card > p { font-size: 15px; color: var(--ink-2); }
|
.mom-card > p { font-size: 15px; color: var(--ink-2); }
|
||||||
|
/* 富文本正文(T2):首尾 margin 归零,避免卡片被 <p> 的默认外边距撑开;
|
||||||
|
段间只留半档间距,列表缩进对齐正文基线。基调保持 15px / ink-2。 */
|
||||||
|
.mom-text {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.mom-text :global(> :first-child) { margin-top: 0; }
|
||||||
|
.mom-text :global(> :last-child) { margin-bottom: 0; }
|
||||||
|
.mom-text :global(p) { margin: 0 0 var(--space-1); }
|
||||||
|
.mom-text :global(ul),
|
||||||
|
.mom-text :global(ol) {
|
||||||
|
margin: 0 0 var(--space-1);
|
||||||
|
padding-left: 1.35em;
|
||||||
|
}
|
||||||
|
.mom-text :global(li) { margin: 0; }
|
||||||
|
.mom-text :global(li + li) { margin-top: 2px; }
|
||||||
|
.mom-text :global(blockquote) {
|
||||||
|
margin: var(--space-1) 0;
|
||||||
|
padding-left: var(--space-2);
|
||||||
|
border-left: 2px solid var(--line);
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.mom-text :global(code) {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.92em;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
}
|
||||||
|
.mom-text :global(a) {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 0.18em;
|
||||||
|
}
|
||||||
.mom-fig {
|
.mom-fig {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -270,12 +297,7 @@ const iso = (d: Date) => d.toISOString();
|
|||||||
transition: border-color var(--dur) var(--ease);
|
transition: border-color var(--dur) var(--ease);
|
||||||
}
|
}
|
||||||
.install-btn:hover { border-color: var(--accent); }
|
.install-btn:hover { border-color: var(--accent); }
|
||||||
.pv-line {
|
.mom-page-meta { margin: 0 0 var(--space-4); }
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ink-4);
|
|
||||||
margin: 0 0 var(--space-4);
|
|
||||||
}
|
|
||||||
/* 初始隐藏仅在 JS 标记存在时生效:宁轻勿闹——透明度+8px 上移,进入视口一次性点亮 */
|
/* 初始隐藏仅在 JS 标记存在时生效:宁轻勿闹——透明度+8px 上移,进入视口一次性点亮 */
|
||||||
[data-mom-anim] .tl-item {
|
[data-mom-anim] .tl-item {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
+53
-1
@@ -1,3 +1,5 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
// 项目墙数据(极客层 P0):构建时双源拉取 + 仓内缓存兜底
|
// 项目墙数据(极客层 P0):构建时双源拉取 + 仓内缓存兜底
|
||||||
// 双源 = 自家 gitea(数据自控叙事)+ GitHub(社区 star 真值);置顶清单人工维护
|
// 双源 = 自家 gitea(数据自控叙事)+ GitHub(社区 star 真值);置顶清单人工维护
|
||||||
export interface RepoCard {
|
export interface RepoCard {
|
||||||
@@ -28,8 +30,18 @@ export const GITHUB_API = 'https://api.github.com/users/wait4xx/repos?per_page=1
|
|||||||
export const PINNED_DEFAULT: string[] = ['blog'];
|
export const PINNED_DEFAULT: string[] = ['blog'];
|
||||||
|
|
||||||
async function fetchJson(url: string): Promise<RawRepo[]> {
|
async function fetchJson(url: string): Promise<RawRepo[]> {
|
||||||
|
const headers: Record<string, string> = { 'User-Agent': 'wait4x-blog-build' };
|
||||||
|
// GitHub 无鉴权仅 60 次/小时(多次构建就耗尽)→ 有 token 就带上(5000/小时)
|
||||||
|
if (url.includes('api.github.com')) {
|
||||||
|
try {
|
||||||
|
const token = readFileSync(join(process.env.HOME ?? '', 'docker_apps/blog/github-token.txt'), 'utf8').trim();
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
} catch {
|
||||||
|
/* token 缺位则匿名(可能被限流,走兜底) */
|
||||||
|
}
|
||||||
|
}
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: { Accept: 'application/json', 'User-Agent': 'wait4x-blog-build' },
|
headers: { ...headers, Accept: 'application/json' },
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
|
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
|
||||||
@@ -158,3 +170,43 @@ function arrange(
|
|||||||
return b.stars - a.stars;
|
return b.stars - a.stars;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GitHub 账号统计(followers / public repos):token 缺位或请求失败一律返回 null(调用方隐藏数字) */
|
||||||
|
export interface GithubUserStats {
|
||||||
|
followers: number;
|
||||||
|
publicRepos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function githubUserStats(
|
||||||
|
login = 'wait4xx',
|
||||||
|
): Promise<GithubUserStats | null> {
|
||||||
|
const { readFileSync } = await import('node:fs');
|
||||||
|
const { join } = await import('node:path');
|
||||||
|
let token: string | null = null;
|
||||||
|
try {
|
||||||
|
token = readFileSync(join(process.env.HOME ?? '', 'docker_apps/blog/github-token.txt'), 'utf8').trim() || null;
|
||||||
|
} catch {
|
||||||
|
token = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://api.github.com/users/${login}`, {
|
||||||
|
headers: {
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
Accept: 'application/vnd.github+json',
|
||||||
|
'User-Agent': 'wait4x-blog-build',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn(`[profile] GitHub 用户统计获取失败 HTTP ${res.status},相关数字已隐藏`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { followers?: number; public_repos?: number };
|
||||||
|
return {
|
||||||
|
followers: Number(data.followers ?? 0),
|
||||||
|
publicRepos: Number(data.public_repos ?? 0),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[profile] GitHub 用户统计请求异常(${(err as Error).message}),相关数字已隐藏`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import LinkCardStyles from '../../components/geek/LinkCardStyles.astro';
|
|||||||
import PhotoZoom from '../../components/geek/PhotoZoom.astro';
|
import PhotoZoom from '../../components/geek/PhotoZoom.astro';
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import ArtalkComments from '../../components/ArtalkComments.astro';
|
import ArtalkComments from '../../components/ArtalkComments.astro';
|
||||||
|
import ViewMeta from '../../components/geek/ViewMeta.astro';
|
||||||
import { trackOf } from '../../content.config';
|
import { trackOf } from '../../content.config';
|
||||||
|
|
||||||
// URL = /archives/{slug},slug 即文件名,与 Halo 旧结构 1:1(不可改)
|
// URL = /archives/{slug},slug 即文件名,与 Halo 旧结构 1:1(不可改)
|
||||||
@@ -61,6 +62,7 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
|||||||
<span aria-hidden="true">·</span>
|
<span aria-hidden="true">·</span>
|
||||||
<span>阅读时长 {minutes} 分钟</span>
|
<span>阅读时长 {minutes} 分钟</span>
|
||||||
</p>
|
</p>
|
||||||
|
<ViewMeta pageKey={`/archives/${entry.id}/`} mode="page" variant="inline" />
|
||||||
</header>
|
</header>
|
||||||
<div class="prose-reading">
|
<div class="prose-reading">
|
||||||
<Content />
|
<Content />
|
||||||
@@ -88,6 +90,7 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
|||||||
<span aria-hidden="true">·</span>
|
<span aria-hidden="true">·</span>
|
||||||
<span>阅读时长 {minutes} 分钟</span>
|
<span>阅读时长 {minutes} 分钟</span>
|
||||||
</p>
|
</p>
|
||||||
|
<ViewMeta pageKey={`/archives/${entry.id}/`} mode="page" variant="inline" />
|
||||||
</header>
|
</header>
|
||||||
<div class="tech-grid">
|
<div class="tech-grid">
|
||||||
<div class="prose-tech">
|
<div class="prose-tech">
|
||||||
@@ -209,6 +212,8 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
|||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
/* 计数徽标(ViewMeta 组件根元素)紧贴 meta 行;scoped 样式够不着子组件内部,需 :global */
|
||||||
|
.post-head :global(.viewmeta) { margin-top: 8px; }
|
||||||
.post-meta {
|
.post-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import LinkCardStyles from '../../../components/geek/LinkCardStyles.astro';
|
|||||||
import PhotoZoom from '../../../components/geek/PhotoZoom.astro';
|
import PhotoZoom from '../../../components/geek/PhotoZoom.astro';
|
||||||
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||||
import ArtalkComments from '../../../components/ArtalkComments.astro';
|
import ArtalkComments from '../../../components/ArtalkComments.astro';
|
||||||
|
import ViewMeta from '../../../components/geek/ViewMeta.astro';
|
||||||
import AiTranslatedBadge from '../../../components/AiTranslatedBadge.astro';
|
import AiTranslatedBadge from '../../../components/AiTranslatedBadge.astro';
|
||||||
import { t } from '../../../i18n/ui';
|
import { t } from '../../../i18n/ui';
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ const minutes = Math.max(1, Math.ceil(words / 220));
|
|||||||
<span aria-hidden="true">·</span>
|
<span aria-hidden="true">·</span>
|
||||||
<span>{minutes} {t('reading_time_min', 'en')}</span>
|
<span>{minutes} {t('reading_time_min', 'en')}</span>
|
||||||
</p>
|
</p>
|
||||||
|
<ViewMeta pageKey={`/archives/${entry.id}/`} locale="en" mode="page" variant="inline" />
|
||||||
</header>
|
</header>
|
||||||
<AiTranslatedBadge />
|
<AiTranslatedBadge />
|
||||||
<div class={isReading ? 'prose-reading' : 'prose-tech'}>
|
<div class={isReading ? 'prose-reading' : 'prose-tech'}>
|
||||||
@@ -85,6 +87,8 @@ const minutes = Math.max(1, Math.ceil(words / 220));
|
|||||||
.post-head { margin-bottom: var(--space-4); }
|
.post-head { margin-bottom: var(--space-4); }
|
||||||
.post-cat { font-family: var(--font-mono); font-size: 12px; color: var(--ink-3); letter-spacing: 0.08em; }
|
.post-cat { font-family: var(--font-mono); font-size: 12px; color: var(--ink-3); letter-spacing: 0.08em; }
|
||||||
.post-title { font-size: var(--text-h1); line-height: var(--leading-heading); color: var(--ink-1); text-wrap: balance; }
|
.post-title { font-size: var(--text-h1); line-height: var(--leading-heading); color: var(--ink-1); text-wrap: balance; }
|
||||||
|
/* 计数徽标紧贴 meta 行(组件根元素在子作用域,需 :global) */
|
||||||
|
.post-head :global(.viewmeta) { margin-top: 8px; }
|
||||||
.post-meta { display: flex; align-items: center; gap: var(--space-2); margin-top: var(--space-2); font-size: 13px; color: var(--ink-3); }
|
.post-meta { display: flex; align-items: center; gap: var(--space-2); margin-top: var(--space-2); font-size: 13px; color: var(--ink-3); }
|
||||||
.emo { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
|
.emo { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
|
||||||
.pn { display: flex; flex-direction: column; gap: var(--space-2); margin-top: var(--space-5); }
|
.pn { display: flex; flex-direction: column; gap: var(--space-2); margin-top: var(--space-5); }
|
||||||
|
|||||||
@@ -2,25 +2,23 @@
|
|||||||
import ProjectWall from '../../components/geek/ProjectWall.astro';
|
import ProjectWall from '../../components/geek/ProjectWall.astro';
|
||||||
import Heatmap from '../../components/geek/Heatmap.astro';
|
import Heatmap from '../../components/geek/Heatmap.astro';
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import GithubProfile from '../../components/geek/GithubProfile.astro';
|
||||||
import { t } from '../../i18n/ui';
|
import { t } from '../../i18n/ui';
|
||||||
import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown, renderRich } from '../../lib/site-content';
|
import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown } from '../../lib/site-content';
|
||||||
|
|
||||||
const copy = await openSourceCopy('en');
|
const copy = await openSourceCopy('en');
|
||||||
const options = await openSourceOptions();
|
const options = await openSourceOptions();
|
||||||
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
|
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
|
||||||
const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null;
|
const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null;
|
||||||
const blurbHtml = await renderRich(copy.blurb, { inline: true });
|
|
||||||
const blurbAfterHtml = await renderRich(copy.blurbAfter, { inline: true });
|
|
||||||
const typo = await pageTypography('siteOpenSource', 'opensource');
|
const typo = await pageTypography('siteOpenSource', 'opensource');
|
||||||
---
|
---
|
||||||
<BaseLayout title="Open Source · wait4x" description="My open-source activity: repos, contributions, and this site's source">
|
<BaseLayout title="Open Source · wait4x" description="My open-source activity: repos, contributions, and this site's source">
|
||||||
<h1 class="page-title">Open Source</h1>
|
<h1 class="page-title">Open Source</h1>
|
||||||
<div class={typo.className || undefined} style={typo.style || undefined}>
|
<div class={typo.className || undefined} style={typo.style || undefined}>
|
||||||
{readmeHtml ? (
|
<GithubProfile locale="en" />
|
||||||
<div class="blurb readme-intro" set:html={readmeHtml} />
|
{/* compat path (not recommended): renders the profile README below the card when
|
||||||
) : (
|
intro_from=github-readme. It pulls 7 kinds of third-party images and loses align centering. */}
|
||||||
<p class="blurb rich" set:html={`${blurbHtml}<a class="link" href="${copy.repoUrl}">${copy.repoLabel}</a>${blurbAfterHtml}`} />
|
{readmeHtml && <div class="blurb readme-intro" set:html={readmeHtml} />}
|
||||||
)}
|
|
||||||
<Heatmap labels={{
|
<Heatmap labels={{
|
||||||
title: t('heatmap_title', 'en'), summary: t('heatmap_summary', 'en'),
|
title: t('heatmap_title', 'en'), summary: t('heatmap_summary', 'en'),
|
||||||
less: t('heatmap_less', 'en'), more: t('heatmap_more', 'en'), note: t('heatmap_note', 'en'),
|
less: t('heatmap_less', 'en'), more: t('heatmap_more', 'en'), note: t('heatmap_note', 'en'),
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
import ProjectWall from '../components/geek/ProjectWall.astro';
|
import ProjectWall from '../components/geek/ProjectWall.astro';
|
||||||
import Heatmap from '../components/geek/Heatmap.astro';
|
import Heatmap from '../components/geek/Heatmap.astro';
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import GithubProfile from '../components/geek/GithubProfile.astro';
|
||||||
import { t } from '../i18n/ui';
|
import { t } from '../i18n/ui';
|
||||||
import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown, renderRich } from '../lib/site-content';
|
import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown } from '../lib/site-content';
|
||||||
|
|
||||||
// 叙述文案 + 置顶名单 + 项目墙选项均改由 content/site/opensource.yaml 供给(后台可改)
|
// 叙述文案 + 置顶名单 + 项目墙选项均改由 content/site/opensource.yaml 供给(后台可改)
|
||||||
const copy = await openSourceCopy('zh');
|
const copy = await openSourceCopy('zh');
|
||||||
@@ -11,19 +12,16 @@ const options = await openSourceOptions();
|
|||||||
// 开篇来源:github-readme → 构建期取 profile 仓 README(缓存 content/.github-readme.md,失败回落 yaml blurb)
|
// 开篇来源:github-readme → 构建期取 profile 仓 README(缓存 content/.github-readme.md,失败回落 yaml blurb)
|
||||||
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
|
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
|
||||||
const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null;
|
const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null;
|
||||||
// 层 1:blurb 两段走统一富文本管线(后台可写 <span class="font-kai c-blue">)
|
|
||||||
const blurbHtml = await renderRich(copy.blurb, { inline: true });
|
|
||||||
const blurbAfterHtml = await renderRich(copy.blurbAfter, { inline: true });
|
|
||||||
const typo = await pageTypography('siteOpenSource', 'opensource');
|
const typo = await pageTypography('siteOpenSource', 'opensource');
|
||||||
---
|
---
|
||||||
<BaseLayout title="开源 · wait4x" description="我的开源活动:仓库、贡献与本站源码">
|
<BaseLayout title="开源 · wait4x" description="我的开源活动:仓库、贡献与本站源码">
|
||||||
<h1 class="page-title">开源<span class="count mono">Open Source</span></h1>
|
<h1 class="page-title">开源<span class="count mono">Open Source</span></h1>
|
||||||
<div class={typo.className || undefined} style={typo.style || undefined}>
|
<div class={typo.className || undefined} style={typo.style || undefined}>
|
||||||
{readmeHtml ? (
|
<GithubProfile locale="zh" />
|
||||||
<div class="blurb readme-intro" set:html={readmeHtml} />
|
{/* 兼容通道(不推荐):intro_from=github-readme 时把 profile README 原样渲染在档案卡下方。
|
||||||
) : (
|
它会引入 7 类第三方外链图(shields.io / readme-typing-svg / stats / snake / 访客徽章)并带失效的
|
||||||
<p class="blurb rich" set:html={`${blurbHtml}<a class="link" href="${copy.repoUrl}">${copy.repoLabel}</a>${blurbAfterHtml}`} />
|
align 居中;慢网或被墙时整片空白。默认 off,正常内容由上方原生档案卡 + opensource.yaml 叙述承载。 */}
|
||||||
)}
|
{readmeHtml && <div class="blurb readme-intro" set:html={readmeHtml} />}
|
||||||
<Heatmap labels={{
|
<Heatmap labels={{
|
||||||
title: t('heatmap_title', 'zh'), summary: t('heatmap_summary', 'zh'),
|
title: t('heatmap_title', 'zh'), summary: t('heatmap_summary', 'zh'),
|
||||||
less: t('heatmap_less', 'zh'), more: t('heatmap_more', 'zh'), note: t('heatmap_note', 'zh'),
|
less: t('heatmap_less', 'zh'), more: t('heatmap_more', 'zh'), note: t('heatmap_note', 'zh'),
|
||||||
|
|||||||
@@ -34,6 +34,28 @@ ok('首页加载', (await page.title()).includes('wait4x'));
|
|||||||
ok('热力图渲染', await page.locator('.hm-grid').count() > 0);
|
ok('热力图渲染', await page.locator('.hm-grid').count() > 0);
|
||||||
ok('项目墙渲染', await page.locator('.project-wall, .repo').count() > 0);
|
ok('项目墙渲染', await page.locator('.project-wall, .repo').count() > 0);
|
||||||
|
|
||||||
|
// 0) Phase 5h:置顶 / 计数徽标 / 瞬间富文本与逐条评论 / 块式关于页
|
||||||
|
await page.goto(BASE + '/archives/', { waitUntil: 'networkidle' });
|
||||||
|
const firstArchive = await page.locator('.post-list a.row').first().getAttribute('href');
|
||||||
|
ok('5h·置顶排在归档首位', (firstArchive ?? '').includes('/archives/writing-template/'), firstArchive ?? 'none');
|
||||||
|
ok('5h·置顶徽标可见', (await page.locator('.pin').count()) > 0);
|
||||||
|
|
||||||
|
await page.goto(BASE + '/archives/writing-template/', { waitUntil: 'networkidle' });
|
||||||
|
ok('5h·文章页计数组件挂载', (await page.locator('[data-viewmeta]').count()) >= 2, `count=${await page.locator('[data-viewmeta]').count()}`);
|
||||||
|
ok('5h·模板彩色强调生效', (await page.locator('.prose-tech .c-blue').count()) > 0);
|
||||||
|
ok('5h·模板折叠块渲染', (await page.locator('.prose-tech details').count()) > 0);
|
||||||
|
|
||||||
|
await page.goto(BASE + '/moments/', { waitUntil: 'networkidle' });
|
||||||
|
ok('5h·瞬间带逐条评论区', (await page.locator('details[data-mom-comments]').count()) > 0);
|
||||||
|
ok('5h·瞬间卡片带只读计数', (await page.locator('.mom-card [data-viewmeta]').count()) > 0);
|
||||||
|
await page.locator('details[data-mom-comments]').first().locator('summary').click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
// 计数徽标是 details 的兄弟(ViewMeta 根节点为 <p>,塞进 <summary> 属非法嵌套)
|
||||||
|
ok('5h·瞬间计数为只读模式', (await page.locator('.mom-cm-meta [data-viewmeta]').first().getAttribute('data-mode')) === 'readonly');
|
||||||
|
|
||||||
|
await page.goto(BASE + '/about/', { waitUntil: 'networkidle' });
|
||||||
|
ok('5h·关于页由块驱动', (await page.locator('[data-block]').count()) > 0, `blocks=${await page.locator('[data-block]').count()}`);
|
||||||
|
|
||||||
// 1) 明暗切换:按钮 + 状态持久 + t 键
|
// 1) 明暗切换:按钮 + 状态持久 + t 键
|
||||||
const before = await page.getAttribute('html', 'data-theme');
|
const before = await page.getAttribute('html', 'data-theme');
|
||||||
await page.click('[data-theme-toggle]');
|
await page.click('[data-theme-toggle]');
|
||||||
|
|||||||
Reference in new issue
Block a user