feat(content): 链接卡片模式——独立成段裸链接转 OG 预览卡

- rehype-link-cards.ts 构建期插件:段落仅含一个链接才转卡(内联链接
  保持普通样式);fetch OG/twitter 元信息(UA 伪装、4s 超时、只读前
  200KB)+ favicon 兜底;任何失败回落普通链接,构建绝不因外部站点挂
- content/.link-cache.json 入仓缓存(30 天刷新,构建确定性与零重复
  请求);单篇上限 10 卡、并发 4,超出回落
- LinkCardStyles.astro 灰阶卡片样式:hairline+圆角、favicon 16px、
  标题一行/描述两行截断、域名 mono、OG 图右侧 88px(无图不占位)、
  hover 1px 微浮(reduced-motion 豁免)、纯 CSS 零 JS
- 安全:抓取内容全 escaping、图片地址仅 http(s)
- @astrojs/markdown-remark 依赖(Astro 7 换默认 markdown 处理器后
  自定义插件必须经此包生效)
- 样张 content/posts/link-cards-test.md(tags 含 hidden,活样例不列出)

修复说明:264148f 提交时误带入 archives 页面对 LinkCardStyles 的
import(两车道共改同一文件),本笔补全组件与插件,恢复构建仓可用。
教训:跨车道共改文件,staged 前必须看 diff 归属。

车道:op-artalk;收编复验:本地构建 45 页 + 卡片渲染(后续专项复验)

验证: pnpm astro build 45 页 exit 0;link-cards 样张页渲染正常

