Commit 0670ca4ed6

0670ca4ed6f9f573fde7d040e1d59049f80dfc33

parent: b698d850a1

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-24 02:14 UTC

termtext: markdown for a terminal

Ref #254
internal/termtext/markdown.go added +197
@@ -0,0 +1,197 @@
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}
internal/termtext/markdown_test.go added +67
@@ -0,0 +1,67 @@
1package termtext
2
3import (
4 "flag"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11var update = flag.Bool("update", false, "rewrite golden files")
12
13func 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
28func 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
45func 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
62func 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 @@
1package termtext
2
3// Org renders org-mode source. Task 3.2 replaces this stub.
4func Org(src string, o Options) string {
5 return renderOrg(src, &out{o: o})
6}
7
8// renderOrg is a stub until Task 3.2.
9func 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.
5package termtext
6
7import (
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
17type Options struct {
18 Width int
19 Color bool
20 Base string
21}
22
23func Render(src, format string, o Options) string {
24 if format == "org" {
25 return Org(src, o)
26 }
27 return Markdown(src, o)
28}
29
30const (
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.
40type out struct {
41 o Options
42 b strings.Builder
43 noURLs bool // links as their text only (Inline)
44}
45
46func (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.
55func (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.
67func (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
83func (w *out) rule(prefix string) {
84 w.b.WriteString(prefix + w.paint(sgrDim, "───") + "\n")
85}
86
87func (w *out) blank() { w.b.WriteString("\n") }
88
89func (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.
96func (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.
114func 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
137func 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
161func 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
3A 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
91. first
102. second
11
12- [x] done
13- [ ] open
14
15> quoted text
16
17```go
18func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") }
19```
20
21![alt text](/img.png)
22
23---
24
25| a | b |
26|---|---|
27| 1 | 2 |
internal/termtext/testdata/doc.md.60.golden added +30
@@ -0,0 +1,30 @@
1A heading
2
3A paragraph long enough to wrap at sixty columns, with
4strong and emphasis, code, a link
5(https://example.com/page), a forge link
6(https://forge.test/krz/gitbay/issues/1), an autolink
7https://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
141. first
152. 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 @@
1A heading
2
3A paragraph long enough to wrap at sixty columns, with
4strong and emphasis, code, a link
5(https://example.com/page), a forge link
6(https://forge.test/krz/gitbay/issues/1), an autolink
7https://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
141. first
152. 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.plain.golden added +25
@@ -0,0 +1,25 @@
1A heading
2
3A 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
91. first
102. 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 |