- commit
- d2acf557357353282d0666d529579637a1b73ab0
- parent
- 5a45ce4de06270a60720d83aaf3f006a88ed7b36
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 3 | + |
| M | internal/ | 22 | +++- |
| M | internal/ | 7 | +- |
| M | internal/ | 47 | +++++++- |
| A | internal/ | 113 | +++++++++++++++++ |
| A | internal/ | 78 | ++++++++++++ |
| A | internal/ | 198 | ++++++++++++++++++++++++++++++ |
@@ -52,6 +52,8 @@
5252 {{else if eq .Tab "repos"}}{{template "repos" .}}5353 {{else if eq .Tab "repo"}}{{template "repo" .}}5454 {{else if eq .Tab "files"}}{{template "files" .}}55+{{else if eq .Tab "wiki"}}{{template "wiki" .}}56+{{else if eq .Tab "wikipage"}}{{template "wikipage" .}}5557 {{else if eq .Tab "file"}}{{template "file" .}}5658 {{else if eq .Tab "approvals"}}{{template "approvals" .}}5759 {{else if eq .Tab "event"}}{{template "event" .}}@@ -96,6 +98,7 @@
9698 {{else if eq .Tab "repos"}}{{template "panel-repos" .}}9799 {{else if or (eq .Tab "files") (eq .Tab "file")}}{{template "panel-files" .}}98100 {{else if eq .Tab "approvals"}}{{template "panel-approvals" .}}101+{{else if or (eq .Tab "wiki") (eq .Tab "wikipage")}}{{template "panel-wiki" .}}99102 {{else if eq .Tab "signin"}}{{template "panel-signin" .}}100103 {{else}}{{template "panel-relay" .}}101104 {{end}}{{end}}@@ -20,7 +20,7 @@ pre{white-space:pre-wrap;overflow-wrap:anywhere}
2020 img{max-width:100%}2121 2222 /* Selection: chrome is not selectable, content and values are. */23-#rail,#footer,#topbar,#panel h4,#panel label,#panel th,#rows th,#grid,#gridhead,#tree header small,#tree li>span,#preview header,button,summary,rpc-form label,signed-form label,publish-list label,nostr-compose label,file-tools label,file-upload label,nostr-event>header,approval-item>header,diff-view caption small{user-select:none;-webkit-user-select:none}23+#rail,#footer,#topbar,#panel h4,#panel label,#panel th,#rows th,#grid,#gridhead,#tree header small,#tree li>span,#preview header,button,summary,rpc-form label,signed-form label,publish-list label,nostr-compose label,wiki-compose label,file-tools label,file-upload label,nostr-event>header,approval-item>header,wiki-article>header,diff-view caption small{user-select:none;-webkit-user-select:none}2424 2525 /* Shell: a table with three columns; each column scrolls on its own. */2626 #topbar{display:none}@@ -82,7 +82,7 @@ share-link{display:inline-block}share-link:empty{display:none}
8282 #rows th:last-child{text-align:right}8383 #rows td a{color:inherit}#rows td:first-child a{color:var(--accent)}8484 #pages{font:12px var(--mono)}85-#collaboration-filter,#file-search{margin:0 0 14px;padding:0 0 8px;border-bottom:1px solid var(--line)}85+#collaboration-filter,#file-search,#wiki-search{margin:0 0 14px;padding:0 0 8px;border-bottom:1px solid var(--line)}8686 #collaboration-body{max-width:720px}8787 #file-actions{max-width:720px;margin:0 0 18px}8888 #events li>details{margin:0 0 10px;font:12px var(--mono)}#events li>details>summary{cursor:pointer;color:var(--muted)}@@ -116,8 +116,8 @@ dl{margin:.5rem 0}dt{font-weight:600}dd{margin:0 0 .35rem 1rem}
116116 rpc-form,signed-form,publish-list,nostr-compose,file-tools,file-upload,file-workspace-root:not([hidden]){display:block;border-bottom:1px solid var(--line);padding:10px 0}117117 nostr-compose,file-tools,file-upload,file-workspace-root{max-width:720px}118118 file-tools h3,file-upload h3{margin:0 0 4px}file-tools p,file-upload p{margin:6px 0}119-rpc-form label,signed-form label,publish-list label,nostr-compose label,file-tools label,file-upload label,#collaboration-filter label,#file-search label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}120-rpc-form label input,rpc-form label select,signed-form label input,nostr-compose label input,nostr-compose label select,file-tools label input,file-tools label select,file-upload label input,#collaboration-filter label input,#collaboration-filter label select,#file-search label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}119+rpc-form label,signed-form label,publish-list label,nostr-compose label,file-tools label,file-upload label,#collaboration-filter label,#file-search label,#wiki-search label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}120+rpc-form label input,rpc-form label select,signed-form label input,nostr-compose label input,nostr-compose label select,file-tools label input,file-tools label select,file-upload label input,#collaboration-filter label input,#collaboration-filter label select,#file-search label input,#wiki-search label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}121121 file-upload label[data-share]{display:block}file-upload input[data-share-url]{width:100%}122122 file-upload label:has([type=checkbox]),#panel file-upload label:has([type=checkbox]){display:inline-block;margin:8px 14px 8px 0}file-upload label:has([type=checkbox]) input,#panel file-upload label:has([type=checkbox]) input{display:inline-block;width:auto;min-width:0;margin:0 4px 0 0;vertical-align:middle}123123 #panel file-upload [hidden],file-upload [hidden],file-tools [hidden],#panel push-toggle [hidden]{display:none}@@ -215,6 +215,20 @@ source-view tfoot td,diff-view tfoot td{white-space:normal;color:var(--muted);fo
215215 diff-view ins,diff-view del{text-decoration:none}diff-view tr:has(ins){background:var(--add)}diff-view tr:has(del){background:var(--del)}diff-view b{color:var(--muted);font-weight:600}216216 diff-view caption{text-align:left;font:13px var(--mono);padding:8px 12px;border-bottom:1px solid var(--line);caption-side:top}217217 218+/* Wiki: the page list, one article, the merge request bar and the editor. */219+#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)}220+wiki-article{display:block;max-width:720px}wiki-article>header{font:12px var(--mono);color:var(--muted);margin-bottom:8px}wiki-article>header nostr-key{color:var(--ink)}221+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}222+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}223+merge-request{display:block;border:1px solid var(--line);background:var(--soft);padding:8px 10px;margin:0 0 14px;max-width:720px;font:13px var(--sans)}224+merge-request nostr-key{color:var(--ink);font-weight:500}merge-request>a,merge-request button{margin-left:6px;font-size:12px}225+merge-request nostr-react,merge-request nostr-react form{display:inline}merge-request nostr-react output{display:inline-block;margin-left:6px;font:12px var(--mono);color:var(--muted)}merge-request nostr-react output[data-error]{color:var(--alert)}226+#merge-status{font:12px var(--mono);color:var(--muted)}#wiki-compare h2{margin-top:18px}#wiki-redirects{font:12px var(--mono);color:var(--muted)}227+wiki-compose{display:block;max-width:720px;border-bottom:1px solid var(--line);padding:10px 0}228+wiki-compose label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}wiki-compose label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}229+wiki-compose label:has(textarea){display:block}wiki-compose textarea{min-height:14em}wiki-compose button{margin-right:6px}230+wiki-compose output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}wiki-compose output[data-error]{color:var(--alert)}231+218232 /* Activity grid. */219233 #grid{border-collapse:separate;border-spacing:2px;margin:0 0 8px;width:auto}220234 #grid td{width:10px;height:10px;padding:0;border:0;background:var(--g0)}@@ -16,7 +16,7 @@ import (
1616 // templateFS holds every page template. page.html owns the shell and the1717 // shared partials; the other files each define one group of tabs.1818 //19-//go:embed page.html public.html manage.html browse.html repo.html collaboration.html19+//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html2020 var templateFS embed.FS2121 2222 // styleCSS is inlined into every page so the UI needs no extra request and@@ -102,7 +102,7 @@ type navItem struct {
102102 103103 // relayNav: entry points, then conversation, then collaborative artifacts,104104 // then what the relay publishes and stores, then what is yours.105-var relayNav = []navItem{{"/home", "/", "home", 0}, {"/search", "/search", "search", 0}, {"/inbox", "/inbox", "inbox", 1}, {"/approvals", "/approvals", "approvals", 1}, {"/repos", "/repos", "repos", 2}, {"/files", "/files", "files", 3}, {"/articles", "/articles", "articles", 3}, {"/sites", "/sites", "sites", 3}, {"/outbox", "/outbox", "outbox", 4}, {"/manage", "/manage/people", "manage", 4}}105+var relayNav = []navItem{{"/home", "/", "home", 0}, {"/search", "/search", "search", 0}, {"/inbox", "/inbox", "inbox", 1}, {"/approvals", "/approvals", "approvals", 1}, {"/repos", "/repos", "repos", 2}, {"/wiki", "/wiki", "wiki", 2}, {"/files", "/files", "files", 3}, {"/articles", "/articles", "articles", 3}, {"/sites", "/sites", "sites", 3}, {"/outbox", "/outbox", "outbox", 4}, {"/manage", "/manage/people", "manage", 4}}106106 107107 // manageNav: who is here, what they may do, what the relay is, what it does108108 // over time, and how it is doing.@@ -253,6 +253,9 @@ func parseTemplates() (*template.Template, error) {
253253 "approvalViews": approvalViews,254254 "approvalCounts": approvalCounts,255255 "approvalDevices": approvalDevices,256+ "wikiHTML": wikiHTML,257+ "wikiView": wikiPageView,258+ "wikiURL": wikiURL,256259 }257260 tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")258261 if err != nil {@@ -85,6 +85,8 @@ type PageData struct {
8585 Path string8686 Readme template.HTML8787 Tree []any88+ // Merge is the browsewikimerge detail shown by a wiki page compare view.89+ Merge any8890 // Connections lists the ways to open this relay in client apps that the8991 // viewer may see, with link placeholders already resolved.9092 Connections []any@@ -158,7 +160,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
158160 http.Redirect(writer, request, requestPrefix(request)+"/manage/health", http.StatusMovedPermanently)159161 return160162 }161- if request.Method == http.MethodGet && (request.URL.Path == "/repos" || request.URL.Path == "/repo" || request.URL.Path == "/files" || request.URL.Path == "/file" || request.URL.Path == "/approvals" || request.URL.Path == "/manage/health") {163+ if request.Method == http.MethodGet && (request.URL.Path == "/repos" || request.URL.Path == "/repo" || request.URL.Path == "/files" || request.URL.Path == "/file" || request.URL.Path == "/approvals" || request.URL.Path == "/wiki" || strings.HasPrefix(request.URL.Path, "/wiki/") || request.URL.Path == "/manage/health") {162164 a.browse(writer, request)163165 return164166 }@@ -524,10 +526,16 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
524526 method = "browsefile"525527 case "/approvals":526528 method = "browseapprovals"529+ case "/wiki":530+ method = "browsewiki"527531 case "/manage/health":528532 method = "browsestatus"529533 }534+ if strings.HasPrefix(path, "/wiki/") {535+ method = "browsewikipage"536+ }530537 params := []json.RawMessage{}538+ pageQuery := request.URL.Query()531539 query := map[string]any{"cursor": request.URL.Query().Get("cursor"), "limit": 50, "q": request.URL.Query().Get("q")}532540 if value := request.URL.Query().Get("limit"); value != "" {533541 if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed > 0 {@@ -577,6 +585,13 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
577585 state = "all"578586 }579587 query = map[string]any{"cursor": query["cursor"], "limit": query["limit"], "state": state}588+ case "browsewiki":589+ query["author"] = request.URL.Query().Get("author")590+ case "browsewikipage":591+ // The name travels in the query as well so the page template can592+ // offer to create a page that does not exist yet.593+ pageQuery.Set("d", wikiPageName(path))594+ query = map[string]any{"d": pageQuery.Get("d"), "author": request.URL.Query().Get("author"), "version": request.URL.Query().Get("version")}580595 case "browsestatus":581596 query = map[string]any{}582597 }@@ -588,10 +603,9 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
588603 }589604 result, err := a.backend.Query(request.Context(), method, params, actor)590605 if err != nil {591- a.render(writer, request, PageData{Tab: browseTab(path), Error: err.Error(), Query: request.URL.Query()})606+ a.render(writer, request, PageData{Tab: browseTab(path), Error: err.Error(), Query: pageQuery})592607 return593608 }594- pageQuery := request.URL.Query()595609 if method == "browserepo" && pageQuery.Get("view") == "" {596610 pageQuery.Set("view", "tree")597611 }@@ -602,6 +616,14 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
602616 }603617 data := PageData{Tab: browseTab(path), Feed: browseRows(result), Event: result, Query: pageQuery}604618 data.Title = browseTitle(path, pageQuery, result, a.backend.Slug())619+ if id := pageQuery.Get("merge"); method == "browsewikipage" && len(id) == 64 {620+ raw, _ := json.Marshal(map[string]any{"id": id})621+ if merge, mergeErr := a.backend.Query(request.Context(), "browsewikimerge", []json.RawMessage{raw}, actor); mergeErr == nil {622+ data.Merge = merge623+ } else {624+ data.Notice = mergeErr.Error()625+ }626+ }605627 if method == "browserepo" && pageQuery.Get("view") == "home" {606628 data.Readme = a.readme(request.Context(), actor, pageQuery)607629 }@@ -610,7 +632,14 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
610632 }611633 612634 func browseTitle(path string, query url.Values, result any, slug string) string {613- label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File", "/approvals": "Approvals"}[path]635+ label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File", "/approvals": "Approvals", "/wiki": "Wiki"}[path]636+ if strings.HasPrefix(path, "/wiki/") {637+ label = plainString(valueMap(result)["title"])638+ if label == "" {639+ label = query.Get("d")640+ }641+ label += " | wiki"642+ }614643 if path == "/repo" {615644 label = query.Get("repo")616645 if label == "" {@@ -663,7 +692,12 @@ func (a *App) siblings(ctx context.Context, actor string, query url.Values) []an
663692 }664693 665694 func browseTab(path string) string {695+ if strings.HasPrefix(path, "/wiki/") {696+ return "wikipage"697+ }666698 switch path {699+ case "/wiki":700+ return "wiki"667701 case "/repos":668702 return "repos"669703 case "/repo":@@ -1068,9 +1102,14 @@ func tabForPath(path string) string {
10681102 if strings.HasPrefix(path, "manage/") {10691103 path = strings.TrimPrefix(path, "manage/")10701104 }1105+ if strings.HasPrefix(path, "wiki/") {1106+ return "wikipage"1107+ }10711108 switch path {10721109 case "manage":10731110 return "owner"1111+ case "wiki":1112+ return "wiki"10741113 case "repos":10751114 return "repos"10761115 case "repo":@@ -0,0 +1,113 @@
1+package webui2+3+// Wiki pages: the view model derived from the browsewikipage result so the4+// templates read plain fields, and the URL helpers for page names.5+6+import (7+ "html/template"8+ "net/url"9+ "strings"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/wiki"12+)13+14+// wikiView is one page as the templates see it. Version is the version15+// being shown, Number its place counted from the oldest, Others the rest.16+type wikiView struct {17+ D, Title string18+ Version map[string]any19+ Author string20+ Number, Count int21+ Forks, Links int22+ Versions []map[string]any23+ Others []map[string]any24+ Merges []map[string]any25+ OpenMerges []map[string]any26+ OpenCount int27+ RedirectsTo []map[string]any28+ RedirectsFrom []map[string]any29+}30+31+func wikiMaps(value any) []map[string]any {32+ items, _ := value.([]any)33+ maps := make([]map[string]any, 0, len(items))34+ for _, item := range items {35+ if m := valueMap(item); m != nil {36+ maps = append(maps, m)37+ }38+ }39+ return maps40+}41+42+// wikiPageView reads the browsewikipage result. Open merge requests aimed at43+// the shown version's author come first in OpenMerges; OpenCount counts every44+// open request on the page.45+func wikiPageView(result any) wikiView {46+ page := valueMap(result)47+ view := wikiView{D: plainString(page["d"]), Title: plainString(page["title"])}48+ view.Versions = wikiMaps(page["versions"])49+ view.Merges = wikiMaps(page["merges"])50+ view.RedirectsTo = wikiMaps(page["redirects_to"])51+ view.RedirectsFrom = wikiMaps(page["redirects_from"])52+ view.Count = len(view.Versions)53+ if current := valueMap(page["version"]); current != nil {54+ view.Version = current55+ view.Author = plainString(current["author"])56+ if links, ok := current["links"].([]any); ok {57+ view.Links = len(links)58+ }59+ }60+ if view.Title == "" {61+ view.Title = view.D62+ }63+ id, coordinate := plainString(view.Version["id"]), plainString(view.Version["coordinate"])64+ for i, v := range view.Versions {65+ if plainString(v["id"]) == id {66+ view.Number = view.Count - i67+ continue68+ }69+ view.Others = append(view.Others, v)70+ fork := valueMap(v["fork"])71+ if fork != nil && (plainString(fork["e"]) == id || (coordinate != "" && plainString(fork["a"]) == coordinate)) {72+ view.Forks++73+ }74+ }75+ for _, m := range view.Merges {76+ if plainString(m["status"]) != "open" {77+ continue78+ }79+ view.OpenCount++80+ if plainString(m["destination"]) == view.Author {81+ view.OpenMerges = append(view.OpenMerges, m)82+ }83+ }84+ return view85+}86+87+// wikiURL builds the page path for a name, with optional query pairs.88+func wikiURL(d string, pairs ...string) string {89+ path := "/wiki/" + url.PathEscape(d)90+ values := url.Values{}91+ for i := 0; i+1 < len(pairs); i += 2 {92+ if pairs[i+1] != "" {93+ values.Set(pairs[i], pairs[i+1])94+ }95+ }96+ if len(values) == 0 {97+ return path98+ }99+ return path + "?" + values.Encode()100+}101+102+func wikiHTML(content any) template.HTML {103+ return wiki.RenderHTML(plainString(content))104+}105+106+// wikiPageName is the name in a /wiki/<d> path, normalized.107+func wikiPageName(path string) string {108+ name, err := url.PathUnescape(strings.TrimPrefix(path, "/wiki/"))109+ if err != nil {110+ name = strings.TrimPrefix(path, "/wiki/")111+ }112+ return wiki.Normalize(name)113+}@@ -0,0 +1,78 @@
1+{{define "wiki"}}<section id="wiki-list">2+<h1>Wiki</h1>3+<form method="get" action="/wiki" id="wiki-search"><label>Search <input name="q" value="{{.Query.Get "q"}}" placeholder="title or summary" autocomplete="off"></label> <button>Search</button>{{if .Query.Get "q"}} <a href="/wiki">clear</a>{{end}}</form>4+{{if and .Actor (.Query.Get "new")}}<h2>New page</h2>{{template "wikiEditor" .}}5+{{end}}{{if .Error}}{{template "alert" .Error}}6+{{else if .Feed}}<table id="rows">7+<thead><tr><th>page</th><th>author</th><th>versions</th><th>merge requests</th><th>updated</th></tr></thead>8+<tbody>9+{{range .Feed}}{{$p := asMap .}}{{$v := asMap (index $p "version")}}<tr><td><a href="{{wikiURL (str (index $p "d"))}}">{{str (index $p "title")}}</a></td><td>{{template "nostrKey" (str (index $v "author"))}}</td><td>{{str (index $p "versions")}}</td><td>{{str (index $p "open_merges")}} open</td><td>{{when (index $v "created_at")}}</td></tr>10+{{end}}</tbody>11+</table>12+{{else}}<p>No pages yet.</p>13+{{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/wiki" $.Query "cursor" .}}">next page</a></p>14+{{end}}</section>15+{{end}}16+17+{{define "wikipage"}}{{$d := .Query.Get "d"}}{{if .Error}}<section id="wiki-page">18+<h1>{{$d}}</h1>19+{{if hasPrefix .Error "not found:"}}<p>There is no page named <code>{{$d}}</code> yet.</p>20+{{if .Actor}}{{template "wikiEditor" .}}{{else}}<p><a href="/signin">Sign in</a> to write it.</p>{{end}}21+{{else}}{{template "alert" .Error}}{{end}}</section>22+{{else}}{{$w := wikiView .Event}}<section id="wiki-page">23+<h1>{{$w.Title}}</h1>24+{{template "alert" .Notice}}{{if not $w.Version}}<p>This name has no article of its own.</p>25+{{template "wikiRedirects" $w}}{{if .Actor}}{{template "wikiEditor" .}}{{end}}26+{{else}}{{range $m := $w.OpenMerges}}<merge-request>{{template "nostrKey" (str (index $m "author"))}} proposes a new version of this page | <a href="{{wikiURL $w.D "merge" (str (index $m "id"))}}">compare</a>{{if eq $.Actor (str (index $m "destination"))}}<nostr-react event="{{str (index $m "id")}}" pubkey="{{str (index $m "author")}}" kind="818"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react>{{end}}<a href="/e/{{str (index $m "id")}}">Reply</a></merge-request>27+{{end}}{{if .Query.Get "merge"}}{{template "wikiCompare" .}}28+{{else if .Query.Get "edit"}}{{template "wikiEditor" .}}29+{{else if .Query.Get "history"}}{{template "wikiHistory" $w}}30+{{else}}<wiki-article><header>wiki | {{$w.D}} | by {{template "nostrKey" $w.Author}} | version {{$w.Number}} of {{$w.Count}} | {{$w.Forks}} fork{{if ne $w.Forks 1}}s{{end}}</header>31+{{with asMap (index $w.Version "fork")}}<p><small>forked from <a href="{{wikiURL $w.D "version" (str (index . "e"))}}">{{shortID (index . "e")}}</a></small></p>32+{{end}}{{with asMap (index $w.Version "defer")}}<p><small>the author defers to <a href="{{wikiURL $w.D "version" (str (index . "e"))}}">{{shortID (index . "e")}}</a></small></p>33+{{end}}{{wikiHTML (index $w.Version "content")}}</wiki-article>34+{{template "wikiRedirects" $w}}{{end}}{{end}}</section>35+{{end}}{{end}}36+37+{{/* wikiRedirects lists the articles a name leads to. */}}38+{{define "wikiRedirects"}}{{if .RedirectsFrom}}<p id="wiki-redirects">This name leads to {{range $i, $r := .RedirectsFrom}}{{if $i}}, {{end}}<a href="{{wikiURL (str (index $r "target_d"))}}">{{str (index $r "target_d")}}</a>{{end}}.</p>39+{{end}}{{end}}40+41+{{/* wikiEditor publishes a version of the page in view, or a new page. */}}42+{{define "wikiEditor"}}{{$w := wikiView .Event}}{{$d := or $w.D (.Query.Get "d")}}<wiki-compose name="{{$d}}" author="{{$w.Author}}" coordinate="{{str (index $w.Version "coordinate")}}" event="{{str (index $w.Version "id")}}">43+<label>Title <input name="title" value="{{$w.Title}}" required autocomplete="off"></label>44+<label>Name <input name="name" value="{{$d}}" required autocomplete="off" spellcheck="false"></label>45+<label>Summary <input name="summary" value="{{str (index $w.Version "summary")}}" autocomplete="off"></label>46+<label>Content <textarea name="content" rows="16" placeholder="Djot markup: # heading, *emphasis*, [[Page Name]]">{{str (index $w.Version "content")}}</textarea></label>47+<button name="action" value="publish">Publish</button>{{if and $w.Author (ne $w.Author .Actor)}} <button name="action" value="propose">Propose to {{shortID $w.Author}}</button>{{end}}48+</wiki-compose><noscript><p>JavaScript and a connected signer are required to publish.</p></noscript>{{end}}49+50+{{/* wikiCompare shows the proposed version of a merge request above the current one. */}}51+{{define "wikiCompare"}}{{$w := wikiView .Event}}{{$m := asMap .Merge}}{{if not $m}}<p>The merge request could not be loaded.</p>52+{{else}}{{$r := asMap (index $m "merge")}}<div id="wiki-compare">53+<p id="merge-status">merge request <a href="/e/{{str (index $r "id")}}">{{shortID (index $r "id")}}</a> | from {{template "nostrKey" (str (index $r "author"))}} to {{template "nostrKey" (str (index $r "destination"))}} | {{str (index $r "status")}}{{with index $r "content"}} | {{.}}{{end}}</p>54+{{with asMap (index $m "proposed")}}<h2>Proposed version</h2>55+<wiki-article><header>wiki | {{str (index . "d")}} | by {{template "nostrKey" (str (index . "author"))}} | {{when (index . "created_at")}}</header>56+{{wikiHTML (index . "content")}}</wiki-article>57+{{else}}<p>The proposed version is not available.</p>58+{{end}}{{with asMap (index $m "target")}}<h2>Current version</h2>59+<wiki-article><header>wiki | {{str (index . "d")}} | by {{template "nostrKey" (str (index . "author"))}} | {{when (index . "created_at")}}</header>60+{{wikiHTML (index . "content")}}</wiki-article>61+{{else}}<p>The destination author has no version of this page.</p>62+{{end}}<p><a href="{{wikiURL $w.D}}">back to the page</a></p></div>{{end}}{{end}}63+64+{{/* wikiHistory lists every version of a page, newest first. */}}65+{{define "wikiHistory"}}<table id="rows">66+<thead><tr><th>author</th><th>version</th><th>notes</th><th>published</th></tr></thead>67+<tbody>68+{{range .Versions}}<tr><td>{{template "nostrKey" (str (index . "author"))}}</td><td><a href="{{wikiURL $.D "version" (str (index . "id"))}}">{{shortID (index . "id")}}</a></td><td>{{with asMap (index . "fork")}}fork of {{shortID (index . "e")}}{{end}}{{with asMap (index . "defer")}} defers to {{shortID (index . "e")}}{{end}}{{with index . "likes"}} | {{str .}} likes{{end}}</td><td>{{when (index . "created_at")}}</td></tr>69+{{end}}</tbody>70+</table>71+<p><a href="{{wikiURL .D}}">back to the page</a></p>{{end}}72+73+{{define "panel-wiki"}}{{if eq .Tab "wiki"}}<section><h4>Wiki</h4><table><tr><th>protocol</th><td>NIP-54</td></tr><tr><th>markup</th><td>Djot</td></tr><tr><th>reads</th><td>{{.Policy.Reads}}</td></tr></table></section>74+<section><h4>Actions</h4><ul>{{if .Actor}}<li><a href="/wiki?new=1">new page</a></li>{{else}}<li><a href="/signin">sign in to write</a></li>{{end}}</ul></section>75+{{else}}{{$w := wikiView .Event}}<section><h4>Page</h4><table><tr><th>name</th><td><code>{{or $w.D (.Query.Get "d")}}</code></td></tr><tr><th>versions</th><td>{{$w.Count}}</td></tr><tr><th>merge requests</th><td>{{$w.OpenCount}} open</td></tr><tr><th>links</th><td>{{$w.Links}}</td></tr></table></section>76+{{if $w.Others}}<section><h4>Other versions</h4><ul>{{range $w.Others}}<li><a href="{{wikiURL $w.D "version" (str (index . "id"))}}">{{shortID (index . "author")}}</a>{{with asMap (index . "fork")}} | fork{{end}} <small>{{short (index . "created_at")}}</small></li>{{end}}</ul></section>77+{{end}}{{if $w.RedirectsTo}}<section><h4>Also known as</h4><ul>{{range $w.RedirectsTo}}<li><code>{{str (index . "d")}}</code></li>{{end}}</ul></section>78+{{end}}<section><h4>Actions</h4><ul>{{if $w.Version}}{{if eq .Actor $w.Author}}<li><a href="{{wikiURL $w.D "edit" "1"}}">edit</a></li>{{else if .Actor}}<li><a href="{{wikiURL $w.D "edit" "1"}}">fork</a></li>{{end}}<li><a href="{{wikiURL $w.D "history" "1"}}">history</a></li>{{end}}{{if .Actor}}<li><a href="/wiki?new=1">new page</a></li>{{else}}<li><a href="/signin">sign in to write</a></li>{{end}}</ul></section>{{end}}{{end}}@@ -0,0 +1,198 @@
1+package webui2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "net/http"8+ "net/http/httptest"9+ "strings"10+ "testing"11+12+ "github.com/FelineStateMachine/tinyrelay/internal/policy"13+)14+15+const (16+ wikiOwner = "1111111111111111111111111111111111111111111111111111111111111111"17+ wikiOther = "2222222222222222222222222222222222222222222222222222222222222222"18+ wikiMerge = "3333333333333333333333333333333333333333333333333333333333333333"19+ wikiOwnerV = "4444444444444444444444444444444444444444444444444444444444444444"20+ wikiOtherV = "5555555555555555555555555555555555555555555555555555555555555555"21+)22+23+// wikiBackend answers the three wiki queries with one page that has two24+// versions and an open merge request from the second author to the owner.25+type wikiBackend struct {26+ fakeBackend27+ calls []string28+ params map[string]map[string]any29+ private bool30+}31+32+func (b *wikiBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {33+ b.calls = append(b.calls, method)34+ var object map[string]any35+ if len(params) == 1 {36+ _ = json.Unmarshal(params[0], &object)37+ }38+ if b.params == nil {39+ b.params = map[string]map[string]any{}40+ }41+ b.params[method] = object42+ if b.private && actor == "" {43+ return nil, errors.New("auth-required: sign in to read this relay")44+ }45+ owner := map[string]any{"id": wikiOwnerV, "author": wikiOwner, "created_at": float64(1788876000), "d": "release-notes-1-4", "title": "Release notes 1.4 <b>", "summary": "What changed", "coordinate": "30818:" + wikiOwner + ":release-notes-1-4", "likes": float64(0),46+ "content": "# Highlights\n\nPrivate tenants and _encrypted_ uploads. See [[Files and Private Repositories]].\n\n<script>alert(1)</script>", "links": []any{"files-and-private-repositories"}}47+ other := map[string]any{"id": wikiOtherV, "author": wikiOther, "created_at": float64(1788880000), "d": "release-notes-1-4", "title": "Release notes 1.4", "coordinate": "30818:" + wikiOther + ":release-notes-1-4", "likes": float64(0), "fork": map[string]any{"a": owner["coordinate"], "e": wikiOwnerV}, "content": "# Highlights\n\nA proposed rewrite."}48+ merge := map[string]any{"id": wikiMerge, "author": wikiOther, "created_at": float64(1788881000), "content": "Please take my rewrite", "target": owner["coordinate"], "target_d": "release-notes-1-4", "destination": wikiOwner, "source": wikiOtherV, "status": "open"}49+ switch method {50+ case "browsewiki":51+ return map[string]any{"items": []any{map[string]any{"d": "release-notes-1-4", "title": "Release notes 1.4 <b>", "version": owner, "versions": float64(2), "open_merges": float64(1)}}, "next_cursor": "release-notes-1-4"}, nil52+ case "browsewikipage":53+ if object["d"] != "release-notes-1-4" {54+ return nil, errors.New("not found: wiki page")55+ }56+ return map[string]any{"d": "release-notes-1-4", "title": "Release notes 1.4 <b>", "version": owner, "preferred_by": "owner", "versions": []any{other, owner}, "merges": []any{merge}, "redirects_to": []any{map[string]any{"d": "changelog-1-4", "target_d": "release-notes-1-4"}}, "redirects_from": []any{}}, nil57+ case "browsewikimerge":58+ return map[string]any{"merge": merge, "proposed": other, "target": owner}, nil59+ }60+ return nil, errors.New("unsupported: " + method)61+}62+63+func wikiApp(t *testing.T, b *wikiBackend, actor string) *App {64+ t.Helper()65+ app, err := New(b, Options{Actor: func(*http.Request) (string, error) { return actor, nil }})66+ if err != nil {67+ t.Fatal(err)68+ }69+ return app70+}71+72+func wikiGet(t *testing.T, app *App, path string) string {73+ t.Helper()74+ recorder := httptest.NewRecorder()75+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))76+ if recorder.Code != http.StatusOK {77+ t.Fatalf("%s: status=%d body=%s", path, recorder.Code, recorder.Body.String())78+ }79+ body := recorder.Body.String()80+ if strings.Contains(body, "class=") {81+ t.Fatalf("%s carries a class attribute", path)82+ }83+ if strings.Contains(body, "ยท") {84+ t.Fatalf("%s carries a middle dot", path)85+ }86+ return body87+}88+89+func wantAll(t *testing.T, path, body string, wants ...string) {90+ t.Helper()91+ for _, want := range wants {92+ if !strings.Contains(body, want) {93+ t.Errorf("%s missing %q", path, want)94+ }95+ }96+}97+98+func wantNone(t *testing.T, path, body string, wants ...string) {99+ t.Helper()100+ for _, want := range wants {101+ if strings.Contains(body, want) {102+ t.Errorf("%s unexpectedly contains %q", path, want)103+ }104+ }105+}106+107+func TestWikiListRendersPagesAndSearch(t *testing.T) {108+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}}109+ app := wikiApp(t, b, wikiOwner)110+ body := wikiGet(t, app, "/wiki?q=release")111+ if b.calls[0] != "browsewiki" || b.params["browsewiki"]["q"] != "release" {112+ t.Fatalf("calls=%v params=%v", b.calls, b.params)113+ }114+ wantAll(t, "/wiki", body,115+ `href="/wiki" aria-current="page"`, `id="wiki-search"`, `value="release"`,116+ `<a href="/wiki/release-notes-1-4">Release notes 1.4 <b></a>`, `<nostr-key hex="`+wikiOwner+`"`,117+ `<td>2</td><td>1 open</td>`, `2026-09-08 14:00 UTC`, `href="/wiki?cursor=release-notes-1-4&q=release"`,118+ `<b>demo</b> » wiki`, `<a href="/wiki?new=1">new page</a>`)119+ wantNone(t, "/wiki", body, "<wiki-compose")120+ body = wikiGet(t, app, "/wiki?new=1")121+ wantAll(t, "/wiki?new=1", body, `<h2>New page</h2>`, `<wiki-compose name="" author="" coordinate="" event="">`, `name="title"`, `name="name"`, `name="summary"`, `name="content"`, `value="publish"`, `<noscript>`)122+ wantNone(t, "/wiki?new=1", body, "Propose to")123+}124+125+func TestWikiPageRendersArticleMergeBarAndPanel(t *testing.T) {126+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}}127+ path := "/wiki/Release%20Notes%201-4"128+ body := wikiGet(t, wikiApp(t, b, wikiOwner), path)129+ if b.params["browsewikipage"]["d"] != "release-notes-1-4" {130+ t.Fatalf("page name was not normalized: %v", b.params["browsewikipage"])131+ }132+ wantAll(t, path, body,133+ `<title>Release notes 1.4 <b> | wiki | demo</title>`, `<h1>Release notes 1.4 <b></h1>`,134+ `<merge-request><nostr-key hex="`+wikiOther+`"`, `proposes a new version of this page`, `href="/wiki/release-notes-1-4?merge=`+wikiMerge+`">compare</a>`,135+ `<nostr-react event="`+wikiMerge+`" pubkey="`+wikiOther+`" kind="818"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react>`,136+ `<a href="/e/`+wikiMerge+`">Reply</a>`,137+ `<wiki-article><header>wiki | release-notes-1-4 | by <nostr-key hex="`+wikiOwner+`"`, `version 1 of 2 | 1 fork</header>`,138+ `<h1>Highlights</h1>`, `<em>encrypted</em>`, `<a href="/wiki/files-and-private-repositories">Files and Private Repositories</a>`, `<script>alert(1)</script>`,139+ `<h4>Page</h4>`, `<th>versions</th><td>2</td>`, `<th>merge requests</th><td>1 open</td>`, `<th>links</th><td>1</td>`,140+ `<h4>Other versions</h4>`, `href="/wiki/release-notes-1-4?version=`+wikiOtherV+`">222222222222</a> | fork`,141+ `<h4>Also known as</h4>`, `<code>changelog-1-4</code>`,142+ `href="/wiki/release-notes-1-4?edit=1">edit</a>`, `href="/wiki/release-notes-1-4?history=1">history</a>`,143+ `<b>demo</b> » wiki/Release Notes 1-4`)144+ wantNone(t, path, body, "<script>alert", ">fork</a>")145+ // The other author sees the request but cannot answer it, and forks146+ // instead of editing.147+ body = wikiGet(t, wikiApp(t, b, wikiOther), path)148+ wantAll(t, path, body, `<merge-request>`, `href="/wiki/release-notes-1-4?edit=1">fork</a>`)149+ wantNone(t, path, body, "<nostr-react", ">edit</a>")150+}151+152+func TestWikiPageCompareEditorAndHistory(t *testing.T) {153+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}}154+ path := "/wiki/release-notes-1-4?merge=" + wikiMerge155+ body := wikiGet(t, wikiApp(t, b, wikiOwner), path)156+ if b.params["browsewikimerge"]["id"] != wikiMerge {157+ t.Fatalf("merge lookup params=%v", b.params["browsewikimerge"])158+ }159+ wantAll(t, path, body, `<div id="wiki-compare">`, `| open | Please take my rewrite</p>`, `<h2>Proposed version</h2>`, `A proposed rewrite.`, `<h2>Current version</h2>`, `<em>encrypted</em>`, `href="/wiki/release-notes-1-4">back to the page</a>`)160+161+ path = "/wiki/release-notes-1-4?edit=1"162+ body = wikiGet(t, wikiApp(t, b, wikiOther), path)163+ wantAll(t, path, body, `<wiki-compose name="release-notes-1-4" author="`+wikiOwner+`" coordinate="30818:`+wikiOwner+`:release-notes-1-4" event="`+wikiOwnerV+`">`,164+ `value="Release notes 1.4 <b>"`, `value="release-notes-1-4"`, `value="What changed"`, `<script>alert(1)</script></textarea>`,165+ `<button name="action" value="propose">Propose to 111111111111</button>`, `JavaScript and a connected signer are required to publish.`)166+ wantNone(t, path, body, "<wiki-article>")167+ body = wikiGet(t, wikiApp(t, b, wikiOwner), path)168+ wantNone(t, path, body, "Propose to")169+170+ path = "/wiki/release-notes-1-4?history=1"171+ body = wikiGet(t, wikiApp(t, b, wikiOwner), path)172+ wantAll(t, path, body, `<th>author</th><th>version</th>`, `fork of 444444444444`, `href="/wiki/release-notes-1-4?version=`+wikiOwnerV+`">444444444444</a>`)173+}174+175+func TestWikiMissingPageOffersTheEditorToMembers(t *testing.T) {176+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}}177+ path := "/wiki/Whats%20Up%3F"178+ body := wikiGet(t, wikiApp(t, b, wikiOwner), path)179+ wantAll(t, path, body, `<h1>whats-up</h1>`, `There is no page named <code>whats-up</code> yet.`, `<wiki-compose name="whats-up"`, `<th>name</th><td><code>whats-up</code></td>`)180+ wantNone(t, path, body, `role="alert"`)181+ body = wikiGet(t, wikiApp(t, b, ""), path)182+ wantAll(t, path, body, `<a href="/signin">Sign in</a> to write it.`)183+ wantNone(t, path, body, "<wiki-compose")184+}185+186+func TestWikiGuestAccessFollowsTheReadsPolicy(t *testing.T) {187+ members := policy.Defaults(wikiOwner)188+ members.Reads = "members"189+ b := &wikiBackend{fakeBackend: fakeBackend{policy: members}, private: true}190+ for _, path := range []string{"/wiki", "/wiki/release-notes-1-4"} {191+ body := wikiGet(t, wikiApp(t, b, ""), path)192+ wantAll(t, path, body, `<p role="alert">auth-required: sign in to read this relay</p>`, `browsing as guest`)193+ wantNone(t, path, body, "<wiki-article>", "<merge-request>", "Release notes 1.4", "<wiki-compose")194+ body = wikiGet(t, wikiApp(t, b, wikiOther), path)195+ wantAll(t, path, body, "Release notes 1.4")196+ wantNone(t, path, body, `role="alert"`)197+ }198+}