Commit 468df8e4c3

468df8e4c3825ad7b3467d095cf4954ee55daf0b

parent: 49ab072901

Verified · cmc

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

termtext: org for a terminal

Ref #254
internal/termtext/org.go +135 −3
@@ -1,9 +1,141 @@
11package termtext
22
3// Org renders org-mode source. Task 3.2 replaces this stub.
3import (
4 "bytes"
5 "errors"
6 "io"
7 "log"
8 "strings"
9
10 "github.com/niklasfasching/go-org/org"
11)
12
413func Org(src string, o Options) string {
514 return renderOrg(src, &out{o: o})
615}
716
8// renderOrg is a stub until Task 3.2.
9func renderOrg(src string, w *out) string { return src }
17// renderOrg parses with the same restrictions as the web: no file is
18// ever read (#+INCLUDE, #+SETUPFILE), and parse warnings go nowhere.
19func renderOrg(src string, w *out) string {
20 c := org.New()
21 c.ReadFile = func(string) ([]byte, error) { return nil, errors.New("org: includes are disabled") }
22 c.Log = log.New(io.Discard, "", 0)
23 doc := c.Parse(bytes.NewReader([]byte(src)), "")
24 r := orgRenderer{w: w}
25 r.nodes(doc.Nodes, "", "")
26 return w.String()
27}
28
29type orgRenderer struct{ w *out }
30
31func (r orgRenderer) nodes(ns []org.Node, first, rest string) {
32 p := first
33 wrote := false
34 for _, n := range ns {
35 if skipOrg(n) {
36 continue
37 }
38 if wrote {
39 r.w.blank()
40 }
41 r.block(n, p, rest)
42 p, wrote = rest, true
43 }
44}
45
46// skipOrg drops nodes that carry nothing to render: keywords, property
47// drawers, comments, and the empty paragraph go-org emits for a blank
48// line that separated two blocks or ended a list item.
49func skipOrg(n org.Node) bool {
50 switch n := n.(type) {
51 case org.Keyword, *org.PropertyDrawer, org.Comment:
52 return true
53 case org.Paragraph:
54 return len(n.Children) == 0
55 }
56 return false
57}
58
59func (r orgRenderer) block(n org.Node, first, rest string) {
60 switch n := n.(type) {
61 case org.Headline:
62 r.w.para(r.w.paint(sgrBold, r.inline(n.Title)), first, rest)
63 if len(n.Children) > 0 {
64 r.w.blank()
65 r.nodes(n.Children, rest, rest)
66 }
67 case org.Paragraph:
68 r.w.para(r.inline(n.Children), first, rest)
69 case org.List:
70 p := first
71 for i, item := range n.Items {
72 if i > 0 && n.Kind == "descriptive" {
73 r.w.blank()
74 }
75 switch item := item.(type) {
76 case org.ListItem:
77 marker := "• "
78 if n.Kind == "ordered" {
79 marker = item.Bullet + " "
80 }
81 hang := rest + strings.Repeat(" ", cells(marker))
82 r.nodes(item.Children, p+marker, hang)
83 case org.DescriptiveListItem:
84 term := r.w.paint(sgrBold, r.inline(item.Term))
85 r.w.para(term, p, rest)
86 r.nodes(item.Details, rest+" ", rest+" ")
87 default:
88 r.w.code(org.String(item), "", rest)
89 }
90 p = rest
91 }
92 case org.Block:
93 switch strings.ToUpper(n.Name) {
94 case "SRC":
95 lang := ""
96 if len(n.Parameters) > 0 {
97 lang = n.Parameters[0]
98 }
99 r.w.code(org.String(n.Children...), lang, rest)
100 case "QUOTE":
101 bar := r.w.paint(sgrDim, "│ ")
102 r.nodes(n.Children, first+bar, rest+bar)
103 default:
104 r.w.code(org.String(n.Children...), "", rest)
105 }
106 case org.Example:
107 r.w.code(org.String(n.Children...), "", rest)
108 case org.HorizontalRule:
109 r.w.rule(first)
110 default:
111 r.w.code(org.String(n), "", rest)
112 }
113}
114
115func (r orgRenderer) inline(ns []org.Node) string {
116 var b strings.Builder
117 for _, n := range ns {
118 switch n := n.(type) {
119 case org.Text:
120 b.WriteString(n.Content)
121 case org.LineBreak:
122 b.WriteString(" ")
123 case org.ExplicitLineBreak:
124 b.WriteString("\n")
125 case org.Emphasis:
126 s := r.inline(n.Content)
127 switch n.Kind {
128 case "*":
129 s = r.w.paint(sgrBold, s)
130 case "/", "_":
131 s = r.w.paint(sgrUnderline, s)
132 }
133 b.WriteString(s)
134 case org.RegularLink:
135 b.WriteString(r.w.link(r.inline(n.Description), n.URL))
136 default:
137 b.WriteString(org.String(n))
138 }
139 }
140 return b.String()
141}
internal/termtext/org_test.go added +32
@@ -0,0 +1,32 @@
1package termtext
2
3import (
4 "os"
5 "strings"
6 "testing"
7)
8
9func TestOrgGolden(t *testing.T) {
10 src, err := os.ReadFile("testdata/doc.org")
11 if err != nil {
12 t.Fatal(err)
13 }
14 golden(t, "doc.org.plain.golden", Org(string(src), Options{Base: "https://forge.test"}))
15 golden(t, "doc.org.60.golden", Org(string(src), Options{Width: 60, Base: "https://forge.test"}))
16 golden(t, "doc.org.60color.golden", Org(string(src), Options{Width: 60, Color: true, Base: "https://forge.test"}))
17}
18
19// #+INCLUDE reads nothing from the server's disk.
20func TestOrgIncludeIsInert(t *testing.T) {
21 got := Org("#+INCLUDE: \"/etc/passwd\"\n\ntext\n", Options{})
22 if strings.Contains(got, "root:") {
23 t.Fatalf("include read a file: %q", got)
24 }
25}
26
27func TestOrgInlineDropsLinkTargets(t *testing.T) {
28 got := Inline("see [[https://x.test/a][the page]] now", "org")
29 if got != "see the page now" {
30 t.Errorf("Inline = %q", got)
31 }
32}
internal/termtext/testdata/doc.org added +27
@@ -0,0 +1,27 @@
1#+TITLE: ignored keyword
2
3* A heading
4A paragraph long enough to wrap at sixty columns, with *bold*, /italic/, _underline_, =verbatim=, ~code~, a [[https://example.com/page][link]], and a bare [[https://example.com]].
5
6- one
7- two, which is long enough that its continuation line has to hang under the text rather than the bullet
8 - nested
9
101. first
112. second
12
13- term :: its description
14
15#+BEGIN_SRC go
16func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") }
17#+END_SRC
18
19#+BEGIN_QUOTE
20quoted text
21#+END_QUOTE
22
23-----
24
25| a | b |
26|---+---|
27| 1 | 2 |
internal/termtext/testdata/doc.org.60.golden added +27
@@ -0,0 +1,27 @@
1A heading
2
3A paragraph long enough to wrap at sixty columns, with bold,
4italic, underline, verbatim, code, a link
5(https://example.com/page), and a bare https://example.com.
6
7• one
8• two, which is long enough that its continuation line has
9 to hang under the text rather than the bullet
10
11 • nested
12
131. first
142. second
15
16term
17 its description
18
19 func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") }
20
21│ quoted text
22
23───
24
25 | a | b |
26 |---+---|
27 | 1 | 2 |
internal/termtext/testdata/doc.org.60color.golden added +27
@@ -0,0 +1,27 @@
1A heading
2
3A paragraph long enough to wrap at sixty columns, with bold,
4italic, underline, verbatim, code, a link
5(https://example.com/page), and a bare https://example.com.
6
7• one
8• two, which is long enough that its continuation line has
9 to hang under the text rather than the bullet
10
11 • nested
12
131. first
142. second
15
16term
17 its description
18
19 func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") }
20
21│ quoted text
22
23───
24
25 | a | b |
26 |---+---|
27 | 1 | 2 |
internal/termtext/testdata/doc.org.plain.golden added +24
@@ -0,0 +1,24 @@
1A heading
2
3A paragraph long enough to wrap at sixty columns, with bold, italic, underline, verbatim, code, a link (https://example.com/page), and a bare https://example.com.
4
5• one
6• two, which is long enough that its continuation line has to hang under the text rather than the bullet
7
8 • nested
9
101. first
112. second
12
13term
14 its description
15
16 func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") }
17
18│ quoted text
19
20───
21
22 | a | b |
23 |---+---|
24 | 1 | 2 |