Spec: none (链接卡片,车道级交付)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-02 12:46:26 +08:00
1 parent 264148f0a6
commit f5232b7c03
7 files changed
+1701 -14

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
---
// 链接卡片样式(构建期由 rehype 插件产出 .link-card 节点,元素无 astro cid):
// 样式必须全局。灰阶克制 + 纯 CSS(hover 微浮、reduced-motion 豁免)。
---
<style is:global>
.link-card {
display: flex;
align-items: stretch;
gap: var(--space-3);
margin: var(--space-3) 0;
padding: var(--space-3);
border: 1px solid var(--line);
border-radius: var(--radius-img, 10px);
text-decoration: none;
transition: border-color var(--dur) var(--ease), transform var(--dur) var(--ease);
}
.link-card:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
@media (prefers-reduced-motion: reduce) {
.link-card, .link-card:hover { transition: none; transform: none; }
}
.lc-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.lc-title {
color: var(--ink-1);
font-size: 14px;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lc-desc {
color: var(--ink-3);
font-size: 13px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.lc-host {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--ink-4);
}
.lc-favicon {
width: 16px;
height: 16px;
border-radius: 3px;
object-fit: contain;
}
/* 缩略图:右侧 88px 方形,无图不占位(元素不存在即无空间) */
.lc-thumb {
flex: none;
width: 88px;
height: 88px;
object-fit: cover;
border-radius: 6px;
border: 1px solid var(--line);
}
@media (max-width: 560px) {
.lc-thumb { width: 64px; height: 64px; }
}
</style>
+285
View File
@@ -0,0 +1,285 @@
/**
* rehype 链接卡片:独立成段的裸链接 → OG 预览卡(构建期抓取 + 仓内缓存)。
*
* 触发:段落 children 去掉空白后恰为一个 <a> 时转卡(内联链接保持普通样式)。
* 抓取:UA 伪装、4s 超时、仅读 HTML 前 200KB、解析 og/twitter 系列的标题描述图 + favicon;
* 任何失败回落普通链接——构建绝不因外部站点失败。
* 缓存:content/.link-cache.json(入仓,保证构建确定性);30 天 TTL 后再抓。
* 安全:标题/描述一律走 hast text 节点(序列化时自动转义,杜绝注入);
* og:image / favicon 仅接受 http(s) URL。
* 护栏:每篇最多 MAX_CARDS 张卡,超出回落;全局并发 4。
*/
import fs from 'node:fs';
import path from 'node:path';
const CACHE_PATH = path.join(process.cwd(), 'content', '.link-cache.json');
const CACHE_TTL = 30 * 24 * 3600 * 1000;
const MAX_CARDS = 10;
const CONCURRENCY = 4;
const TIMEOUT_MS = 4000;
const MAX_BYTES = 200 * 1024;
const UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
interface CardMeta {
title: string;
desc: string;
image: string;
favicon: string;
ts: number;
}
type Cache = Record<string, CardMeta>;
const stats = { cards: 0, fallback: 0 };
function readCache(): Cache {
try {
const raw = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8')) as Record<string, unknown>;
const out: Cache = {};
for (const [url, v] of Object.entries(raw)) {
if (url.startsWith('_')) continue; // 文件头说明字段
if (v && typeof v === 'object') out[url] = v as CardMeta;
}
return out;
} catch {
return {};
}
}
const cache: Cache = readCache();
let cacheDirty = false;
function writeCache(): void {
if (!cacheDirty) return;
const payload = {
_readme:
'链接卡片抓取缓存(构建确定性需求,入仓)。键=URL,值={title,desc,image,favicon,ts};ts 超 30 天刷新。手工改动安全:删条目即下次构建重抓。',
...cache,
};
try {
fs.writeFileSync(CACHE_PATH, JSON.stringify(payload, null, 2) + '\n');
cacheDirty = false;
} catch {
/* 缓存写失败不阻断构建 */
}
}
// 并发闸:全局最多 4 个在途抓取
let active = 0;
const queue: (() => void)[] = [];
async function withSlot<T>(fn: () => Promise<T>): Promise<T> {
if (active >= CONCURRENCY) await new Promise<void>((r) => queue.push(r));
active++;
try {
return await fn();
} finally {
active--;
queue.shift()?.();
}
}
/** 只允许 http(s):og:image/favicon 来自外部站点,协议白名单挡住 data:/javascript: 等 */
function safeUrl(raw: string, base: string): string {
if (!raw) return '';
try {
const u = new URL(raw, base);
return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : '';
} catch {
return '';
}
}
/** 读流并截断到 MAX_BYTES:外部页面可能很大,只看头部足够拿到 meta */
async function readHead(res: Response): Promise<string> {
const body = res.body;
if (!body) return (await res.text()).slice(0, MAX_BYTES);
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
while (total < MAX_BYTES) {
const { done, value } = await reader.read();
if (done || !value) break;
chunks.push(value);
total += value.byteLength;
}
try { await reader.cancel(); } catch { /* 已结束 */ }
return new TextDecoder('utf-8', { fatal: false }).decode(
Buffer.concat(chunks).subarray(0, MAX_BYTES),
);
}
function extractMeta(html: string, base: string): Omit<CardMeta, 'ts'> | null {
const metas = html.match(/<meta\b[^>]*>/gi) ?? [];
const pick: Record<string, string> = {};
for (const tag of metas) {
const key = /(?:property|name)\s*=\s*["']([^"']+)["']/i.exec(tag)?.[1]?.toLowerCase();
const val = /content\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1];
if (key && val && !(key in pick)) pick[key] = val;
}
// 契约门槛:仅当站点显式提供 OG/Twitter 标题才建卡;只有 <title> 的无 OG 站回落普通链接
const title = pick['og:title'] || pick['twitter:title'] || '';
const desc = pick['og:description'] || pick['twitter:description'] || pick['description'] || '';
const image = safeUrl(pick['og:image'] || pick['twitter:image'] || '', base);
// favicon:<link rel~="icon"> 优先,/favicon.ico 兜底(不依赖第三方 s2 服务)
const iconHref =
(/<link\b[^>]*rel\s*=\s*["'][^"']*icon[^"']*["'][^>]*>/i.exec(html) ?? [])
.map((t) => /href\s*=\s*["']([^"']+)["']/i.exec(t)?.[1] ?? '')[0] ?? '';
const favicon = safeUrl(iconHref || '/favicon.ico', base);
if (!title) return null; // 无 OG/Twitter 标题=无预览语义,回落普通链接(契约反例)
return {
title: decodeEntities(title).slice(0, 200),
desc: decodeEntities(desc).slice(0, 300),
image,
favicon,
};
}
/** 常见 HTML 实体还原(og 字段常有 &amp; 等;最终仍走 text 节点再转义一次) */
function decodeEntities(s: string): string {
return s
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
async function fetchMeta(url: string): Promise<CardMeta | null> {
const hit = cache[url];
if (hit && Date.now() - hit.ts < CACHE_TTL) return hit;
try {
const meta = await withSlot(async () => {
const res = await fetch(url, {
headers: { 'User-Agent': UA, Accept: 'text/html,application/xhtml+xml' },
signal: AbortSignal.timeout(TIMEOUT_MS),
redirect: 'follow',
});
if (!res.ok) return null;
const html = await readHead(res);
const m = extractMeta(html, url);
return m ? { ...m, ts: Date.now() } : null;
});
if (meta) {
cache[url] = meta;
cacheDirty = true;
writeCache();
}
return meta;
} catch {
return null; // 超时/网络/证书失败:一律回落
}
}
// ── hast 构造(text 节点天然转义) ──
const el = (tagName: string, properties: Record<string, unknown>, children: unknown[] = []) =>
({ type: 'element', tagName, properties, children }) as const;
const txt = (value: string) => ({ type: 'text', value }) as const;
function buildCard(url: string, meta: CardMeta) {
const href = safeUrl(url, url);
const host = (() => {
try { return new URL(href).hostname.replace(/^www\./, ''); } catch { return ''; }
})();
const body = el('div', { className: ['lc-body'] }, [
el('div', { className: ['lc-title'] }, [txt(meta.title)]),
...(meta.desc ? [el('div', { className: ['lc-desc'] }, [txt(meta.desc)])] : []),
el('div', { className: ['lc-host'] }, [
...(meta.favicon
? [el('img', { className: ['lc-favicon'], src: meta.favicon, alt: '', loading: 'lazy', decoding: 'async', width: 16, height: 16 })]
: []),
txt(host),
]),
]);
return el(
'a',
{
className: ['link-card'],
href,
target: '_blank',
rel: 'noopener noreferrer',
},
[...(meta.image
? [el('img', { className: ['lc-thumb'], src: meta.image, alt: '', loading: 'lazy', decoding: 'async' })]
: []),
body],
);
}
/** 段落内「只有一个链接」判定:忽略空白 text */
function loneLink(node: any): any | null {
const kids = (node.children ?? []).filter(
(c: any) => !(c.type === 'text' && !c.value.trim()),
);
if (kids.length !== 1) return null;
const only = kids[0];
if (only.type !== 'element' || only.tagName !== 'a') return null;
return only;
}
export default function rehypeLinkCards() {
return async (tree: any) => {
const jobs: { parent: any; index: number; url: string }[] = [];
let count = 0;
const walk = (node: any) => {
const kids: any[] = node.children ?? [];
for (let i = 0; i < kids.length; i++) {
const child = kids[i];
if (child.type === 'element' && child.tagName === 'p') {
const link = loneLink(child);
const rawHref = link?.properties?.href;
if (link && typeof rawHref === 'string' && /^https?:\/\//.test(rawHref)) {
if (count < MAX_CARDS) {
jobs.push({ parent: node, index: i, url: rawHref });
count++;
} else {
stats.fallback++; // 超护栏:保留普通链接
}
}
}
if (child.children) walk(child);
}
};
walk(tree);
if (jobs.length === 0) return;
const results = await Promise.all(jobs.map((j) => fetchMeta(j.url)));
// 从后往前替换,避免下标偏移(同父内多个卡片)
const byParent = new Map<any, { index: number; card: any }[]>();
results.forEach((meta, k) => {
const job = jobs[k]!;
if (!meta) { stats.fallback++; return; }
if (!byParent.has(job.parent)) byParent.set(job.parent, []);
byParent.get(job.parent)!.push({ index: job.index, card: buildCard(job.url, meta) });
});
for (const [parent, list] of byParent) {
list.sort((a, b) => b.index - a.index).forEach(({ index, card }) => {
parent.children[index] = card;
stats.cards++;
});
}
writeCache();
};
}
// 构建收尾汇总(单进程 build):N 卡片 / M 回落
/**
* 汇总日志集成:rehype 插件无法感知"最后一个文件",Astro CLI 末尾 `process.exit` 又会跳过
* beforeExit/exit 钩子——用 build:done 阶段汇报(契约要求「N 卡片 / M 回落」)。
*/
export function linkCardsReport() {
return {
name: 'link-cards-report',
hooks: {
'astro:build:done': () => {
if (stats.cards || stats.fallback) {
console.log(`[link-cards] ${stats.cards} 卡片 / ${stats.fallback} 回落`);
}
writeCache();
},
},
};
}