Files
jiang13-forum/frontend/src/utils/postContent.ts
freefire c99238c1e7 feat: Vike +data 服务端取数 / SEO Head 移植 / 404 错误页 / 服务端 DOM 模拟
- feedData.ts 聚合首页/板块/帖子详情数据,缺失内容 throw render(404)
- Go 侧 spa_meta/seo 的 head 生成逻辑移植到 src/ssr/head.ts
- _error 页复用站点壳渲染 404,+data 从 abortReason 恢复壳层数据
- linkedom 模拟服务端 DOM,DOMPurify 封装为 SSR 透传的 sanitizeHtml
- 新增 /api/home-boot 聚合端点(handler/home_boot.go)供 SSR 冷启动灌缓存
2026-09-09 02:41:30 +08:00

370 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { sanitizeHtml } from './purify';
import type { Config } from 'dompurify';
import { enhanceCodeBlocks } from './enhanceCodeBlocks';
import { enhanceHeadingAnchors } from './postHeadings';
/**
* DOMPurify 配置:允许会员专属自定义标签与链接 target。
* 注意:DOMPurify 3.x 默认放行 <style> 与 style=(只做 XSS 向 CSS 消毒),
* 全局选择器仍会污染整页,故显式禁止。
*/
export const POST_CONTENT_PURIFY_CONFIG: Config = {
ADD_TAGS: ['members-only', 'reply-only', 'points-only'],
ADD_ATTR: [
'data-locked', 'data-length', 'data-gate', 'data-cost', 'data-block-key', 'target', 'rel',
'data-code-copy', 'data-code-fold', 'data-lang', 'data-full',
'data-code-style', 'data-line-numbers', 'data-collapsed', 'data-line-count', 'data-lineno-digits',
'data-image-group', 'data-layout', 'data-display',
'data-clear-float',
'colspan', 'rowspan',
'class',
],
FORBID_TAGS: ['style', 'link', 'meta', 'base', 'object', 'embed', 'form', 'input', 'button', 'textarea', 'select'],
FORBID_ATTR: ['style'],
};
const LOCK_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`;
const REPLY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 15v4a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h10"/><path d="M20 7V3"/><path d="M22 5h-4"/></svg>`;
/** 积分解锁门控 */
function buildPointsLockedGateHtml(charLength: number, cost: number, blockKey: string, isLoggedIn: boolean): string {
const lengthHint = charLength > 0 ? `约 ${charLength} 字` : '付费内容';
const actions = isLoggedIn
? `<button type="button" class="post-points-only__gate-btn" data-points-unlock data-block-key="${blockKey}" data-cost="${cost}">花费 ${cost} 积分解锁</button>`
: `<button type="button" class="post-points-only__gate-btn" data-members-login>登录后解锁</button>
<button type="button" class="post-points-only__gate-link" data-members-register>免费注册</button>`;
return `
<div class="post-points-only__locked-wrap">
<div class="post-points-only__gate">
<span class="post-points-only__gate-icon" aria-hidden="true">${LOCK_ICON_SVG}</span>
<div class="post-points-only__gate-text">
<p class="post-points-only__gate-title">积分可见(${lengthHint})</p>
<p class="post-points-only__gate-desc">解锁需 ${cost} 积分,作者获得分成</p>
</div>
<div class="post-points-only__gate-actions">
${actions}
</div>
</div>
</div>`;
}
/** 游客看到的锁定区块:流内嵌条 + 登录引导(精简高度) */
function buildLockedGateHtml(charLength: number): string {
const lengthHint = charLength > 0
? `约 ${charLength} 字`
: '专属内容';
return `
<div class="post-members-only__locked-wrap">
<div class="post-members-only__gate">
<span class="post-members-only__gate-icon" aria-hidden="true">${LOCK_ICON_SVG}</span>
<div class="post-members-only__gate-text">
<p class="post-members-only__gate-title">登录后可见(${lengthHint})</p>
<p class="post-members-only__gate-desc">作者将此段设为仅登录用户可读</p>
</div>
<div class="post-members-only__gate-actions">
<button type="button" class="post-members-only__gate-btn" data-members-login>登录查看</button>
<button type="button" class="post-members-only__gate-link" data-members-register>免费注册</button>
</div>
</div>
</div>`;
}
/** 回复可见锁定门控:游客引导登录,已登录引导去评论 */
function buildReplyLockedGateHtml(charLength: number, isLoggedIn: boolean): string {
const lengthHint = charLength > 0
? `约 ${charLength} 字`
: '隐藏内容';
const actions = isLoggedIn
? `<button type="button" class="post-reply-only__gate-btn" data-reply-scroll>去回复</button>`
: `<button type="button" class="post-reply-only__gate-btn" data-members-login>登录后回复</button>
<button type="button" class="post-reply-only__gate-link" data-members-register>免费注册</button>`;
return `
<div class="post-reply-only__locked-wrap">
<div class="post-reply-only__gate">
<span class="post-reply-only__gate-icon" aria-hidden="true">${REPLY_ICON_SVG}</span>
<div class="post-reply-only__gate-text">
<p class="post-reply-only__gate-title">回复后可见(${lengthHint})</p>
<p class="post-reply-only__gate-desc">作者将此段设为回复本帖后可读</p>
</div>
<div class="post-reply-only__gate-actions">
${actions}
</div>
</div>
</div>`;
}
/** 提取门控区块正文 HTML(去掉编辑态 badge) */
function extractGatedInnerHtml(el: Element, bodyClass: string, badgeClass: string): string {
return el.querySelector(`.${bodyClass}`)?.innerHTML
?? Array.from(el.childNodes)
.filter(n => !(n instanceof Element && n.classList.contains(badgeClass)))
.map(n => (n instanceof Element ? n.outerHTML : n.textContent ?? ''))
.join('');
}
/** 判断 HTML 正文是否为空(忽略空段落等,含图片/视频视为非空) */
export function isHtmlEmpty(html: string): boolean {
if (!html.trim()) return true;
const doc = new DOMParser().parseFromString(
sanitizeHtml(html, POST_CONTENT_PURIFY_CONFIG),
'text/html',
);
// 有文本内容
if ((doc.body.textContent ?? '').trim().length > 0) return false;
// 有图片、视频等媒体节点
if (doc.body.querySelector('img, video, iframe')) return false;
return true;
}
/** 根据登录状态渲染帖子正文 HTML */
export function renderPostContentHtml(
html: string,
isLoggedIn: boolean,
opts?: { openLinksInNewTab?: boolean },
): string {
if (!html.trim()) return '';
const doc = new DOMParser().parseFromString(
sanitizeHtml(html, POST_CONTENT_PURIFY_CONFIG),
'text/html',
);
doc.querySelectorAll('members-only').forEach(el => {
const locked = el.getAttribute('data-locked') === 'true' || !isLoggedIn;
if (locked) {
const charLength = parseInt(el.getAttribute('data-length') || '0', 10) || 0;
el.setAttribute('data-locked', 'true');
el.className = 'post-members-only post-members-only--locked';
el.innerHTML = buildLockedGateHtml(charLength);
return;
}
const innerHtml = extractGatedInnerHtml(
el,
'post-members-only__body',
'post-members-only__badge',
);
// 已登录:降噪,不展示醒目 badge,仅保留结构容器
el.className = 'post-members-only post-members-only--visible';
el.innerHTML = `<div class="post-members-only__body">${innerHtml}</div>`;
});
doc.querySelectorAll('reply-only').forEach(el => {
// 是否解锁由服务端 redact(data-locked)决定
const locked = el.getAttribute('data-locked') === 'true';
if (locked) {
const charLength = parseInt(el.getAttribute('data-length') || '0', 10) || 0;
el.className = 'post-reply-only post-reply-only--locked';
el.innerHTML = buildReplyLockedGateHtml(charLength, isLoggedIn);
return;
}
const innerHtml = extractGatedInnerHtml(
el,
'post-reply-only__body',
'post-reply-only__badge',
);
el.className = 'post-reply-only post-reply-only--visible';
el.innerHTML = `<div class="post-reply-only__body">${innerHtml}</div>`;
});
doc.querySelectorAll('points-only').forEach(el => {
const locked = el.getAttribute('data-locked') === 'true';
const cost = parseInt(el.getAttribute('data-cost') || '10', 10) || 10;
const blockKey = el.getAttribute('data-block-key') || '';
if (locked) {
const charLength = parseInt(el.getAttribute('data-length') || '0', 10) || 0;
el.className = 'post-points-only post-points-only--locked';
el.innerHTML = buildPointsLockedGateHtml(charLength, cost, blockKey, isLoggedIn);
return;
}
const innerHtml = extractGatedInnerHtml(
el,
'post-points-only__body',
'post-points-only__badge',
);
el.className = 'post-points-only post-points-only--visible';
el.innerHTML = `<div class="post-points-only__body">${innerHtml}</div>`;
});
doc.querySelectorAll('img').forEach(img => {
if (!img.getAttribute('loading')) img.setAttribute('loading', 'lazy');
if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async');
const rawSrc = (img.getAttribute('src') || '').trim();
const full = (img.getAttribute('data-full') || rawSrc).trim();
// 表情贴纸保持行内展示,不进灯箱;本地上传图与外链图均可点击查看
if (!full || full.startsWith('data:') || full.startsWith('blob:')) return;
if (rawSrc.includes('/stickers/') || full.includes('/stickers/')) return;
if (!img.getAttribute('data-full')) img.setAttribute('data-full', full);
img.classList.add('post-content-img--zoomable');
img.setAttribute('role', 'button');
img.setAttribute('tabindex', '0');
img.setAttribute('title', img.getAttribute('title') || '点击查看原图');
});
// 规范化图组 class,保证阅读态宫格样式生效
doc.querySelectorAll('div[data-image-group]').forEach(el => {
const layout = el.getAttribute('data-layout') || 'cols-2';
el.classList.add('image-group', `image-group--${layout}`);
});
// 单图展示形态 class
doc.querySelectorAll('img[data-display]').forEach(img => {
const display = img.getAttribute('data-display');
if (!display || display === 'default') return;
img.classList.add('article-img', `article-img--${display}`);
});
// 绕排图后:连续 ≥2 个空段落,则其后首个有内容块清除浮动(写到图下)
markClearFloatAfterBlankRuns(doc.body);
if (opts?.openLinksInNewTab) {
doc.querySelectorAll('a[href]').forEach(a => {
const href = a.getAttribute('href') || '';
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
a.setAttribute('target', '_blank');
const rel = new Set((a.getAttribute('rel') || '').split(/\s+/).filter(Boolean));
rel.add('noopener');
rel.add('noreferrer');
a.setAttribute('rel', Array.from(rel).join(' '));
});
}
enhanceHeadingAnchors(doc.body);
enhanceCodeBlocks(doc.body);
wrapContentTables(doc.body);
return doc.body.innerHTML;
}
/** 表格外包一层,避免 display:block 时边框撑满而单元格背景偏窄 */
function wrapContentTables(root: ParentNode): void {
root.querySelectorAll('table').forEach(table => {
if (table.parentElement?.classList.contains('md-table-wrap')) return;
const wrap = table.ownerDocument.createElement('div');
wrap.className = 'md-table-wrap';
table.replaceWith(wrap);
wrap.appendChild(table);
});
}
function isFloatDisplayImage(el: Element): boolean {
if (el.tagName !== 'IMG') return false;
const display = el.getAttribute('data-display') || '';
return display === 'float-left' || display === 'float-right'
|| el.classList.contains('article-img--float-left')
|| el.classList.contains('article-img--float-right');
}
/** 空段落 / 仅含 br、空白 */
function isBlankParagraph(el: Element): boolean {
if (el.tagName !== 'P') return false;
const text = (el.textContent || '').replace(/\u00a0/g, ' ').trim();
if (text.length > 0) return false;
return !el.querySelector('img, video, iframe, table, pre, blockquote, members-only, reply-only');
}
/**
* 浮动绕排后若作者连按多次回车再写字,给后续块加 clear,
* 避免「明明写在图下却仍贴在图右侧」。
* 阅读态与编辑器 DOM 均可调用。
*/
export function markClearFloatAfterBlankRuns(root: HTMLElement): void {
const blocks = [...root.children];
let seenFloat = false;
let blankRun = 0;
for (const el of blocks) {
// 已持久化的清浮动标记始终生效
if (el.hasAttribute('data-clear-float')) {
el.classList.add('article-clear-float');
}
if (isFloatDisplayImage(el)) {
seenFloat = true;
blankRun = 0;
continue;
}
// 通栏块本身会清浮动,重置状态
if (
el.tagName === 'IMG'
|| el.classList.contains('image-group')
|| /^H[1-6]$/.test(el.tagName)
|| el.tagName === 'HR'
|| el.tagName === 'PRE'
|| el.tagName === 'TABLE'
|| el.tagName === 'BLOCKQUOTE'
) {
seenFloat = isFloatDisplayImage(el);
blankRun = 0;
continue;
}
if (!seenFloat) {
if (!el.hasAttribute('data-clear-float')) {
el.classList.remove('article-clear-float');
}
blankRun = 0;
continue;
}
if (isBlankParagraph(el)) {
blankRun += 1;
continue;
}
// 双空行 或 已有 data-clear-float:保持写到图下
if (blankRun >= 2 || el.hasAttribute('data-clear-float')) {
el.classList.add('article-clear-float');
if (!el.hasAttribute('data-clear-float')) {
el.setAttribute('data-clear-float', '');
}
} else {
el.classList.remove('article-clear-float');
}
blankRun = 0;
}
}
/**
* 将帖子上传图 URL 转为缩略图地址。
* /uploads/posts/a.jpg → /media/thumb/posts/a.jpg
*/
export function toPostImageThumbSrc(src: string): string | null {
const path = extractUploadPath(src);
if (!path) return null;
if (path.startsWith('/media/thumb/')) return path;
if (!path.startsWith('/uploads/posts/')) return null;
return `/media/thumb/${path.slice('/uploads/'.length)}`;
}
/** 提取同源相对路径(忽略 query / hash) */
function extractUploadPath(src: string): string | null {
const raw = (src || '').trim();
if (!raw || raw.startsWith('data:') || raw.startsWith('blob:')) return null;
try {
if (raw.startsWith('http://') || raw.startsWith('https://')) {
const u = new URL(raw);
if (typeof window !== 'undefined' && u.origin !== window.location.origin) return null;
return u.pathname;
}
} catch {
return null;
}
const path = raw.split('?')[0].split('#')[0];
return path.startsWith('/') ? path : null;
}