feat(moments): 瞬间富文本与逐条评论(5h T2/T3)

瞬间文字从纯文本直出升级到与文章同级表达力,每条动态获得独立评论流。

T2 富文本:
- text 在 frontmatter 预渲染成 momentId → html 的 Map(renderRich 是 async,
  模板内不能 await),模板只此一处 set:html 且喂的是净化产物
- 走与全站文案同一条管线:markdown → 内容净化(标签/属性/协议三层白名单)
  → class 白名单 35 项,故 <span class="c-blue font-kai"> 生效、
  <script> 整棵子树被丢弃(实测)
- 纯文本老数据零回归:无 markdown 的 text 得到单个 <p>,与旧直出视觉等价
- 卡片内富文本首尾 margin 归零、段间半档、列表缩进 1.35em,15px/ink-2 基调不变

T3 逐条评论:
- 新增 MomentComments.astro:<details> 折叠,**展开才拉 Artalk 并 init**
  (未展开连 Artalk.js 都不下载,时间线一屏十几条不白付成本)
- page_key = /moments/#<slug>,中英双语共用同一评论流(与文章页同策略)
- pvAdd: false 且复用 ViewMeta readonly 计数:读者开评论不会虚增浏览数
- 5s 兜底失败提示 + 重试;软导航回页对已展开的框补挂
- Playwright 实测同页两个容器各自 init 成功(各渲染 31 个 atk-* 节点,无异常),
  故无需降级;若日后内存吃紧可对上一实例调 destroy() 一行切换

验证: 构建 exit 0;E2E 37/37(快照口径);临时瞬间实测 markdown 粗体、
      彩色+楷体类保留、行内码、<script> 净化丢弃、details 与 /moments/# 键、
      ViewMeta 只读挂载全部命中(验收数据已还原)

Spec: 2026-11-02_blog-5h-content-social (T2/T3)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-02 20:20:31 +08:00
1 parent 5bf0c327bf
commit 8e74bead62
3 files changed
+285 -3

No files matched your search

@@ -13,12 +13,12 @@
- 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 瞬间富文本(字体可换 / 颜色可换 / 多形式)
- [x] T2 瞬间富文本(字体可换 / 颜色可换 / 多形式)
- 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
- 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
- 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"`
+234
View File
@@ -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>
+49 -1
View File
@@ -2,7 +2,9 @@
import { Image } from 'astro:assets';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import MomentComments from '../MomentComments.astro';
import { t } from '../../i18n/ui';
import { renderRich } from '../../lib/site-content';
import type { Locale } from '../../lib/locale';
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
@@ -14,6 +16,16 @@ const { locale = 'zh' } = Astro.props;
const moments = (await getCollection('moments')).sort(
(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();
---
@@ -46,12 +58,13 @@ const iso = (d: Date) => d.toISOString();
/>
</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 && (
<p class="mom-tags">
{m.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
</p>
)}
<MomentComments slug={m.id} locale={locale} />
</div>
</li>
))}
@@ -208,6 +221,41 @@ const iso = (d: Date) => d.toISOString();
background: var(--bg-soft);
}
.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 {
border-radius: 8px;
overflow: hidden;