import DOMPurify from 'dompurify' // 后端 goldmark 已转义原始 HTML,这里再过一道 DOMPurify 作纵深防御, // 所有 v-html 出口必须经过它。 export function sanitizeHtml(html) { return enhanceProse(DOMPurify.sanitize(html || '')) } // ---------- 缩略图 ---------- // 时间线只展示小图:把我们存储上的图片直链改写为 /uploads/thumb/{key}?w=, // 后端懒生成并永久缓存。识别规则:以 uploads_public_base 开头的直链, // 或同源的 /uploads/ 相对路径(本地存储兜底)。外链(图床/头像)原样返回。 // base 由 site.js 拉到站点设置后经 setThumbBase 注入(不走 import 避免循环依赖)。 const IMG_EXT = /\.(jpe?g|png|gif)([?#].*)?$/i let thumbBase = '' export function setThumbBase(base) { thumbBase = (base || '').replace(/\/+$/, '') } export function thumbURL(u, w = 480) { if (!u || typeof u !== 'string') return u let key = '' if (thumbBase && u.startsWith(thumbBase + '/')) key = u.slice(thumbBase.length + 1) else if (u.startsWith('/uploads/') && !u.startsWith('/uploads/thumb/')) key = u.slice('/uploads/'.length) else return u if (!IMG_EXT.test(key)) return u return '/uploads/thumb/' + key + '?w=' + w } // ---------- xLog 式排版增强 ---------- // 对消毒后的正文 HTML 做三件展示层增强(markdown/库里内容不动): // 1) pre 包一层容器并注入悬停复制按钮; // 2) 带 id 的标题(withHeadingIds 生成)尾部加 # 锚点; // 3) (盘古之白在后端渲染出口做,见 backend/internal/render/pangu.go) export function enhanceProse(html) { if (!html) return html // 复制按钮:包在 .pre-wrap 里,按钮悬浮于 pre 右上 html = html .replace(//g, '') // 标题锚点:h2-h4 且有 id 的,末尾补一个 # 链接(悬停浮现) html = html.replace( /]*\bid="([^"]+)"[^>]*)>([\s\S]*?)<\/h\1>/g, (m, lvl, attrs, id, inner) => `${inner}#` ) return html } // 编辑器拖拽缩放的比例展示:Milkdown 图片块把高度比例写进 markdown 的 alt // (![0.6](url)),渲染出的 0.6 在这里还原成对应的宽度并居中。 // 纯数字 alt 才生效(>=1 的当 100% 处理),正常图片的描述性 alt 不受影响。 export function applyImageRatio(html) { if (!html) return html return html.replace(/]*>/gi, (tag) => { const m = tag.match(/\balt="(\d*\.?\d+)"/i) if (!m) return tag const ratio = Number.parseFloat(m[1]) if (!Number.isFinite(ratio) || ratio <= 0 || ratio >= 1) return tag const style = `display:block;width:${(ratio * 100).toFixed(1)}%;max-width:100%;height:auto;margin-inline:auto;` if (/\bstyle="/i.test(tag)) return tag.replace(/\bstyle="/i, 'style="' + style) return tag.replace(/^ 换缩略图 + 补懒加载。 // 只在展示层重写,库里存的正文不动。截断的 HTML 可能带半截标签, // 正则只匹配完整的 ,坏尾巴交给 sanitize/浏览器容错。 export function thumbifyHtml(html, w = 480) { if (!html) return html return html.replace(/]*?>/gi, (tag) => { let out = tag.replace(/\bsrc="([^"]*)"/i, (m, src) => 'src="' + thumbURL(src, w) + '"') if (!/\bloading=/i.test(out)) out = out.replace(/^= 10000) return (v / 10000).toFixed(1).replace(/\.0$/, '') + '万' return String(v) } export function formatDate(iso) { if (!iso) return '' const d = new Date(iso) if (Number.isNaN(d.getTime())) return iso return longDateFmt.format(d) } export function formatDateShort(iso) { if (!iso) return '' const d = new Date(iso) if (Number.isNaN(d.getTime())) return iso return shortDateFmt.format(d) } // 时间线上的相对时间:三天内用「几小时前」,同年省略年份 export function relativeDate(iso) { if (!iso) return '' const d = new Date(iso) if (Number.isNaN(d.getTime())) return iso const diff = Date.now() - d.getTime() const hour = 3600 * 1000 if (diff < hour) return `${Math.max(1, Math.floor(diff / 60000))} 分钟前` if (diff < 24 * hour) return `${Math.floor(diff / hour)} 小时前` if (diff < 3 * 24 * hour) return `${Math.floor(diff / (24 * hour))} 天前` const sameYear = d.getFullYear() === new Date().getFullYear() return sameYear ? `${d.getMonth() + 1} 月 ${d.getDate()} 日` : `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}` } export function stripTags(html) { // 不用 div.innerHTML:那会真正解析并触发 之类的事件 return new DOMParser().parseFromString(html || '', 'text/html').body.textContent || '' } // 短文在列表里没有标题,用正文首句当索引 export function displayTitle(post) { if (post.kind === 'short') return '' return post.title || '无题' } export function excerpt(post, limit = 60) { if (post.summary) return post.summary const text = stripTags(post.content_html || '') return text.length > limit ? text.slice(0, limit) + '…' : text } export function minutesLabel(post) { if (post.kind === 'short') return '短文' return `${post.reading_minutes || 1} 分钟` } // 把 text 插进 textarea 光标处(没聚焦过就追加到末尾),返回新值和插入后的 // 光标位。光标重定位要在赋值后的 nextTick 里做(DOM 更新完 setSelectionRange // 才不会被打断),所以这里只算,不动 DOM: // const r = insertAtCursor(ta, draft.value, text); draft.value = r.value // nextTick(() => placeCursor(ta, r.pos)) export function insertAtCursor(ta, value, text) { const focused = !!ta && document.activeElement === ta && typeof ta.selectionStart === 'number' const pos = focused ? ta.selectionStart : value.length return { value: value.slice(0, pos) + text + value.slice(pos), pos: pos + text.length } } export function placeCursor(ta, pos) { if (!ta) return ta.focus() try { ta.setSelectionRange(pos, pos) } catch (_) { /* 个别输入类型不支持 setSelectionRange,忽略 */ } }