Commit 468df8e4c3
Verified · cmc
internal/termtext/org.go +135 −3
| @@ -1,9 +1,141 @@ | ||
| 1 | 1 | package termtext |
| 2 | 2 | |
| 3 | // Org renders org-mode source. Task 3.2 replaces this stub. | |
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "errors" | |
| 6 | "io" | |
| 7 | "log" | |
| 8 | "strings" | |
| 9 | ||
| 10 | "github.com/niklasfasching/go-org/org" | |
| 11 | ) | |
| 12 | ||
| 4 | 13 | func Org(src string, o Options) string { |
| 5 | 14 | return renderOrg(src, &out{o: o}) |
| 6 | 15 | } |
| 7 | 16 | |
| 8 | // renderOrg is a stub until Task 3.2. | |
| 9 | func 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. | |
| 19 | func 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 | ||
| 29 | type orgRenderer struct{ w *out } | |
| 30 | ||
| 31 | func (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. | |
| 49 | func 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 | ||
| 59 | func (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 | ||
| 115 | func (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 @@ | ||
| 1 | package termtext | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ) | |
| 8 | ||
| 9 | func 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. | |
| 20 | func 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 | ||
| 27 | func 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 | |
| 4 | A 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 | ||
| 10 | 1. first | |
| 11 | 2. second | |
| 12 | ||
| 13 | - term :: its description | |
| 14 | ||
| 15 | #+BEGIN_SRC go | |
| 16 | func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") } | |
| 17 | #+END_SRC | |
| 18 | ||
| 19 | #+BEGIN_QUOTE | |
| 20 | quoted 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 @@ | ||
| 1 | A heading | |
| 2 | ||
| 3 | A paragraph long enough to wrap at sixty columns, with bold, | |
| 4 | italic, 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 | ||
| 13 | 1. first | |
| 14 | 2. second | |
| 15 | ||
| 16 | term | |
| 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 @@ | ||
| 1 | [1mA heading[0m | |
| 2 | ||
| 3 | A paragraph long enough to wrap at sixty columns, with [1mbold[0m, | |
| 4 | [4mitalic[0m, [4munderline[0m, 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 | ||
| 13 | 1. first | |
| 14 | 2. second | |
| 15 | ||
| 16 | [1mterm[0m | |
| 17 | its description | |
| 18 | ||
| 19 | [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 | |
| 20 | ||
| 21 | [2m│ [0mquoted text | |
| 22 | ||
| 23 | [2m───[0m | |
| 24 | ||
| 25 | [97m| a | b |[0m | |
| 26 | [97m|---+---|[0m | |
| 27 | [97m| 1 | 2 |[0m | |
internal/termtext/testdata/doc.org.plain.golden added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | A heading | |
| 2 | ||
| 3 | A 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 | ||
| 10 | 1. first | |
| 11 | 2. second | |
| 12 | ||
| 13 | term | |
| 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 | | |