diff --git a/.gitignore b/.gitignore index 5ee4849..d3ccc86 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ vendor/ # 命令行工具 cmd/ +.opencode/.npmrc +.opencode/opencode.jsonc +.opencode/openwork.json diff --git a/utils/markdown.go b/utils/markdown.go index a15036c..381f479 100644 --- a/utils/markdown.go +++ b/utils/markdown.go @@ -3,12 +3,13 @@ package utils import ( "bytes" "html/template" - "regexp" "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 渲染引擎 @@ -22,6 +23,10 @@ var markdownEngine = goldmark.New( ), ) +// searchableTextParser 复用同一套扩展配置,但只做解析,不渲染。 +// 与 markdownEngine 共享 goldmark 实例的 Parser,避免重复构造解析器开销。 +var searchableTextParser = markdownEngine.Parser() + // RenderMarkdown 将 Markdown 文本渲染为 HTML func RenderMarkdown(source string) template.HTML { var buf bytes.Buffer @@ -32,102 +37,76 @@ func RenderMarkdown(source string) template.HTML { return template.HTML(buf.String()) } -// 全局预编译正则(保持不变,但移除冗余) -var ( - // 块级语法 - reHeading = regexp.MustCompile(`(?m)^\s*#{1,6}\s+`) - // 列表 - reUnorderedList = regexp.MustCompile(`(?m)^\s*[-*+]\s+`) - // 有序列表 - reOrderedList = regexp.MustCompile(`(?m)^\s*\d+[.)]\s+`) - // 块引用 - reBlockquote = regexp.MustCompile(`(?m)^\s*>\s+`) - // 分割线 - reHr = regexp.MustCompile(`(?m)^\s*[-*_]{3,}\s*$`) - - // 引用定义 - reFootnoteDef = regexp.MustCompile(`(?m)^\[\^.+?\]:\s.*$`) - reRefDef = regexp.MustCompile(`(?m)^\s*\[.+?\]:\s*(.*)$`) - - // 图片(全部移除) - reImage = regexp.MustCompile(`!\[.*?\]\(.*?\)`) - reRefImage = regexp.MustCompile(`!\[.*?\](\[.*?\])?`) - - // 内联链接 [text](url) → 保留 text - reLink = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`) - // 引用式链接 [text][id] 或 [text][] → 保留 text - reRefLink = regexp.MustCompile(`\[([^\]]*)\](\[[^\]]*\])?`) - - // 行内标记(加粗、斜体、删除线) - reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`) - reBoldUnd = regexp.MustCompile(`__(.+?)__`) - reItalicStar = regexp.MustCompile(`\*(.+?)\*`) // 注意:必须在粗体之后处理 - reItalicUnd = regexp.MustCompile(`_(.+?)_`) // 同上 - reStrike = regexp.MustCompile(`~~(.+?)~~`) // 删除线 - - // 为保留代码内容(可能含关键词),此处只移除反引号,但代码块可能多行,我们保留内容。 - reCodeInline = regexp.MustCompile("`([^`]*)`") - reCodeBlock = regexp.MustCompile("(?s)```(.*?)```") - - // HTML 标签 - reHTMLTag = regexp.MustCompile(`<[^>]+>`) - - // 表格分隔线(移除 --- 行,保留内容) - reTableSep = regexp.MustCompile(`(?m)^\s*\|?\s*[-:]+[-| :]+\s*\|?\s*$`) - - // 多余空白 - reMultiSpace = regexp.MustCompile(`\s+`) -) - -// GenerateSearchableText 提取 Markdown 中的可见纯文本 +// GenerateSearchableText 提取 Markdown 中的可见纯文本(基于 goldmark AST) func GenerateSearchableText(raw string) string { if raw == "" { return "" } - text := raw - // 1. 全角空格转半角 - text = strings.ReplaceAll(text, "\u3000", " ") + // 全角空格转半角 + raw = strings.ReplaceAll(raw, "\u3000", " ") - // 2. 移除块级标记(标题、列表、引用、分割线) - text = reHeading.ReplaceAllString(text, "") - text = reUnorderedList.ReplaceAllString(text, "") - text = reOrderedList.ReplaceAllString(text, "") - text = reBlockquote.ReplaceAllString(text, "") - text = reHr.ReplaceAllString(text, "") + source := []byte(raw) + doc := searchableTextParser.Parse(text.NewReader(source)) - // 3. 移除引用定义行 - text = reFootnoteDef.ReplaceAllString(text, "") - text = reRefDef.ReplaceAllString(text, "") + var sb strings.Builder + extractSearchableText(doc, source, &sb) - // 4. 移除图片(所有形式) - text = reImage.ReplaceAllString(text, "") - text = reRefImage.ReplaceAllString(text, "") - - // 5. 移除代码块(整块移除,因为代码块内容通常不参与自然语言搜索) - text = reCodeBlock.ReplaceAllString(text, "$1") - - // 6. 处理链接(先引用式后内联) - text = reLink.ReplaceAllString(text, "$1") - text = reRefLink.ReplaceAllString(text, "$1") - - // 7. 移除行内代码反引号(保留代码内容) - text = reCodeInline.ReplaceAllString(text, "") - - // 8. 移除粗体、斜体、删除线标记(保留文字) - text = reBoldStar.ReplaceAllString(text, "$1") - text = reBoldUnd.ReplaceAllString(text, "$1") - text = reItalicStar.ReplaceAllString(text, "$1") - text = reItalicUnd.ReplaceAllString(text, "$1") - text = reStrike.ReplaceAllString(text, "$1") - - // 9. 移除表格分隔线(保留表格内容) - text = reTableSep.ReplaceAllString(text, "") - - // 10. 移除 HTML 标签 - text = reHTMLTag.ReplaceAllString(text, "") - - // 11. 压缩空白 - text = reMultiSpace.ReplaceAllString(text, " ") - return strings.TrimSpace(text) + // 压缩空白(等价于原 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(' ') + } }