113 lines
3.1 KiB
Go
113 lines
3.1 KiB
Go
package utils
|
||
|
||
import (
|
||
"bytes"
|
||
"html/template"
|
||
"strings"
|
||
|
||
"github.com/yuin/goldmark"
|
||
"github.com/yuin/goldmark/ast"
|
||
"github.com/yuin/goldmark/extension"
|
||
goldmarkhtml "github.com/yuin/goldmark/renderer/html"
|
||
"github.com/yuin/goldmark/text"
|
||
)
|
||
|
||
// markdownEngine 全局 Markdown 渲染引擎
|
||
// 启用 GFM 扩展(表格、删除线、任务列表、自动链接等)
|
||
// 启用 WithUnsafe 允许原始 HTML 透传,兼容旧文章中的 HTML 内容
|
||
var markdownEngine = goldmark.New(
|
||
goldmark.WithExtensions(extension.GFM),
|
||
goldmark.WithRendererOptions(
|
||
goldmarkhtml.WithUnsafe(),
|
||
goldmarkhtml.WithHardWraps(),
|
||
),
|
||
)
|
||
|
||
// searchableTextParser 复用同一套扩展配置,但只做解析,不渲染。
|
||
// 与 markdownEngine 共享 goldmark 实例的 Parser,避免重复构造解析器开销。
|
||
var searchableTextParser = markdownEngine.Parser()
|
||
|
||
// RenderMarkdown 将 Markdown 文本渲染为 HTML
|
||
func RenderMarkdown(source string) template.HTML {
|
||
var buf bytes.Buffer
|
||
if err := markdownEngine.Convert([]byte(source), &buf); err != nil {
|
||
// 渲染失败时原样返回,避免页面空白
|
||
return template.HTML(source)
|
||
}
|
||
return template.HTML(buf.String())
|
||
}
|
||
|
||
// GenerateSearchableText 提取 Markdown 中的可见纯文本(基于 goldmark AST)
|
||
func GenerateSearchableText(raw string) string {
|
||
if raw == "" {
|
||
return ""
|
||
}
|
||
|
||
// 全角空格转半角
|
||
raw = strings.ReplaceAll(raw, "\u3000", " ")
|
||
|
||
source := []byte(raw)
|
||
doc := searchableTextParser.Parse(text.NewReader(source))
|
||
|
||
var sb strings.Builder
|
||
extractSearchableText(doc, source, &sb)
|
||
|
||
// 压缩空白(等价于原 reMultiSpace + TrimSpace)
|
||
return strings.Join(strings.Fields(sb.String()), " ")
|
||
}
|
||
|
||
// extractSearchableText 深度优先遍历 AST,把可见文本写入 sb
|
||
func extractSearchableText(node ast.Node, source []byte, sb *strings.Builder) {
|
||
switch n := node.(type) {
|
||
case *ast.Image:
|
||
// 图片:整棵子树跳过(含 alt 文本),对应原 reImage / reRefImage
|
||
return
|
||
|
||
case *ast.HTMLBlock, *ast.RawHTML:
|
||
// 原始 HTML:跳过,对应原 reHTMLTag
|
||
return
|
||
|
||
case *ast.AutoLink:
|
||
// 自动链接 <url> / <email>:保留 URL / 邮箱
|
||
// n.URL(source) 返回链接目标;邮箱自动链接会带 mailto: 前缀,这里去掉
|
||
url := string(n.URL(source))
|
||
url = strings.TrimPrefix(url, "mailto:")
|
||
sb.WriteString(url)
|
||
sb.WriteByte(' ')
|
||
return
|
||
|
||
case *ast.FencedCodeBlock, *ast.CodeBlock:
|
||
lines := n.Lines()
|
||
for i := 0; i < lines.Len(); i++ {
|
||
seg := lines.At(i)
|
||
sb.Write(seg.Value(source))
|
||
}
|
||
sb.WriteByte(' ')
|
||
return
|
||
|
||
case *ast.Text:
|
||
sb.Write(n.Segment.Value(source))
|
||
if n.SoftLineBreak() || n.HardLineBreak() {
|
||
sb.WriteByte(' ')
|
||
}
|
||
return
|
||
|
||
case *ast.String:
|
||
// 代码块 / 部分扩展产生的字符串节点
|
||
sb.Write(n.Value)
|
||
return
|
||
}
|
||
|
||
// 递归子节点
|
||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||
extractSearchableText(child, source, sb)
|
||
}
|
||
|
||
// 块级元素结束后补一个空格,避免相邻段落/列表项文本粘连
|
||
switch node.Kind() {
|
||
case ast.KindParagraph, ast.KindHeading, ast.KindListItem,
|
||
ast.KindBlockquote, ast.KindList:
|
||
sb.WriteByte(' ')
|
||
}
|
||
}
|