package markdown import ( "fmt" "regexp" "strings" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" extast "github.com/yuin/goldmark/extension/ast" "github.com/yuin/goldmark/text" ) // plaintextParser only needs the GFM AST, never HTML output. var plaintextParser = goldmark.New(goldmark.WithExtensions(extension.GFM)).Parser() var ( tagRE = regexp.MustCompile(`]*>`) blankRunRE = regexp.MustCompile(`\n{3,}`) spaceRunRE = regexp.MustCompile(`\n+`) ) // ToPlaintext converts markdown to plain text, keeping structure such as list // prefixes and blockquote markers. Used for previews. func ToPlaintext(md string) string { src := []byte(md) doc := plaintextParser.Parse(text.NewReader(src)) out := renderPlain(doc, src) // Inline raw HTML reaches the output verbatim, so drop tag-shaped runs. out = tagRE.ReplaceAllString(out, "") return strings.TrimSpace(blankRunRE.ReplaceAllString(out, "\n\n")) } // renderPlain walks the children of n and concatenates their plain text. func renderPlain(n ast.Node, src []byte) string { var b strings.Builder for c := n.FirstChild(); c != nil; c = c.NextSibling() { b.WriteString(plainNode(c, src)) } return b.String() } func plainNode(n ast.Node, src []byte) string { switch node := n.(type) { case *ast.Heading, *ast.Paragraph, *ast.TextBlock: return renderPlain(n, src) + "\n\n" case *ast.FencedCodeBlock, *ast.CodeBlock: return linesOf(n, src) + "\n" case *ast.Blockquote: body := strings.TrimSpace(renderPlain(n, src)) lines := strings.Split(body, "\n") for i, l := range lines { lines[i] = "> " + l } return strings.Join(lines, "\n") + "\n\n" case *ast.List: return plainList(node, src) + "\n" case *ast.ThematicBreak: return "---\n\n" case *ast.HTMLBlock: return "" case *extast.Table: return renderPlain(n, src) + "\n" case *extast.TableHeader, *extast.TableRow: var cells []string for c := n.FirstChild(); c != nil; c = c.NextSibling() { cells = append(cells, strings.TrimSpace(renderPlain(c, src))) } return strings.Join(cells, " | ") + "\n" case *ast.Text: out := string(node.Segment.Value(src)) if node.SoftLineBreak() || node.HardLineBreak() { out += "\n" } return out case *ast.String: return string(node.Value) case *ast.CodeSpan: return "`" + renderPlain(n, src) + "`" case *ast.AutoLink: return string(node.URL(src)) case *ast.RawHTML: return "" case *ast.Image: label := strings.TrimSpace(string(node.Title)) if label == "" { label = strings.TrimSpace(renderPlain(n, src)) } if label == "" { return "[Image]" } return "[Image: " + label + "]" default: return renderPlain(n, src) } } // plainList prefixes each item with its bullet or number. func plainList(list *ast.List, src []byte) string { var b strings.Builder number := list.Start if number == 0 { number = 1 } for item := list.FirstChild(); item != nil; item = item.NextSibling() { prefix := "- " if list.IsOrdered() { prefix = fmt.Sprintf("%d. ", number) number++ } body := renderPlain(item, src) if cb, ok := item.FirstChild().(*ast.TextBlock); ok { if box, ok := cb.FirstChild().(*extast.TaskCheckBox); ok { if box.IsChecked { prefix += "[x] " } else { prefix += "[ ] " } } } body = spaceRunRE.ReplaceAllString(strings.TrimSpace(body), " ") b.WriteString(prefix + body + "\n") } return b.String() } // linesOf returns the raw source text of a block node. func linesOf(n ast.Node, src []byte) string { var b strings.Builder for i := 0; i < n.Lines().Len(); i++ { seg := n.Lines().At(i) b.Write(seg.Value(src)) } return b.String() }