- commit
- 34b6786edfc087e660f274f08a3c0da6ce482b87
- parent
- 44d8d02d77557585e45af4381b9580d356c795ab
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 105 | +++++++++++++++++++++++++++++- |
| M | internal/ | 21 | ++++++ |
@@ -8,7 +8,7 @@ A page is identified by its name, the `d` tag of a kind 30818 article. Names are
88 99 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.1010 11-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.11+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.1212 1313 ## Versions and forks1414 @@ -233,7 +233,7 @@ diff-view td>nostr-event{white-space:normal;max-width:720px;border:0;padding:6px
233233 /* Wiki: the page list, one article, the merge request bar and the editor. */234234 #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)}235235 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)}236-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}236+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)}237237 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}238238 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}239239 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)}@@ -84,8 +84,8 @@ func Links(content string) []string {
8484 }8585 8686 // RenderHTML renders a subset of Djot: ATX headings, paragraphs, fenced87-// code, bullet and numbered lists, emphasis, strong, code spans, links,88-// [[wikilinks]] and reference-style links. Everything else is shown as87+// code, bullet and numbered lists, pipe tables, emphasis, strong, code spans,88+// links, [[wikilinks]] and reference-style links. Everything else is shown as8989 // escaped text. Link targets are limited to http, https, mailto, nostr and9090 // site-relative paths.9191 func RenderHTML(content string) template.HTML {@@ -110,6 +110,34 @@ func RenderHTML(content string) template.HTML {
110110 b.WriteString("<li>" + renderInline(item, refs) + "</li>\n")111111 }112112 b.WriteString("</" + block.kind + ">\n")113+ case "table":114+ b.WriteString("<table>\n")115+ for i, row := range block.rows {116+ cell := "td"117+ if i < block.level {118+ cell = "th"119+ }120+ if i == 0 && block.level > 0 {121+ b.WriteString("<thead>\n")122+ }123+ if i == block.level {124+ if block.level > 0 {125+ b.WriteString("</thead>\n")126+ }127+ b.WriteString("<tbody>\n")128+ }129+ b.WriteString("<tr>")130+ for _, text := range row {131+ b.WriteString("<" + cell + ">" + renderInline(text, refs) + "</" + cell + ">")132+ }133+ b.WriteString("</tr>\n")134+ }135+ if block.level >= len(block.rows) {136+ b.WriteString("</thead>\n")137+ } else {138+ b.WriteString("</tbody>\n")139+ }140+ b.WriteString("</table>\n")113141 default:114142 b.WriteString("<p>" + renderInline(block.text, refs) + "</p>\n")115143 }@@ -123,6 +151,7 @@ type block struct {
123151 lang string124152 text string125153 items []string154+ rows [][]string // table rows; level counts the header rows126155 }127156 128157 // definitions collects reference link definitions, "[label]: target", keyed@@ -175,6 +204,31 @@ func blocks(content string) []block {
175204 out = append(out, block{kind: "def", lang: label, text: target})176205 continue177206 }207+ if tableRow(trimmed) {208+ // A table interrupts a paragraph, the way Djot block starts do.209+ flush()210+ var rows [][]string211+ header := 0212+ for ; i < len(lines); i++ {213+ current := strings.TrimSpace(lines[i])214+ if !tableRow(current) {215+ break216+ }217+ if separatorRow(current) {218+ // A separator marks every row above it as a header.219+ if header == 0 {220+ header = len(rows)221+ }222+ continue223+ }224+ rows = append(rows, tableCells(current))225+ }226+ i--227+ if len(rows) > 0 {228+ out = append(out, block{kind: "table", level: header, rows: rows})229+ }230+ continue231+ }178232 if kind, _ := listMarker(trimmed); kind != "" && len(para) == 0 {179233 var items []string180234 for ; i < len(lines); i++ {@@ -195,6 +249,53 @@ func blocks(content string) []block {
195249 return out196250 }197251 252+// tableRow reports a Djot pipe table row: a line that starts and ends with253+// a pipe.254+func tableRow(line string) bool {255+ return len(line) >= 2 && strings.HasPrefix(line, "|") && strings.HasSuffix(line, "|")256+}257+258+// separatorRow reports the header separator, cells of dashes with optional259+// alignment colons, such as |---|:--:|.260+func separatorRow(line string) bool {261+ cells := tableCells(line)262+ if len(cells) == 0 {263+ return false264+ }265+ for _, cell := range cells {266+ if strings.Trim(cell, "-") == "" && strings.Contains(cell, "-") {267+ continue268+ }269+ if strings.Trim(strings.TrimSuffix(strings.TrimPrefix(cell, ":"), ":"), "-") == "" && strings.Contains(cell, "-") {270+ continue271+ }272+ return false273+ }274+ return true275+}276+277+// tableCells splits a row into trimmed cells; a backslash escapes a pipe278+// inside a cell.279+func tableCells(line string) []string {280+ inner := strings.TrimSuffix(strings.TrimPrefix(line, "|"), "|")281+ var cells []string282+ var current strings.Builder283+ for i := 0; i < len(inner); i++ {284+ switch {285+ case inner[i] == '\\' && i+1 < len(inner) && inner[i+1] == '|':286+ current.WriteByte('|')287+ i++288+ case inner[i] == '|':289+ cells = append(cells, strings.TrimSpace(current.String()))290+ current.Reset()291+ default:292+ current.WriteByte(inner[i])293+ }294+ }295+ cells = append(cells, strings.TrimSpace(current.String()))296+ return cells297+}298+198299 func headingLevel(line string) int {199300 level := len(line) - len(strings.TrimLeft(line, "#"))200301 if level == 0 || level > 6 || level >= len(line) || line[level] != ' ' {@@ -141,3 +141,24 @@ func TestRenderHTMLHandlesEdgeCases(t *testing.T) {
141141 }142142 }143143 }144+145+func TestRenderHTMLPipeTables(t *testing.T) {146+ got := string(RenderHTML("Before.\n| Proven | Activity | Evidence |\n| --- | :---: | ---: |\n| yes | Discovery, *auth* | NIP-98 succeeded. |\n| no | Wiki fork \\| merge | remains |\nAfter."))147+ for _, want := range []string{148+ "<p>Before.</p>\n<table>\n<thead>\n<tr><th>Proven</th><th>Activity</th><th>Evidence</th></tr>\n</thead>\n<tbody>\n",149+ "<tr><td>yes</td><td>Discovery, <strong>auth</strong></td><td>NIP-98 succeeded.</td></tr>\n",150+ "<tr><td>no</td><td>Wiki fork | merge</td><td>remains</td></tr>\n</tbody>\n</table>\n<p>After.</p>\n",151+ } {152+ if !strings.Contains(got, want) {153+ t.Errorf("missing %q in %s", want, got)154+ }155+ }156+ // A table without a separator is all body rows; a pipe inside a paragraph is text.157+ got = string(RenderHTML("| a | b |\n| c | d |\n\ntext | with a pipe"))158+ 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>") {159+ t.Fatalf("headerless table: %s", got)160+ }161+ if strings.Contains(string(RenderHTML("| <script>x</script> |\n| --- |")), "<script>") {162+ t.Fatal("table cells were not escaped")163+ }164+}