| 1 | diff --git a/docs/wiki.md b/docs/wiki.md |
| 2 | index e39b5b8..8a14fe4 100644 |
| 3 | --- a/docs/wiki.md |
| 4 | +++ b/docs/wiki.md |
| 5 | @@ -8,7 +8,7 @@ A page is identified by its name, the `d` tag of a kind 30818 article. Names are |
| 6 | |
| 7 | An article carries a `title` for display, an optional `summary` for lists and its content in [Djot](https://djot.net/) markup. Two link forms are specific to the wiki: `[[Page Name]]` or `[[Page Name|shown text]]` links to another page, and a reference-style link with no definition, such as `[proof of work][]`, does the same. Links to profiles and events use `nostr:` addresses, such as `[Bob](nostr:npub1...)`. The page list searches titles and summaries. |
| 8 | |
| 9 | -The relay renders headings, paragraphs, emphasis, strong text, code spans and blocks, bullet and numbered lists, links, wikilinks and `nostr:` links. Other markup and any HTML in an article is shown as plain text. Links are limited to `http`, `https`, `mailto`, `nostr` and relay paths. |
| 10 | +The relay renders headings, paragraphs, emphasis, strong text, code spans and blocks, bullet and numbered lists, pipe tables, links, wikilinks and `nostr:` links. Other markup and any HTML in an article is shown as plain text. Links are limited to `http`, `https`, `mailto`, `nostr` and relay paths. |
| 11 | |
| 12 | ## Versions and forks |
| 13 | |
| 14 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 15 | index 7e0a15a..f4ebea3 100644 |
| 16 | --- a/internal/webui/style.css |
| 17 | +++ b/internal/webui/style.css |
| 18 | @@ -233,7 +233,7 @@ diff-view td>nostr-event{white-space:normal;max-width:720px;border:0;padding:6px |
| 19 | /* Wiki: the page list, one article, the merge request bar and the editor. */ |
| 20 | #wiki-list #rows th{white-space:nowrap}#wiki-list #rows td:first-child{width:auto;font:14px var(--sans)}#wiki-list #rows td:nth-child(3),#wiki-list #rows td:nth-child(4){width:110px;font:12px var(--mono);color:var(--muted)} |
| 21 | wiki-article{display:block;max-width:720px;overflow-wrap:anywhere}wiki-article>header{font:12px var(--mono);color:var(--muted);margin-bottom:8px}wiki-article>header nostr-key,wiki-article>header nostr-name{color:var(--ink)} |
| 22 | -wiki-article h1{font-size:20px;margin:18px 0 8px}wiki-article h2{font-size:17px}wiki-article h3{font-size:15px}wiki-article p{margin:0 0 10px} |
| 23 | +wiki-article h1{font-size:20px;margin:18px 0 8px}wiki-article h2{font-size:17px}wiki-article h3{font-size:15px}wiki-article p{margin:0 0 10px}wiki-article table{border-collapse:collapse;margin:0 0 12px;font:13px var(--sans);width:auto}wiki-article th,wiki-article td{border:1px solid var(--line);padding:4px 8px;text-align:left;vertical-align:top;height:auto;white-space:normal}wiki-article th{font-weight:600;background:var(--rail)} |
| 24 | wiki-article pre{background:var(--rail);border:1px solid var(--line);padding:10px 12px;overflow:auto;font-size:12.5px;line-height:1.5;white-space:pre}wiki-article ul,wiki-article ol{padding-left:20px;margin:0 0 10px} |
| 25 | merge-request,wiki-proposal,collaboration-proposal{display:block;border:1px solid var(--line);background:var(--soft);padding:8px 10px;margin:0 0 14px;max-width:720px;font:13px var(--sans)}merge-request nostr-key,merge-request nostr-name,wiki-proposal nostr-key,wiki-proposal nostr-name,collaboration-proposal nostr-key,collaboration-proposal nostr-name{color:var(--ink);font-weight:500}merge-request>a,merge-request button,wiki-proposal button,collaboration-proposal button{margin-left:6px;font-size:12px} |
| 26 | merge-request nostr-react,merge-request nostr-react form,wiki-proposal nostr-react,wiki-proposal nostr-react form,collaboration-proposal nostr-react,collaboration-proposal nostr-react form{display:inline}merge-request nostr-react output,wiki-proposal nostr-react output,collaboration-proposal nostr-react output{display:inline-block;margin-left:6px;font:12px var(--mono);color:var(--muted)}merge-request nostr-react output[data-error],wiki-proposal nostr-react output[data-error],collaboration-proposal nostr-react output[data-error]{color:var(--alert)} |
| 27 | diff --git a/internal/wiki/wiki.go b/internal/wiki/wiki.go |
| 28 | index b78bb05..887ad54 100644 |
| 29 | --- a/internal/wiki/wiki.go |
| 30 | +++ b/internal/wiki/wiki.go |
| 31 | @@ -84,8 +84,8 @@ func Links(content string) []string { |
| 32 | } |
| 33 | |
| 34 | // RenderHTML renders a subset of Djot: ATX headings, paragraphs, fenced |
| 35 | -// code, bullet and numbered lists, emphasis, strong, code spans, links, |
| 36 | -// [[wikilinks]] and reference-style links. Everything else is shown as |
| 37 | +// code, bullet and numbered lists, pipe tables, emphasis, strong, code spans, |
| 38 | +// links, [[wikilinks]] and reference-style links. Everything else is shown as |
| 39 | // escaped text. Link targets are limited to http, https, mailto, nostr and |
| 40 | // site-relative paths. |
| 41 | func RenderHTML(content string) template.HTML { |
| 42 | @@ -110,6 +110,34 @@ func RenderHTML(content string) template.HTML { |
| 43 | b.WriteString("<li>" + renderInline(item, refs) + "</li>\n") |
| 44 | } |
| 45 | b.WriteString("</" + block.kind + ">\n") |
| 46 | + case "table": |
| 47 | + b.WriteString("<table>\n") |
| 48 | + for i, row := range block.rows { |
| 49 | + cell := "td" |
| 50 | + if i < block.level { |
| 51 | + cell = "th" |
| 52 | + } |
| 53 | + if i == 0 && block.level > 0 { |
| 54 | + b.WriteString("<thead>\n") |
| 55 | + } |
| 56 | + if i == block.level { |
| 57 | + if block.level > 0 { |
| 58 | + b.WriteString("</thead>\n") |
| 59 | + } |
| 60 | + b.WriteString("<tbody>\n") |
| 61 | + } |
| 62 | + b.WriteString("<tr>") |
| 63 | + for _, text := range row { |
| 64 | + b.WriteString("<" + cell + ">" + renderInline(text, refs) + "</" + cell + ">") |
| 65 | + } |
| 66 | + b.WriteString("</tr>\n") |
| 67 | + } |
| 68 | + if block.level >= len(block.rows) { |
| 69 | + b.WriteString("</thead>\n") |
| 70 | + } else { |
| 71 | + b.WriteString("</tbody>\n") |
| 72 | + } |
| 73 | + b.WriteString("</table>\n") |
| 74 | default: |
| 75 | b.WriteString("<p>" + renderInline(block.text, refs) + "</p>\n") |
| 76 | } |
| 77 | @@ -123,6 +151,7 @@ type block struct { |
| 78 | lang string |
| 79 | text string |
| 80 | items []string |
| 81 | + rows [][]string // table rows; level counts the header rows |
| 82 | } |
| 83 | |
| 84 | // definitions collects reference link definitions, "[label]: target", keyed |
| 85 | @@ -175,6 +204,31 @@ func blocks(content string) []block { |
| 86 | out = append(out, block{kind: "def", lang: label, text: target}) |
| 87 | continue |
| 88 | } |
| 89 | + if tableRow(trimmed) { |
| 90 | + // A table interrupts a paragraph, the way Djot block starts do. |
| 91 | + flush() |
| 92 | + var rows [][]string |
| 93 | + header := 0 |
| 94 | + for ; i < len(lines); i++ { |
| 95 | + current := strings.TrimSpace(lines[i]) |
| 96 | + if !tableRow(current) { |
| 97 | + break |
| 98 | + } |
| 99 | + if separatorRow(current) { |
| 100 | + // A separator marks every row above it as a header. |
| 101 | + if header == 0 { |
| 102 | + header = len(rows) |
| 103 | + } |
| 104 | + continue |
| 105 | + } |
| 106 | + rows = append(rows, tableCells(current)) |
| 107 | + } |
| 108 | + i-- |
| 109 | + if len(rows) > 0 { |
| 110 | + out = append(out, block{kind: "table", level: header, rows: rows}) |
| 111 | + } |
| 112 | + continue |
| 113 | + } |
| 114 | if kind, _ := listMarker(trimmed); kind != "" && len(para) == 0 { |
| 115 | var items []string |
| 116 | for ; i < len(lines); i++ { |
| 117 | @@ -195,6 +249,53 @@ func blocks(content string) []block { |
| 118 | return out |
| 119 | } |
| 120 | |
| 121 | +// tableRow reports a Djot pipe table row: a line that starts and ends with |
| 122 | +// a pipe. |
| 123 | +func tableRow(line string) bool { |
| 124 | + return len(line) >= 2 && strings.HasPrefix(line, "|") && strings.HasSuffix(line, "|") |
| 125 | +} |
| 126 | + |
| 127 | +// separatorRow reports the header separator, cells of dashes with optional |
| 128 | +// alignment colons, such as |---|:--:|. |
| 129 | +func separatorRow(line string) bool { |
| 130 | + cells := tableCells(line) |
| 131 | + if len(cells) == 0 { |
| 132 | + return false |
| 133 | + } |
| 134 | + for _, cell := range cells { |
| 135 | + if strings.Trim(cell, "-") == "" && strings.Contains(cell, "-") { |
| 136 | + continue |
| 137 | + } |
| 138 | + if strings.Trim(strings.TrimSuffix(strings.TrimPrefix(cell, ":"), ":"), "-") == "" && strings.Contains(cell, "-") { |
| 139 | + continue |
| 140 | + } |
| 141 | + return false |
| 142 | + } |
| 143 | + return true |
| 144 | +} |
| 145 | + |
| 146 | +// tableCells splits a row into trimmed cells; a backslash escapes a pipe |
| 147 | +// inside a cell. |
| 148 | +func tableCells(line string) []string { |
| 149 | + inner := strings.TrimSuffix(strings.TrimPrefix(line, "|"), "|") |
| 150 | + var cells []string |
| 151 | + var current strings.Builder |
| 152 | + for i := 0; i < len(inner); i++ { |
| 153 | + switch { |
| 154 | + case inner[i] == '\\' && i+1 < len(inner) && inner[i+1] == '|': |
| 155 | + current.WriteByte('|') |
| 156 | + i++ |
| 157 | + case inner[i] == '|': |
| 158 | + cells = append(cells, strings.TrimSpace(current.String())) |
| 159 | + current.Reset() |
| 160 | + default: |
| 161 | + current.WriteByte(inner[i]) |
| 162 | + } |
| 163 | + } |
| 164 | + cells = append(cells, strings.TrimSpace(current.String())) |
| 165 | + return cells |
| 166 | +} |
| 167 | + |
| 168 | func headingLevel(line string) int { |
| 169 | level := len(line) - len(strings.TrimLeft(line, "#")) |
| 170 | if level == 0 || level > 6 || level >= len(line) || line[level] != ' ' { |
| 171 | diff --git a/internal/wiki/wiki_test.go b/internal/wiki/wiki_test.go |
| 172 | index e40121f..9af4455 100644 |
| 173 | --- a/internal/wiki/wiki_test.go |
| 174 | +++ b/internal/wiki/wiki_test.go |
| 175 | @@ -141,3 +141,24 @@ func TestRenderHTMLHandlesEdgeCases(t *testing.T) { |
| 176 | } |
| 177 | } |
| 178 | } |
| 179 | + |
| 180 | +func TestRenderHTMLPipeTables(t *testing.T) { |
| 181 | + got := string(RenderHTML("Before.\n| Proven | Activity | Evidence |\n| --- | :---: | ---: |\n| yes | Discovery, *auth* | NIP-98 succeeded. |\n| no | Wiki fork \\| merge | remains |\nAfter.")) |
| 182 | + for _, want := range []string{ |
| 183 | + "<p>Before.</p>\n<table>\n<thead>\n<tr><th>Proven</th><th>Activity</th><th>Evidence</th></tr>\n</thead>\n<tbody>\n", |
| 184 | + "<tr><td>yes</td><td>Discovery, <strong>auth</strong></td><td>NIP-98 succeeded.</td></tr>\n", |
| 185 | + "<tr><td>no</td><td>Wiki fork | merge</td><td>remains</td></tr>\n</tbody>\n</table>\n<p>After.</p>\n", |
| 186 | + } { |
| 187 | + if !strings.Contains(got, want) { |
| 188 | + t.Errorf("missing %q in %s", want, got) |
| 189 | + } |
| 190 | + } |
| 191 | + // A table without a separator is all body rows; a pipe inside a paragraph is text. |
| 192 | + got = string(RenderHTML("| a | b |\n| c | d |\n\ntext | with a pipe")) |
| 193 | + if !strings.Contains(got, "<table>\n<tbody>\n<tr><td>a</td><td>b</td></tr>\n<tr><td>c</td><td>d</td></tr>\n</tbody>\n</table>") || !strings.Contains(got, "<p>text | with a pipe</p>") { |
| 194 | + t.Fatalf("headerless table: %s", got) |
| 195 | + } |
| 196 | + if strings.Contains(string(RenderHTML("| <script>x</script> |\n| --- |")), "<script>") { |
| 197 | + t.Fatal("table cells were not escaped") |
| 198 | + } |
| 199 | +} |