plaintext.go
⎇
Raw
1package markdown
2
3import (
4 "fmt"
5 "regexp"
6 "strings"
7
8 "github.com/yuin/goldmark"
9 "github.com/yuin/goldmark/ast"
10 "github.com/yuin/goldmark/extension"
11 extast "github.com/yuin/goldmark/extension/ast"
12 "github.com/yuin/goldmark/text"
13)
14
15// plaintextParser only needs the GFM AST, never HTML output.
16var plaintextParser = goldmark.New(goldmark.WithExtensions(extension.GFM)).Parser()
17
18var (
19 tagRE = regexp.MustCompile(`</?[a-zA-Z][^>]*>`)
20 blankRunRE = regexp.MustCompile(`\n{3,}`)
21 spaceRunRE = regexp.MustCompile(`\n+`)
22)
23
24// ToPlaintext converts markdown to plain text, keeping structure such as list
25// prefixes and blockquote markers. Used for previews.
26func ToPlaintext(md string) string {
27 src := []byte(md)
28 doc := plaintextParser.Parse(text.NewReader(src))
29 out := renderPlain(doc, src)
30 // Inline raw HTML reaches the output verbatim, so drop tag-shaped runs.
31 out = tagRE.ReplaceAllString(out, "")
32 return strings.TrimSpace(blankRunRE.ReplaceAllString(out, "\n\n"))
33}
34
35// renderPlain walks the children of n and concatenates their plain text.
36func renderPlain(n ast.Node, src []byte) string {
37 var b strings.Builder
38 for c := n.FirstChild(); c != nil; c = c.NextSibling() {
39 b.WriteString(plainNode(c, src))
40 }
41 return b.String()
42}
43
44func plainNode(n ast.Node, src []byte) string {
45 switch node := n.(type) {
46 case *ast.Heading, *ast.Paragraph, *ast.TextBlock:
47 return renderPlain(n, src) + "\n\n"
48
49 case *ast.FencedCodeBlock, *ast.CodeBlock:
50 return linesOf(n, src) + "\n"
51
52 case *ast.Blockquote:
53 body := strings.TrimSpace(renderPlain(n, src))
54 lines := strings.Split(body, "\n")
55 for i, l := range lines {
56 lines[i] = "> " + l
57 }
58 return strings.Join(lines, "\n") + "\n\n"
59
60 case *ast.List:
61 return plainList(node, src) + "\n"
62
63 case *ast.ThematicBreak:
64 return "---\n\n"
65
66 case *ast.HTMLBlock:
67 return ""
68
69 case *extast.Table:
70 return renderPlain(n, src) + "\n"
71
72 case *extast.TableHeader, *extast.TableRow:
73 var cells []string
74 for c := n.FirstChild(); c != nil; c = c.NextSibling() {
75 cells = append(cells, strings.TrimSpace(renderPlain(c, src)))
76 }
77 return strings.Join(cells, " | ") + "\n"
78
79 case *ast.Text:
80 out := string(node.Segment.Value(src))
81 if node.SoftLineBreak() || node.HardLineBreak() {
82 out += "\n"
83 }
84 return out
85
86 case *ast.String:
87 return string(node.Value)
88
89 case *ast.CodeSpan:
90 return "`" + renderPlain(n, src) + "`"
91
92 case *ast.AutoLink:
93 return string(node.URL(src))
94
95 case *ast.RawHTML:
96 return ""
97
98 case *ast.Image:
99 label := strings.TrimSpace(string(node.Title))
100 if label == "" {
101 label = strings.TrimSpace(renderPlain(n, src))
102 }
103 if label == "" {
104 return "[Image]"
105 }
106 return "[Image: " + label + "]"
107
108 default:
109 return renderPlain(n, src)
110 }
111}
112
113// plainList prefixes each item with its bullet or number.
114func plainList(list *ast.List, src []byte) string {
115 var b strings.Builder
116 number := list.Start
117 if number == 0 {
118 number = 1
119 }
120 for item := list.FirstChild(); item != nil; item = item.NextSibling() {
121 prefix := "- "
122 if list.IsOrdered() {
123 prefix = fmt.Sprintf("%d. ", number)
124 number++
125 }
126 body := renderPlain(item, src)
127 if cb, ok := item.FirstChild().(*ast.TextBlock); ok {
128 if box, ok := cb.FirstChild().(*extast.TaskCheckBox); ok {
129 if box.IsChecked {
130 prefix += "[x] "
131 } else {
132 prefix += "[ ] "
133 }
134 }
135 }
136 body = spaceRunRE.ReplaceAllString(strings.TrimSpace(body), " ")
137 b.WriteString(prefix + body + "\n")
138 }
139 return b.String()
140}
141
142// linesOf returns the raw source text of a block node.
143func linesOf(n ast.Node, src []byte) string {
144 var b strings.Builder
145 for i := 0; i < n.Lines().Len(); i++ {
146 seg := n.Lines().At(i)
147 b.Write(seg.Value(src))
148 }
149 return b.String()
150}
151