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 / 邮箱 // 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(' ') } }