Commit 211e3d4c4f

211e3d4c4f0478207cef4e72451cb6d78b16bf90

parent: 468df8e4c3

Verified · cmc

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

termtext: org lists stay tight; includes and keyword gaps render nothing

Ref #254
internal/termtext/org.go +74 −5
@@ -43,12 +43,14 @@ func (r orgRenderer) nodes(ns []org.Node, first, rest string) {
4343 }
4444}
4545
46// skipOrg drops nodes that carry nothing to render: keywords, property
46// skipOrg drops nodes that carry nothing to render: keywords
47// (#+INCLUDE included — ReadFile already refuses the read, but the
48// keyword itself is still a line of source, not content), property
4749// drawers, comments, and the empty paragraph go-org emits for a blank
4850// line that separated two blocks or ended a list item.
4951func skipOrg(n org.Node) bool {
5052 switch n := n.(type) {
51 case org.Keyword, *org.PropertyDrawer, org.Comment:
53 case org.Keyword, org.Include, *org.PropertyDrawer, org.Comment:
5254 return true
5355 case org.Paragraph:
5456 return len(n.Children) == 0
@@ -67,9 +69,10 @@ func (r orgRenderer) block(n org.Node, first, rest string) {
6769 case org.Paragraph:
6870 r.w.para(r.inline(n.Children), first, rest)
6971 case org.List:
72 loose := looseOrgList(n)
7073 p := first
7174 for i, item := range n.Items {
72 if i > 0 && n.Kind == "descriptive" {
75 if i > 0 && loose {
7376 r.w.blank()
7477 }
7578 switch item := item.(type) {
@@ -79,11 +82,11 @@ func (r orgRenderer) block(n org.Node, first, rest string) {
7982 marker = item.Bullet + " "
8083 }
8184 hang := rest + strings.Repeat(" ", cells(marker))
82 r.nodes(item.Children, p+marker, hang)
85 r.itemChildren(item.Children, p+marker, hang, loose)
8386 case org.DescriptiveListItem:
8487 term := r.w.paint(sgrBold, r.inline(item.Term))
8588 r.w.para(term, p, rest)
86 r.nodes(item.Details, rest+" ", rest+" ")
89 r.itemChildren(item.Details, rest+" ", rest+" ", loose)
8790 default:
8891 r.w.code(org.String(item), "", rest)
8992 }
@@ -112,7 +115,73 @@ func (r orgRenderer) block(n org.Node, first, rest string) {
112115 }
113116}
114117
118// looseOrgList reports whether n's source had a blank line between
119// any two of its items. go-org marks the item before such a blank
120// with a trailing empty org.Paragraph — the same artifact skipOrg
121// drops elsewhere, checked here first since skipOrg would erase it.
122// The list's own last item gets that trailing empty paragraph too
123// whenever a blank line follows the whole list (ending it before the
124// next block), which says nothing about spacing inside the list, so
125// only non-last items are checked. A single-item list is always
126// tight by this measure: there is no gap between items to have or
127// lack a blank line.
128func looseOrgList(n org.List) bool {
129 for i := 0; i < len(n.Items)-1; i++ {
130 var children []org.Node
131 switch item := n.Items[i].(type) {
132 case org.ListItem:
133 children = item.Children
134 case org.DescriptiveListItem:
135 children = item.Details
136 default:
137 continue
138 }
139 if len(children) == 0 {
140 continue
141 }
142 if p, ok := children[len(children)-1].(org.Paragraph); ok && len(p.Children) == 0 {
143 return true
144 }
145 }
146 return false
147}
148
149// itemChildren renders a list item's children. A loose list keeps
150// nodes' blank line between them; a tight list runs them straight
151// together, so a nested list sits directly under its parent item's
152// line rather than a line below it.
153func (r orgRenderer) itemChildren(ns []org.Node, first, rest string, loose bool) {
154 if loose {
155 r.nodes(ns, first, rest)
156 return
157 }
158 p := first
159 for _, n := range ns {
160 if skipOrg(n) {
161 continue
162 }
163 r.block(n, p, rest)
164 p = rest
165 }
166}
167
168// trimOrgLineBreaks drops leading and trailing org.LineBreak nodes: a
169// paragraph that follows a keyword across a blank line, or a blank
170// line gap already handled elsewhere, otherwise renders that break as
171// the leading or trailing space inline() gives it.
172func trimOrgLineBreaks(ns []org.Node) []org.Node {
173 isBreak := func(n org.Node) bool { _, ok := n.(org.LineBreak); return ok }
174 for len(ns) > 0 && isBreak(ns[0]) {
175 ns = ns[1:]
176 }
177 for len(ns) > 0 && isBreak(ns[len(ns)-1]) {
178 ns = ns[:len(ns)-1]
179 }
180 return ns
181}
182
115183func (r orgRenderer) inline(ns []org.Node) string {
184 ns = trimOrgLineBreaks(ns)
116185 var b strings.Builder
117186 for _, n := range ns {
118187 switch n := n.(type) {
internal/termtext/org_test.go +14 −1
@@ -16,12 +16,25 @@ func TestOrgGolden(t *testing.T) {
1616 golden(t, "doc.org.60color.golden", Org(string(src), Options{Width: 60, Color: true, Base: "https://forge.test"}))
1717}
1818
19// #+INCLUDE reads nothing from the server's disk.
19// #+INCLUDE reads nothing from the server's disk, and the keyword
20// itself renders nothing either.
2021func TestOrgIncludeIsInert(t *testing.T) {
2122 got := Org("#+INCLUDE: \"/etc/passwd\"\n\ntext\n", Options{})
2223 if strings.Contains(got, "root:") {
2324 t.Fatalf("include read a file: %q", got)
2425 }
26 if strings.Contains(got, "#+INCLUDE") {
27 t.Fatalf("include rendered as text: %q", got)
28 }
29}
30
31// A keyword (dropped) followed by a blank line then a paragraph must
32// not leave the paragraph's leading LineBreak as a stray space.
33func TestOrgKeywordGapNoLeadingSpace(t *testing.T) {
34 got := Org("#+SETUPFILE: \"x\"\n\ntext\n", Options{})
35 if got != "text\n" {
36 t.Errorf("Org = %q, want %q", got, "text\n")
37 }
2538}
2639
2740func TestOrgInlineDropsLinkTargets(t *testing.T) {
internal/termtext/testdata/doc.org.60.golden −1
@@ -7,7 +7,6 @@ italic, underline, verbatim, code, a link
77• one
88• two, which is long enough that its continuation line has
99 to hang under the text rather than the bullet
10
1110 • nested
1211
13121. first
internal/termtext/testdata/doc.org.60color.golden −1
@@ -7,7 +7,6 @@ A paragraph long enough to wrap at sixty columns, with bold,
77• one
88• two, which is long enough that its continuation line has
99 to hang under the text rather than the bullet
10
1110 • nested
1211
13121. first
internal/termtext/testdata/doc.org.plain.golden −1
@@ -4,7 +4,6 @@ A paragraph long enough to wrap at sixty columns, with bold, italic, underline,
44
55• one
66• two, which is long enough that its continuation line has to hang under the text rather than the bullet
7
87 • nested
98
1091. first