internal/termtext/markdown.go
197 lines · 4636 bytes
1package termtext
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/yuin/goldmark"
8 "github.com/yuin/goldmark/ast"
9 "github.com/yuin/goldmark/extension"
10 east "github.com/yuin/goldmark/extension/ast"
11 "github.com/yuin/goldmark/text"
12)
13
14// md parses as the web does (CommonMark plus GFM); raw HTML is dropped
15// there and here.
16var md = goldmark.New(goldmark.WithExtensions(extension.GFM))
17
18func Markdown(src string, o Options) string {
19 return renderMarkdown(src, &out{o: o})
20}
21
22func renderMarkdown(src string, w *out) string {
23 source := []byte(src)
24 doc := md.Parser().Parse(text.NewReader(source))
25 r := mdRenderer{w: w, src: source}
26 r.blocks(doc, "", "")
27 return w.String()
28}
29
30// Inline is src as one line of plain text, links reduced to their
31// text, for event lines.
32func Inline(src, format string) string {
33 w := &out{noURLs: true}
34 var s string
35 if format == "org" {
36 s = renderOrg(src, w)
37 } else {
38 s = renderMarkdown(src, w)
39 }
40 return strings.Join(strings.Fields(s), " ")
41}
42
43type mdRenderer struct {
44 w *out
45 src []byte
46}
47
48// blocks renders n's children with a blank line between them. The
49// first child's first line is prefixed by first, every other line by
50// rest.
51func (r mdRenderer) blocks(n ast.Node, first, rest string) {
52 p := first
53 for c := n.FirstChild(); c != nil; c = c.NextSibling() {
54 if c != n.FirstChild() {
55 r.w.blank()
56 }
57 r.block(c, p, rest)
58 p = rest
59 }
60}
61
62func (r mdRenderer) block(n ast.Node, first, rest string) {
63 switch n := n.(type) {
64 case *ast.Heading:
65 r.w.para(r.w.paint(sgrBold, r.inline(n)), first, rest)
66 case *ast.Paragraph:
67 r.w.para(r.inline(n), first, rest)
68 case *ast.TextBlock:
69 r.w.para(r.inline(n), first, rest)
70 case *ast.List:
71 i := n.Start
72 p := first
73 for item := n.FirstChild(); item != nil; item = item.NextSibling() {
74 marker := "• "
75 if n.IsOrdered() {
76 marker = fmt.Sprintf("%d. ", i)
77 i++
78 }
79 hang := rest + strings.Repeat(" ", cells(marker))
80 for c := item.FirstChild(); c != nil; c = c.NextSibling() {
81 if c == item.FirstChild() {
82 r.block(c, p+marker, hang)
83 } else {
84 if !n.IsTight {
85 r.w.blank()
86 }
87 r.block(c, hang, hang)
88 }
89 }
90 p = rest
91 }
92 case *ast.FencedCodeBlock:
93 r.w.code(r.lines(n), string(n.Language(r.src)), rest)
94 case *ast.CodeBlock:
95 r.w.code(r.lines(n), "", rest)
96 case *ast.Blockquote:
97 bar := r.w.paint(sgrDim, "│ ")
98 r.blocks(n, first+bar, rest+bar)
99 case *ast.ThematicBreak:
100 r.w.rule(first)
101 case *ast.HTMLBlock:
102 // Dropped, as the web drops it.
103 default:
104 // GFM tables and anything else: the source, as a code block.
105 // Tables (and their rows/cells) don't carry Lines() themselves,
106 // so span the raw source under the node instead.
107 r.w.code(r.raw(n), "", rest)
108 }
109}
110
111func (r mdRenderer) lines(n ast.Node) string {
112 var b strings.Builder
113 ls := n.Lines()
114 for i := 0; i < ls.Len(); i++ {
115 seg := ls.At(i)
116 b.Write(seg.Value(r.src))
117 }
118 return b.String()
119}
120
121// raw returns the source text spanned by n and its descendants,
122// extended to whole lines.
123func (r mdRenderer) raw(n ast.Node) string {
124 start, end := -1, -1
125 var walk func(ast.Node)
126 walk = func(x ast.Node) {
127 if x.Type() == ast.TypeBlock {
128 ls := x.Lines()
129 for i := 0; i < ls.Len(); i++ {
130 seg := ls.At(i)
131 if start == -1 || seg.Start < start {
132 start = seg.Start
133 }
134 if seg.Stop > end {
135 end = seg.Stop
136 }
137 }
138 }
139 for c := x.FirstChild(); c != nil; c = c.NextSibling() {
140 walk(c)
141 }
142 }
143 walk(n)
144 if start == -1 {
145 return ""
146 }
147 for start > 0 && r.src[start-1] != '\n' {
148 start--
149 }
150 for end < len(r.src) && r.src[end] != '\n' {
151 end++
152 }
153 return string(r.src[start:end])
154}
155
156func (r mdRenderer) inline(n ast.Node) string {
157 var b strings.Builder
158 for c := n.FirstChild(); c != nil; c = c.NextSibling() {
159 switch c := c.(type) {
160 case *ast.Text:
161 b.Write(c.Segment.Value(r.src))
162 switch {
163 case c.HardLineBreak():
164 b.WriteString("\n")
165 case c.SoftLineBreak():
166 b.WriteString(" ")
167 }
168 case *ast.String:
169 b.Write(c.Value)
170 case *ast.CodeSpan:
171 b.WriteString(r.inline(c))
172 case *ast.Emphasis:
173 sgr := sgrUnderline
174 if c.Level == 2 {
175 sgr = sgrBold
176 }
177 b.WriteString(r.w.paint(sgr, r.inline(c)))
178 case *ast.Link:
179 b.WriteString(r.w.link(r.inline(c), string(c.Destination)))
180 case *ast.AutoLink:
181 u := string(c.URL(r.src))
182 b.WriteString(r.w.link(u, u))
183 case *ast.Image:
184 b.WriteString("[image: " + r.inline(c) + "]")
185 case *ast.RawHTML:
186 case *east.TaskCheckBox:
187 if c.IsChecked {
188 b.WriteString("[x] ")
189 } else {
190 b.WriteString("[ ] ")
191 }
192 default:
193 b.WriteString(r.inline(c))
194 }
195 }
196 return b.String()
197}