text.gno
4.30 Kb · 171 lines
1package gallery
2
3import "strings"
4
5// doc builds the plain-text target: a document that reads well as-is, rather
6// than markdown with the syntax characters deleted.
7//
8// This is a prototype, deliberately small and deliberately here rather than in
9// a p/ package. Porting one realm is not enough to know what the shared
10// document model should be; porting the fifth will be. What it already pins is
11// the set of rules that make text good instead of merely stripped:
12//
13// 1. A link prints its text inline and collects the URL into a numbered
14// footnote. Never "[text](url)", which is the single worst thing markdown
15// does to a terminal reader.
16// 2. A heading is underlined, not prefixed with '#'.
17// 3. Art is emitted verbatim: never wrapped, never escaped, never fenced.
18// 4. Prose is wrapped at a fixed width; art and fields are not.
19type doc struct {
20 b strings.Builder
21 links []string
22}
23
24// width is where prose wraps. 72 leaves room for a quoting prefix in an email
25// or a diff without re-wrapping.
26const width = 72
27
28func (d *doc) H1(s string) {
29 d.blank()
30 d.b.WriteString(s + "\n" + strings.Repeat("=", runeLen(s)) + "\n")
31}
32
33func (d *doc) H2(s string) {
34 d.blank()
35 d.b.WriteString(s + "\n" + strings.Repeat("-", runeLen(s)) + "\n")
36}
37
38func (d *doc) Para(s string) {
39 if s == "" {
40 return
41 }
42 d.blank()
43 d.b.WriteString(wrap(s, width) + "\n")
44}
45
46func (d *doc) Item(s string) {
47 d.b.WriteString(" - " + s + "\n")
48}
49
50// Table writes columns padded to their widest cell, under a rule. An unpadded
51// pipe table is the other thing markdown does to a terminal reader that no
52// amount of character-stripping fixes: the columns stop lining up and the table
53// stops being a table.
54//
55// Cells are laid out by rune count, not byte count, so a box-drawing glyph or
56// an accent does not shift the column.
57func (d *doc) Table(headers []string, rows [][]string) {
58 if len(headers) == 0 {
59 return
60 }
61 w := make([]int, len(headers))
62 for i, h := range headers {
63 w[i] = runeLen(h)
64 }
65 for _, r := range rows {
66 for i, c := range r {
67 if i < len(w) && runeLen(c) > w[i] {
68 w[i] = runeLen(c)
69 }
70 }
71 }
72
73 d.blank()
74 d.b.WriteString(" " + joinPadded(headers, w) + "\n")
75
76 rule := make([]string, len(headers))
77 for i := range rule {
78 rule[i] = strings.Repeat("-", w[i])
79 }
80 d.b.WriteString(" " + joinPadded(rule, w) + "\n")
81
82 for _, r := range rows {
83 d.b.WriteString(" " + joinPadded(r, w) + "\n")
84 }
85}
86
87// joinPadded pads every cell but the last, which would only add trailing space.
88func joinPadded(cells []string, w []int) string {
89 var b strings.Builder
90 for i, c := range cells {
91 if i > 0 {
92 b.WriteString(" ")
93 }
94 b.WriteString(c)
95 if i < len(cells)-1 && i < len(w) {
96 b.WriteString(strings.Repeat(" ", w[i]-runeLen(c)))
97 }
98 }
99 return b.String()
100}
101
102func (d *doc) Field(k, v string) {
103 d.b.WriteString(" " + k + ": " + v + "\n")
104}
105
106// Art writes a block verbatim. No fence, no indent, no escaping: a consumer
107// that asked for text asked for the picture as it is.
108func (d *doc) Art(s string) {
109 d.blank()
110 d.b.WriteString(s + "\n")
111}
112
113// Link prints the text inline and defers the URL to the footnote block.
114func (d *doc) Link(text, url string) {
115 d.links = append(d.links, url)
116 d.b.WriteString(" " + text + " [" + itoa(len(d.links)) + "]\n")
117}
118
119func (d *doc) String() string {
120 out := d.b.String()
121 if len(d.links) == 0 {
122 return out
123 }
124 var b strings.Builder
125 b.WriteString(out)
126 b.WriteString("\nLinks:\n")
127 for i, u := range d.links {
128 b.WriteString(" [" + itoa(i+1) + "] " + u + "\n")
129 }
130 return b.String()
131}
132
133// blank writes a separating newline unless the document is empty or already
134// ends in one, so no block has to know what came before it.
135func (d *doc) blank() {
136 s := d.b.String()
137 if s == "" || strings.HasSuffix(s, "\n\n") {
138 return
139 }
140 d.b.WriteString("\n")
141}
142
143// wrap breaks on spaces at w runes. A word longer than w is left alone rather
144// than cut: a URL or a realm path is worse broken than wide.
145func wrap(s string, w int) string {
146 var out strings.Builder
147 line := 0
148 for i, word := range strings.Fields(s) {
149 n := runeLen(word)
150 switch {
151 case i == 0:
152 out.WriteString(word)
153 line = n
154 case line+1+n > w:
155 out.WriteString("\n" + word)
156 line = n
157 default:
158 out.WriteString(" " + word)
159 line += 1 + n
160 }
161 }
162 return out.String()
163}
164
165func runeLen(s string) int {
166 n := 0
167 for range s {
168 n++
169 }
170 return n
171}