Commit 0670ca4ed6
Verified · cmc
internal/termtext/markdown.go added +197
| @@ -0,0 +1,197 @@ | ||
| 1 | package termtext | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 16 | var md = goldmark.New(goldmark.WithExtensions(extension.GFM)) | |
| 17 | ||
| 18 | func Markdown(src string, o Options) string { | |
| 19 | return renderMarkdown(src, &out{o: o}) | |
| 20 | } | |
| 21 | ||
| 22 | func 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. | |
| 32 | func 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 | ||
| 43 | type 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. | |
| 51 | func (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 | ||
| 62 | func (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 | ||
| 111 | func (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. | |
| 123 | func (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 | ||
| 156 | func (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 | } | |
internal/termtext/markdown_test.go added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | package termtext | |
| 2 | ||
| 3 | import ( | |
| 4 | "flag" | |
| 5 | "os" | |
| 6 | "path/filepath" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | ) | |
| 10 | ||
| 11 | var update = flag.Bool("update", false, "rewrite golden files") | |
| 12 | ||
| 13 | func golden(t *testing.T, name, got string) { | |
| 14 | t.Helper() | |
| 15 | path := filepath.Join("testdata", name) | |
| 16 | if *update { | |
| 17 | os.WriteFile(path, []byte(got), 0o644) | |
| 18 | } | |
| 19 | want, err := os.ReadFile(path) | |
| 20 | if err != nil { | |
| 21 | t.Fatal(err) | |
| 22 | } | |
| 23 | if got != string(want) { | |
| 24 | t.Errorf("%s differs:\n--- got\n%s\n--- want\n%s", name, got, want) | |
| 25 | } | |
| 26 | } | |
| 27 | ||
| 28 | func TestMarkdownGolden(t *testing.T) { | |
| 29 | src, err := os.ReadFile("testdata/doc.md") | |
| 30 | if err != nil { | |
| 31 | t.Fatal(err) | |
| 32 | } | |
| 33 | for _, o := range []struct { | |
| 34 | name string | |
| 35 | opt Options | |
| 36 | }{ | |
| 37 | {"doc.md.plain.golden", Options{Base: "https://forge.test"}}, | |
| 38 | {"doc.md.60.golden", Options{Width: 60, Base: "https://forge.test"}}, | |
| 39 | {"doc.md.60color.golden", Options{Width: 60, Color: true, Base: "https://forge.test"}}, | |
| 40 | } { | |
| 41 | golden(t, o.name, Markdown(string(src), o.opt)) | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | func TestMarkdownWidth(t *testing.T) { | |
| 46 | src, _ := os.ReadFile("testdata/doc.md") | |
| 47 | out := Markdown(string(src), Options{Width: 60, Color: true}) | |
| 48 | inCode := false | |
| 49 | for _, line := range strings.Split(out, "\n") { | |
| 50 | plain := stripSGR(line) | |
| 51 | if strings.HasPrefix(plain, " ") { | |
| 52 | inCode = true | |
| 53 | } else if plain != "" { | |
| 54 | inCode = false | |
| 55 | } | |
| 56 | if !inCode && cells(plain) > 60 { | |
| 57 | t.Errorf("line of %d cells: %q", cells(plain), plain) | |
| 58 | } | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | func TestInlineDropsLinkTargets(t *testing.T) { | |
| 63 | got := Inline("referenced in commit [6c4d1e1454](/krz/gitbay/commit/6c4d) by [cmc](/cmc): landing", "md") | |
| 64 | if got != "referenced in commit 6c4d1e1454 by cmc: landing" { | |
| 65 | t.Errorf("Inline = %q", got) | |
| 66 | } | |
| 67 | } | |
internal/termtext/org.go added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | package termtext | |
| 2 | ||
| 3 | // Org renders org-mode source. Task 3.2 replaces this stub. | |
| 4 | func Org(src string, o Options) string { | |
| 5 | return renderOrg(src, &out{o: o}) | |
| 6 | } | |
| 7 | ||
| 8 | // renderOrg is a stub until Task 3.2. | |
| 9 | func renderOrg(src string, w *out) string { return src } | |
internal/termtext/termtext.go added +173
| @@ -0,0 +1,173 @@ | ||
| 1 | // Package termtext renders markdown and org to text for a terminal: | |
| 2 | // wrapped to a width, links reduced to their text, code highlighted | |
| 3 | // with 16 colours. Width 0 is plain: no wrapping and no SGR, for | |
| 4 | // piped output. | |
| 5 | package termtext | |
| 6 | ||
| 7 | import ( | |
| 8 | "bytes" | |
| 9 | "strings" | |
| 10 | "unicode" | |
| 11 | "unicode/utf8" | |
| 12 | ||
| 13 | "github.com/alecthomas/chroma/v2/quick" | |
| 14 | "golang.org/x/text/width" | |
| 15 | ) | |
| 16 | ||
| 17 | type Options struct { | |
| 18 | Width int | |
| 19 | Color bool | |
| 20 | Base string | |
| 21 | } | |
| 22 | ||
| 23 | func Render(src, format string, o Options) string { | |
| 24 | if format == "org" { | |
| 25 | return Org(src, o) | |
| 26 | } | |
| 27 | return Markdown(src, o) | |
| 28 | } | |
| 29 | ||
| 30 | const ( | |
| 31 | sgrReset = "\x1b[0m" | |
| 32 | sgrBold = "\x1b[1m" | |
| 33 | sgrDim = "\x1b[2m" | |
| 34 | sgrUnderline = "\x1b[4m" | |
| 35 | ) | |
| 36 | ||
| 37 | // out collects rendered lines. Every block goes through it so the | |
| 38 | // prefixes (indent, list marker, quote bar) and the wrap live in one | |
| 39 | // place. | |
| 40 | type out struct { | |
| 41 | o Options | |
| 42 | b strings.Builder | |
| 43 | noURLs bool // links as their text only (Inline) | |
| 44 | } | |
| 45 | ||
| 46 | func (w *out) paint(sgr, s string) string { | |
| 47 | if !w.o.Color || s == "" { | |
| 48 | return s | |
| 49 | } | |
| 50 | return sgr + s + sgrReset | |
| 51 | } | |
| 52 | ||
| 53 | // para writes s wrapped to the width, the first line after first and | |
| 54 | // the rest after rest. Hard breaks in s ("\n") start a new line. | |
| 55 | func (w *out) para(s, first, rest string) { | |
| 56 | prefix := first | |
| 57 | for _, hard := range strings.Split(s, "\n") { | |
| 58 | for _, line := range wrap(hard, w.o.Width-cells(rest)) { | |
| 59 | w.b.WriteString(prefix + line + "\n") | |
| 60 | prefix = rest | |
| 61 | } | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | // code writes lines verbatim under prefix plus four spaces, | |
| 66 | // highlighted when colour is on. | |
| 67 | func (w *out) code(src, lang, prefix string) { | |
| 68 | src = strings.TrimRight(src, "\n") | |
| 69 | if w.o.Color && w.o.Width > 0 { | |
| 70 | var hb bytes.Buffer | |
| 71 | if lang == "" { | |
| 72 | lang = "plaintext" | |
| 73 | } | |
| 74 | if quick.Highlight(&hb, src, lang, "terminal16", "monokai") == nil { | |
| 75 | src = strings.TrimRight(hb.String(), "\n") | |
| 76 | } | |
| 77 | } | |
| 78 | for _, line := range strings.Split(src, "\n") { | |
| 79 | w.b.WriteString(prefix + " " + line + "\n") | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | func (w *out) rule(prefix string) { | |
| 84 | w.b.WriteString(prefix + w.paint(sgrDim, "───") + "\n") | |
| 85 | } | |
| 86 | ||
| 87 | func (w *out) blank() { w.b.WriteString("\n") } | |
| 88 | ||
| 89 | func (w *out) String() string { | |
| 90 | return strings.TrimRight(w.b.String(), "\n") + "\n" | |
| 91 | } | |
| 92 | ||
| 93 | // link is a link as terminal text: its text, then the target when the | |
| 94 | // target says something the text does not. Relative targets are made | |
| 95 | // absolute against Base. | |
| 96 | func (w *out) link(text, target string) string { | |
| 97 | if w.noURLs && text != "" { | |
| 98 | return text | |
| 99 | } | |
| 100 | if strings.HasPrefix(target, "/") && w.o.Base != "" { | |
| 101 | target = strings.TrimRight(w.o.Base, "/") + target | |
| 102 | } | |
| 103 | if text == "" { | |
| 104 | return target | |
| 105 | } | |
| 106 | if target == "" || target == text || strings.TrimPrefix(strings.TrimPrefix(target, "https://"), "http://") == text { | |
| 107 | return text | |
| 108 | } | |
| 109 | return text + " (" + target + ")" | |
| 110 | } | |
| 111 | ||
| 112 | // wrap breaks s at spaces into lines of at most width cells. A word | |
| 113 | // wider than width is a line of its own. width <= 0 is no wrapping. | |
| 114 | func wrap(s string, width int) []string { | |
| 115 | if width <= 0 { | |
| 116 | return []string{s} | |
| 117 | } | |
| 118 | var lines []string | |
| 119 | var cur string | |
| 120 | for _, word := range strings.Fields(s) { | |
| 121 | switch { | |
| 122 | case cur == "": | |
| 123 | cur = word | |
| 124 | case cells(cur)+1+cells(word) <= width: | |
| 125 | cur += " " + word | |
| 126 | default: | |
| 127 | lines = append(lines, cur) | |
| 128 | cur = word | |
| 129 | } | |
| 130 | } | |
| 131 | if cur != "" || len(lines) == 0 { | |
| 132 | lines = append(lines, cur) | |
| 133 | } | |
| 134 | return lines | |
| 135 | } | |
| 136 | ||
| 137 | func cells(s string) int { | |
| 138 | n := 0 | |
| 139 | for i := 0; i < len(s); { | |
| 140 | if s[i] == 0x1b { | |
| 141 | j := strings.IndexByte(s[i:], 'm') | |
| 142 | if j < 0 { | |
| 143 | break | |
| 144 | } | |
| 145 | i += j + 1 | |
| 146 | continue | |
| 147 | } | |
| 148 | r, size := utf8.DecodeRuneInString(s[i:]) | |
| 149 | i += size | |
| 150 | switch { | |
| 151 | case unicode.In(r, unicode.Mn, unicode.Me) || r == '': | |
| 152 | case width.LookupRune(r).Kind() == width.EastAsianWide || width.LookupRune(r).Kind() == width.EastAsianFullwidth: | |
| 153 | n += 2 | |
| 154 | default: | |
| 155 | n++ | |
| 156 | } | |
| 157 | } | |
| 158 | return n | |
| 159 | } | |
| 160 | ||
| 161 | func stripSGR(s string) string { | |
| 162 | var b strings.Builder | |
| 163 | for i := 0; i < len(s); i++ { | |
| 164 | if s[i] == 0x1b { | |
| 165 | if j := strings.IndexByte(s[i:], 'm'); j >= 0 { | |
| 166 | i += j | |
| 167 | continue | |
| 168 | } | |
| 169 | } | |
| 170 | b.WriteByte(s[i]) | |
| 171 | } | |
| 172 | return b.String() | |
| 173 | } | |
internal/termtext/testdata/doc.md added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | # A heading | |
| 2 | ||
| 3 | A paragraph long enough to wrap at sixty columns, with **strong** and *emphasis*, `code`, a [link](https://example.com/page), a [forge link](/krz/gitbay/issues/1), an autolink <https://example.com>, and ~~struck~~ text. | |
| 4 | ||
| 5 | - one | |
| 6 | - two, which is long enough that its continuation line has to hang under the text rather than the bullet | |
| 7 | - nested | |
| 8 | ||
| 9 | 1. first | |
| 10 | 2. second | |
| 11 | ||
| 12 | - [x] done | |
| 13 | - [ ] open | |
| 14 | ||
| 15 | > quoted text | |
| 16 | ||
| 17 | ```go | |
| 18 | func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") } | |
| 19 | ``` | |
| 20 | ||
| 21 |  | |
| 22 | ||
| 23 | --- | |
| 24 | ||
| 25 | | a | b | | |
| 26 | |---|---| | |
| 27 | | 1 | 2 | | |
internal/termtext/testdata/doc.md.60.golden added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | A heading | |
| 2 | ||
| 3 | A paragraph long enough to wrap at sixty columns, with | |
| 4 | strong and emphasis, code, a link | |
| 5 | (https://example.com/page), a forge link | |
| 6 | (https://forge.test/krz/gitbay/issues/1), an autolink | |
| 7 | https://example.com, and struck text. | |
| 8 | ||
| 9 | • one | |
| 10 | • two, which is long enough that its continuation line has | |
| 11 | to hang under the text rather than the bullet | |
| 12 | • nested | |
| 13 | ||
| 14 | 1. first | |
| 15 | 2. second | |
| 16 | ||
| 17 | • [x] done | |
| 18 | • [ ] open | |
| 19 | ||
| 20 | │ quoted text | |
| 21 | ||
| 22 | func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") } | |
| 23 | ||
| 24 | [image: alt text] | |
| 25 | ||
| 26 | ─── | |
| 27 | ||
| 28 | | a | b | | |
| 29 | |---|---| | |
| 30 | | 1 | 2 | | |
internal/termtext/testdata/doc.md.60color.golden added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | [1mA heading[0m | |
| 2 | ||
| 3 | A paragraph long enough to wrap at sixty columns, with | |
| 4 | [1mstrong[0m and [4memphasis[0m, code, a link | |
| 5 | (https://example.com/page), a forge link | |
| 6 | (https://forge.test/krz/gitbay/issues/1), an autolink | |
| 7 | https://example.com, and struck text. | |
| 8 | ||
| 9 | • one | |
| 10 | • two, which is long enough that its continuation line has | |
| 11 | to hang under the text rather than the bullet | |
| 12 | • nested | |
| 13 | ||
| 14 | 1. first | |
| 15 | 2. second | |
| 16 | ||
| 17 | • [x] done | |
| 18 | • [ ] open | |
| 19 | ||
| 20 | [2m│ [0mquoted text | |
| 21 | ||
| 22 | [96mfunc[0m[97m [0m[93mmain[0m[97m()[0m[97m [0m[97m{[0m[97m [0m[93mfmt[0m[97m.[0m[93mPrintln[0m[97m([0m[37m"a line longer than sixty columns stays on one line, unwrapped"[0m[97m)[0m[97m [0m[97m}[0m | |
| 23 | ||
| 24 | [image: alt text] | |
| 25 | ||
| 26 | [2m───[0m | |
| 27 | ||
| 28 | [97m| a | b |[0m | |
| 29 | [97m|---|---|[0m | |
| 30 | [97m| 1 | 2 |[0m | |
internal/termtext/testdata/doc.md.plain.golden added +25
| @@ -0,0 +1,25 @@ | ||
| 1 | A heading | |
| 2 | ||
| 3 | A paragraph long enough to wrap at sixty columns, with strong and emphasis, code, a link (https://example.com/page), a forge link (https://forge.test/krz/gitbay/issues/1), an autolink https://example.com, and struck text. | |
| 4 | ||
| 5 | • one | |
| 6 | • two, which is long enough that its continuation line has to hang under the text rather than the bullet | |
| 7 | • nested | |
| 8 | ||
| 9 | 1. first | |
| 10 | 2. second | |
| 11 | ||
| 12 | • [x] done | |
| 13 | • [ ] open | |
| 14 | ||
| 15 | │ quoted text | |
| 16 | ||
| 17 | func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") } | |
| 18 | ||
| 19 | [image: alt text] | |
| 20 | ||
| 21 | ─── | |
| 22 | ||
| 23 | | a | b | | |
| 24 | |---|---| | |
| 25 | | 1 | 2 | | |