| 1 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 2 | index 6ac7d79..f829c66 100644 |
| 3 | --- a/internal/webui/components.js |
| 4 | +++ b/internal/webui/components.js |
| 5 | @@ -181,6 +181,47 @@ |
| 6 | } |
| 7 | } |
| 8 | |
| 9 | + // ViewForm adds a custom view: it composes the addcustomview object from |
| 10 | + // its fields and shows the returned secret once, since the relay never |
| 11 | + // shows it again. |
| 12 | + class ViewForm extends RpcForm { |
| 13 | + params(form) { |
| 14 | + const value = name => (form.elements[name]?.value || "").trim(); |
| 15 | + const options = { |
| 16 | + name: value("view-name"), |
| 17 | + kinds: list(value("view-kinds")).map(Number), |
| 18 | + languages: list(value("view-languages")), |
| 19 | + transform: value("view-transform"), |
| 20 | + trigger: value("view-trigger") || "write", |
| 21 | + audience: value("view-audience") || "public" |
| 22 | + }; |
| 23 | + if (value("view-max-bytes")) options.max_bytes = Number(value("view-max-bytes")); |
| 24 | + if (value("view-secret")) options.secret = value("view-secret"); |
| 25 | + return [options]; |
| 26 | + } |
| 27 | + |
| 28 | + show(result) { |
| 29 | + const secret = result && typeof result === "object" ? result.secret : ""; |
| 30 | + if (!secret) return super.show(result); |
| 31 | + const warning = el("p", "Copy the view's secret now. The relay signs each transform request with it and will not show it again."); |
| 32 | + warning.setAttribute("role", "alert"); |
| 33 | + const label = el("label", "View secret "); |
| 34 | + label.setAttribute("data-secret", ""); |
| 35 | + const input = el("input"); |
| 36 | + input.readOnly = true; |
| 37 | + input.name = "secret"; |
| 38 | + input.value = secret; |
| 39 | + input.addEventListener("focus", () => input.select()); |
| 40 | + label.append(input); |
| 41 | + const link = el("a", "Show the new view"); |
| 42 | + link.href = globalThis.location?.pathname || ""; |
| 43 | + const note = el("p"); |
| 44 | + note.append(link); |
| 45 | + this.form.reset(); |
| 46 | + this.append(warning, label, note); |
| 47 | + } |
| 48 | + } |
| 49 | + |
| 50 | class SignedForm extends FormElement { |
| 51 | async submit(form) { |
| 52 | const action = this.getAttribute("action"); |
| 53 | @@ -1674,6 +1715,7 @@ |
| 54 | ensureModules(); |
| 55 | |
| 56 | customElements.define("rpc-form", RpcForm); |
| 57 | + customElements.define("view-form", ViewForm); |
| 58 | customElements.define("connect-card", ConnectCard); |
| 59 | customElements.define("connect-list", ConnectList); |
| 60 | customElements.define("relay-lists", RelayLists); |
| 61 | @@ -1737,7 +1779,7 @@ |
| 62 | document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg)); |
| 63 | window.addEventListener("pagehide", closeStreams); |
| 64 | const repairComponents = () => { |
| 65 | - document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,profile-form,nostr-react,wiki-compose,room-compose,room-create,room-action").forEach(node => { |
| 66 | + document.querySelectorAll("rpc-form,view-form,signed-form,publish-list,agent-grant,profile-form,nostr-react,wiki-compose,room-compose,room-create,room-action").forEach(node => { |
| 67 | if (node.form?.isConnected) return; |
| 68 | node.form = null; |
| 69 | node.output = null; |
| 70 | diff --git a/internal/webui/custom_views.go b/internal/webui/custom_views.go |
| 71 | new file mode 100644 |
| 72 | index 0000000..652aa79 |
| 73 | --- /dev/null |
| 74 | +++ b/internal/webui/custom_views.go |
| 75 | @@ -0,0 +1,31 @@ |
| 76 | +package webui |
| 77 | + |
| 78 | +import "strings" |
| 79 | + |
| 80 | +// customViewState is the row state on the Views page: paused when the |
| 81 | +// owner or the relay paused the view, active otherwise. |
| 82 | +func customViewState(view any) string { |
| 83 | + if valueMap(view)["enabled"] == false { |
| 84 | + return "paused" |
| 85 | + } |
| 86 | + return "active" |
| 87 | +} |
| 88 | + |
| 89 | +// customViewKinds lists the kinds a custom view watches. |
| 90 | +func customViewKinds(view any) string { |
| 91 | + return joinValues(valueMap(view)["kinds"]) |
| 92 | +} |
| 93 | + |
| 94 | +// customViewLanguages lists the fenced block languages a custom view renders. |
| 95 | +func customViewLanguages(view any) string { |
| 96 | + return joinValues(valueMap(view)["languages"]) |
| 97 | +} |
| 98 | + |
| 99 | +func joinValues(value any) string { |
| 100 | + items, _ := value.([]any) |
| 101 | + parts := make([]string, 0, len(items)) |
| 102 | + for _, item := range items { |
| 103 | + parts = append(parts, plainString(item)) |
| 104 | + } |
| 105 | + return strings.Join(parts, ", ") |
| 106 | +} |
| 107 | diff --git a/internal/webui/manage.html b/internal/webui/manage.html |
| 108 | index b95ab75..a1f8b10 100644 |
| 109 | --- a/internal/webui/manage.html |
| 110 | +++ b/internal/webui/manage.html |
| 111 | @@ -246,6 +246,32 @@ |
| 112 | <h1>Views</h1> |
| 113 | <rpc-form method="listviews"><button>List views</button></rpc-form> |
| 114 | <rpc-form method="publishview"><label>View name <input name="param" required></label><button>Publish view</button></rpc-form> |
| 115 | +{{if .Owner}}<h3>Custom views</h3> |
| 116 | +<p>A custom view sends fenced code blocks to a transform you run and keeps what comes back as relay-signed artifacts shown in place of the code.</p> |
| 117 | +{{if .CustomViews}}<table id="custom-views"> |
| 118 | +<thead><tr><th>name</th><th>kinds</th><th>languages</th><th>trigger</th><th>audience</th><th>state</th><th>last run</th><th></th></tr></thead> |
| 119 | +<tbody>{{range .CustomViews}}{{$v := asMap .}}<tr id="view-{{str (index $v "name")}}"> |
| 120 | +<td><code>{{str (index $v "name")}}</code></td> |
| 121 | +<td>{{viewKinds $v}}</td> |
| 122 | +<td>{{viewLanguages $v}}</td> |
| 123 | +<td>{{str (index $v "trigger")}}</td> |
| 124 | +<td>{{str (index $v "audience")}}</td> |
| 125 | +<td data-state="{{viewState $v}}">{{viewState $v}}{{with str (index $v "lastStatus")}} | {{.}}{{end}}</td> |
| 126 | +<td>{{with index $v "lastRun"}}{{when .}}{{else}}never{{end}}</td> |
| 127 | +<td>{{if eq (viewState $v) "paused"}}<rpc-form method="resumecustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Resume</button></rpc-form>{{else}}<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Pause</button></rpc-form><rpc-form method="runcustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Run now</button></rpc-form>{{end}}<rpc-form method="removecustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Remove</button></rpc-form></td> |
| 128 | +</tr>{{end}}</tbody> |
| 129 | +</table>{{else}}<p id="custom-views-empty">No custom views yet.</p>{{end}} |
| 130 | +<view-form method="addcustomview"> |
| 131 | +<label>Name <input name="view-name" pattern="[a-z0-9-]{1,32}" placeholder="diagrams" required></label> |
| 132 | +<label>Kinds <input name="view-kinds" placeholder="1, 30023, 30618" required></label> |
| 133 | +<label>Languages <input name="view-languages" placeholder="mermaid, dot" required></label> |
| 134 | +<label>Transform URL <input name="view-transform" type="url" placeholder="https://render.example/tiny" required></label> |
| 135 | +<label>Trigger <select name="view-trigger"><option value="write">write</option><option value="hourly">hourly</option></select></label> |
| 136 | +<label>Audience <select name="view-audience"><option value="public">public</option><option value="members">members</option></select></label> |
| 137 | +<label>Max artifact bytes <input name="view-max-bytes" type="number" min="1" max="4194304" placeholder="1048576"></label> |
| 138 | +<label>Secret <input name="view-secret" minlength="16" maxlength="128" placeholder="generated when empty"></label> |
| 139 | +<button>Add view</button> |
| 140 | +</view-form>{{end}} |
| 141 | </section> |
| 142 | {{end}} |
| 143 | |
| 144 | diff --git a/internal/webui/markdown.go b/internal/webui/markdown.go |
| 145 | index 0d928f9..6f91623 100644 |
| 146 | --- a/internal/webui/markdown.go |
| 147 | +++ b/internal/webui/markdown.go |
| 148 | @@ -7,25 +7,42 @@ import ( |
| 149 | "path" |
| 150 | "regexp" |
| 151 | "strings" |
| 152 | + |
| 153 | + "github.com/FelineStateMachine/tinyrelay/internal/views" |
| 154 | ) |
| 155 | |
| 156 | +// blockRenderer decides how a fenced code block is shown. It returns the |
| 157 | +// markup for blocks a custom view renders and false for the rest, which |
| 158 | +// stay code blocks. |
| 159 | +type blockRenderer func(lang, source string) (string, bool) |
| 160 | + |
| 161 | // renderMarkdown turns a small, common subset of Markdown into HTML: ATX |
| 162 | // headings, paragraphs, fenced code, unordered and ordered lists, inline |
| 163 | // code, emphasis, and links. Every piece of text is escaped first and only |
| 164 | // the tags this function emits reach the page, so untrusted README files and |
| 165 | // relay descriptions cannot inject markup. Anything else stays plain text. |
| 166 | func renderMarkdown(source string) template.HTML { |
| 167 | - return markdownHTML(source, false) |
| 168 | + return markdownHTML(source, false, nil) |
| 169 | +} |
| 170 | + |
| 171 | +// renderMarkdownWith is renderMarkdown with fenced blocks offered to a |
| 172 | +// custom view renderer first. |
| 173 | +func renderMarkdownWith(source string, blocks blockRenderer) template.HTML { |
| 174 | + return markdownHTML(source, false, blocks) |
| 175 | } |
| 176 | |
| 177 | // renderChatMarkdown is the same subset for chat messages, where a single |
| 178 | // newline is a line break and bare http(s) and nostr: references become |
| 179 | // links, as people and agents type them. |
| 180 | func renderChatMarkdown(source string) template.HTML { |
| 181 | - return template.HTML(autolinkOutsideTags(string(markdownHTML(source, true)))) |
| 182 | + return renderChatMarkdownWith(source, nil) |
| 183 | +} |
| 184 | + |
| 185 | +func renderChatMarkdownWith(source string, blocks blockRenderer) template.HTML { |
| 186 | + return template.HTML(autolinkOutsideTags(string(markdownHTML(source, true, blocks)))) |
| 187 | } |
| 188 | |
| 189 | -func markdownHTML(source string, breaks bool) template.HTML { |
| 190 | +func markdownHTML(source string, breaks bool, blocks blockRenderer) template.HTML { |
| 191 | lines := strings.Split(strings.ReplaceAll(source, "\r\n", "\n"), "\n") |
| 192 | var out strings.Builder |
| 193 | var paragraph []string |
| 194 | @@ -46,14 +63,26 @@ func markdownHTML(source string, breaks bool) template.HTML { |
| 195 | } |
| 196 | for i := 0; i < len(lines); i++ { |
| 197 | line := lines[i] |
| 198 | + marker, lang, fenced := views.OpenFence(line) |
| 199 | switch { |
| 200 | - case strings.HasPrefix(line, "```"): |
| 201 | + case fenced: |
| 202 | flush() |
| 203 | var code []string |
| 204 | - for i++; i < len(lines) && !strings.HasPrefix(lines[i], "```"); i++ { |
| 205 | + for i++; i < len(lines) && !views.ClosesFence(lines[i], marker); i++ { |
| 206 | code = append(code, lines[i]) |
| 207 | } |
| 208 | - out.WriteString("<pre><code>" + html.EscapeString(strings.Join(code, "\n")) + "</code></pre>\n") |
| 209 | + body := strings.Join(code, "\n") |
| 210 | + if blocks != nil { |
| 211 | + if figure, ok := blocks(lang, body); ok { |
| 212 | + out.WriteString(figure) |
| 213 | + continue |
| 214 | + } |
| 215 | + } |
| 216 | + out.WriteString("<pre><code") |
| 217 | + if lang != "" { |
| 218 | + out.WriteString(` data-lang="` + html.EscapeString(lang) + `"`) |
| 219 | + } |
| 220 | + out.WriteString(">" + html.EscapeString(body) + "</code></pre>\n") |
| 221 | case strings.HasPrefix(line, "#"): |
| 222 | flush() |
| 223 | level := len(line) - len(strings.TrimLeft(line, "#")) |
| 224 | @@ -137,7 +166,11 @@ var markdownAnchor = regexp.MustCompile(`<a href="([^"]+)">`) |
| 225 | // renderRepositoryMarkdown resolves relative README links to relay file |
| 226 | // viewers while retaining external links and fragment-only links. |
| 227 | func renderRepositoryMarkdown(source string, query url.Values) template.HTML { |
| 228 | - rendered := string(renderMarkdown(source)) |
| 229 | + return renderRepositoryMarkdownWith(source, query, nil) |
| 230 | +} |
| 231 | + |
| 232 | +func renderRepositoryMarkdownWith(source string, query url.Values, blocks blockRenderer) template.HTML { |
| 233 | + rendered := string(renderMarkdownWith(source, blocks)) |
| 234 | return template.HTML(markdownAnchor.ReplaceAllStringFunc(rendered, func(anchor string) string { |
| 235 | match := markdownAnchor.FindStringSubmatch(anchor) |
| 236 | target, err := url.Parse(html.UnescapeString(match[1])) |
| 237 | @@ -183,9 +216,9 @@ func autolinkOutsideTags(rendered string) string { |
| 238 | if strings.HasPrefix(part, "<") { |
| 239 | lower := strings.ToLower(part) |
| 240 | switch { |
| 241 | - case strings.HasPrefix(lower, "<a ") || strings.HasPrefix(lower, "<code") || strings.HasPrefix(lower, "<pre"): |
| 242 | + case strings.HasPrefix(lower, "<a ") || strings.HasPrefix(lower, "<code") || strings.HasPrefix(lower, "<pre") || strings.HasPrefix(lower, "<object"): |
| 243 | skip++ |
| 244 | - case strings.HasPrefix(lower, "</a") || strings.HasPrefix(lower, "</code") || strings.HasPrefix(lower, "</pre"): |
| 245 | + case strings.HasPrefix(lower, "</a") || strings.HasPrefix(lower, "</code") || strings.HasPrefix(lower, "</pre") || strings.HasPrefix(lower, "</object"): |
| 246 | if skip > 0 { |
| 247 | skip-- |
| 248 | } |
| 249 | diff --git a/internal/webui/markdown_test.go b/internal/webui/markdown_test.go |
| 250 | index 8b57591..7daed24 100644 |
| 251 | --- a/internal/webui/markdown_test.go |
| 252 | +++ b/internal/webui/markdown_test.go |
| 253 | @@ -8,7 +8,7 @@ import ( |
| 254 | |
| 255 | func TestMarkdownRendersCommonFormsAndEscapesTheRest(t *testing.T) { |
| 256 | got := string(renderMarkdown("# Title\n\nA `code` span with **bold** and *em* and [a link](https://example.com).\n\n```sh\ngo build <x>\n```\n\n- one\n- two\n\n1. first\n2. second\n\n<script>alert(1)</script> and [bad](javascript:alert(1))")) |
| 257 | - for _, want := range []string{"<h1>Title</h1>", "<code>code</code>", "<strong>bold</strong>", "<em>em</em>", `<a href="https://example.com">a link</a>`, "<pre><code>go build <x></code></pre>", "<ul><li>one</li><li>two</li></ul>", "<ol><li>first</li><li>second</li></ol>", "<script>alert(1)</script>"} { |
| 258 | + for _, want := range []string{"<h1>Title</h1>", "<code>code</code>", "<strong>bold</strong>", "<em>em</em>", `<a href="https://example.com">a link</a>`, `<pre><code data-lang="sh">go build <x></code></pre>`, "<ul><li>one</li><li>two</li></ul>", "<ol><li>first</li><li>second</li></ol>", "<script>alert(1)</script>"} { |
| 259 | if !strings.Contains(got, want) { |
| 260 | t.Errorf("missing %q in %s", want, got) |
| 261 | } |
| 262 | diff --git a/internal/webui/prefix.go b/internal/webui/prefix.go |
| 263 | index bf86faf..fe0cf5d 100644 |
| 264 | --- a/internal/webui/prefix.go |
| 265 | +++ b/internal/webui/prefix.go |
| 266 | @@ -7,7 +7,7 @@ import ( |
| 267 | "golang.org/x/net/html" |
| 268 | ) |
| 269 | |
| 270 | -var rootURLAttribute = regexp.MustCompile(`\s(?:href|src|fx-action|action)="(/[^\"]*)"`) |
| 271 | +var rootURLAttribute = regexp.MustCompile(`\s(?:href|src|fx-action|action|data)="(/[^\"]*)"`) |
| 272 | |
| 273 | // injectBase scopes root-relative template URLs without changing scripts or |
| 274 | // literal content. The signer bridge scopes its requests with localPath. |
| 275 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 276 | index 0d6fe29..cb23ced 100644 |
| 277 | --- a/internal/webui/style.css |
| 278 | +++ b/internal/webui/style.css |
| 279 | @@ -150,6 +150,19 @@ agent-card p>a{display:inline-block;font:12px var(--mono);line-height:29px;verti |
| 280 | #decided{list-style:none;margin:8px 0 0;padding:0;max-width:720px}#decided li{padding:3px 0;font:13px var(--mono)}#decided li small{color:var(--muted)} |
| 281 | #decided li[data-state=approved] small{color:var(--accent)}#decided li[data-state=denied] small{color:var(--alert)} |
| 282 | |
| 283 | +/* #custom-views: the owner's custom views with their controls. view-form adds one and shows its secret once. */ |
| 284 | +#custom-views{border-collapse:collapse;width:100%;max-width:960px;font:13px var(--mono)} |
| 285 | +#custom-views th{text-align:left;font:12px var(--mono);color:var(--muted);font-weight:normal;padding:0 6px;height:28px;border-bottom:1px solid var(--line)} |
| 286 | +#custom-views td{padding:8px 6px;border-bottom:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere;line-height:1.35} |
| 287 | +#custom-views td:nth-child(7){white-space:nowrap;color:var(--muted);font-size:12px}#custom-views td:last-child{white-space:nowrap;width:210px} |
| 288 | +#custom-views rpc-form{display:inline-block;border:0;padding:0;margin:0 6px 0 0;vertical-align:middle}#custom-views rpc-form output:empty{display:none}#custom-views button{font-size:12px;padding:1px 6px} |
| 289 | +#custom-views-empty{color:var(--muted);font:13px var(--sans)} |
| 290 | +view-form{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px} |
| 291 | +view-form label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)} |
| 292 | +view-form label input,view-form label select{display:block;margin-top:2px;font:13px var(--mono);min-width:200px} |
| 293 | +view-form [role=alert]{color:var(--alert);font:13px var(--sans);margin:8px 0 4px}view-form label[data-secret] input{min-width:320px} |
| 294 | +/* figure[data-view]: an artifact a custom view rendered; the code inside the object shows when the artifact is missing. */ |
| 295 | +figure[data-view]{margin:12px 0}figure[data-view] object{display:block;max-width:100%}figure[data-view] pre{margin:0} |
| 296 | /* agent-callback: one registered callback on an agent card, with its controls. */ |
| 297 | agent-callback{display:block;margin:0 0 4px}agent-callback code{color:inherit} |
| 298 | agent-callback rpc-form{display:inline-block;border:0;padding:0;margin:0 0 0 6px;vertical-align:middle}agent-callback rpc-form output:empty{display:none}agent-callback button{font-size:12px;padding:1px 6px} |
| 299 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 300 | index 38df33c..553cd32 100644 |
| 301 | --- a/internal/webui/templates.go |
| 302 | +++ b/internal/webui/templates.go |
| 303 | @@ -236,7 +236,7 @@ var scriptsVersion = func() string { |
| 304 | return hex.EncodeToString(digest.Sum(nil))[:12] |
| 305 | }() |
| 306 | |
| 307 | -func parseTemplates() (*template.Template, error) { |
| 308 | +func parseTemplates(a *App) (*template.Template, error) { |
| 309 | funcs := template.FuncMap{ |
| 310 | "stylesheet": func() template.CSS { return template.CSS(styleCSS) }, |
| 311 | "scriptURL": func(name string) (string, error) { |
| 312 | @@ -259,8 +259,8 @@ func parseTemplates() (*template.Template, error) { |
| 313 | "str": plainString, |
| 314 | "datetime": datetime, |
| 315 | "when": when, |
| 316 | - "markdown": renderMarkdown, |
| 317 | - "chatMarkdown": renderChatMarkdown, |
| 318 | + "markdown": a.markdown, |
| 319 | + "chatMarkdown": a.chatMarkdown, |
| 320 | "hasPrefix": strings.HasPrefix, |
| 321 | "npub": identityNpub, |
| 322 | "wsURL": wsURL, |
| 323 | @@ -310,7 +310,10 @@ func parseTemplates() (*template.Template, error) { |
| 324 | "approvalViews": approvalViews, |
| 325 | "approvalCounts": approvalCounts, |
| 326 | "approvalDevices": approvalDevices, |
| 327 | - "wikiHTML": wikiHTML, |
| 328 | + "wikiHTML": a.wikiHTML, |
| 329 | + "viewState": customViewState, |
| 330 | + "viewKinds": customViewKinds, |
| 331 | + "viewLanguages": customViewLanguages, |
| 332 | "wikiView": wikiPageView, |
| 333 | "wikiProposal": wikiProposal, |
| 334 | "wikiURL": wikiURL, |
| 335 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 336 | index 1ed6241..dce4f39 100644 |
| 337 | --- a/internal/webui/webui.go |
| 338 | +++ b/internal/webui/webui.go |
| 339 | @@ -18,6 +18,7 @@ import ( |
| 340 | "time" |
| 341 | |
| 342 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 343 | + "github.com/FelineStateMachine/tinyrelay/internal/views" |
| 344 | "github.com/skip2/go-qrcode" |
| 345 | ) |
| 346 | |
| 347 | @@ -42,6 +43,12 @@ type Backend interface { |
| 348 | Identity() string |
| 349 | } |
| 350 | |
| 351 | +// CustomViewSource is the optional backend summary of the custom views the |
| 352 | +// renderers show in place of fenced code blocks: names and languages only. |
| 353 | +type CustomViewSource interface { |
| 354 | + CustomViews() []views.View |
| 355 | +} |
| 356 | + |
| 357 | type ActorResolver func(*http.Request) (string, error) |
| 358 | type QRProvider func(string) ([]byte, error) |
| 359 | |
| 360 | @@ -94,6 +101,8 @@ type PageData struct { |
| 361 | Rooms []any |
| 362 | // Callbacks lists the event callbacks shown beside each agent. |
| 363 | Callbacks []any |
| 364 | + // CustomViews lists the owner's custom views on the Views page. |
| 365 | + CustomViews []any |
| 366 | // Requests lists the access requests on the People page, pending first; |
| 367 | // Review is true when the viewer may decide them. |
| 368 | Requests []any |
| 369 | @@ -104,15 +113,39 @@ func New(backend Backend, options Options) (*App, error) { |
| 370 | if backend == nil { |
| 371 | return nil, fmt.Errorf("webui backend is required") |
| 372 | } |
| 373 | - tmpl, err := parseTemplates() |
| 374 | - if err != nil { |
| 375 | - return nil, err |
| 376 | - } |
| 377 | qr := options.QR |
| 378 | if qr == nil { |
| 379 | qr = defaultQR |
| 380 | } |
| 381 | - return &App{backend: backend, actor: options.Actor, qr: qr, tmpl: tmpl, version: options.Version, revision: options.Revision}, nil |
| 382 | + a := &App{backend: backend, actor: options.Actor, qr: qr, version: options.Version, revision: options.Revision} |
| 383 | + tmpl, err := parseTemplates(a) |
| 384 | + if err != nil { |
| 385 | + return nil, err |
| 386 | + } |
| 387 | + a.tmpl = tmpl |
| 388 | + return a, nil |
| 389 | +} |
| 390 | + |
| 391 | +// blocks is the block renderer for the custom views the backend reports, |
| 392 | +// or nil when there are none so fenced blocks stay code. |
| 393 | +func (a *App) blocks() blockRenderer { |
| 394 | + source, ok := a.backend.(CustomViewSource) |
| 395 | + if !ok { |
| 396 | + return nil |
| 397 | + } |
| 398 | + renderer := views.NewRenderer(source.CustomViews()) |
| 399 | + if len(renderer.Languages) == 0 { |
| 400 | + return nil |
| 401 | + } |
| 402 | + return renderer.Block |
| 403 | +} |
| 404 | + |
| 405 | +func (a *App) markdown(source string) template.HTML { |
| 406 | + return renderMarkdownWith(source, a.blocks()) |
| 407 | +} |
| 408 | + |
| 409 | +func (a *App) chatMarkdown(source string) template.HTML { |
| 410 | + return renderChatMarkdownWith(source, a.blocks()) |
| 411 | } |
| 412 | |
| 413 | func defaultQR(text string) ([]byte, error) { |
| 414 | @@ -711,7 +744,7 @@ func (a *App) readme(ctx context.Context, actor string, query url.Values) templa |
| 415 | if binary, _ := page["binary"].(bool); binary || content == "" { |
| 416 | continue |
| 417 | } |
| 418 | - return renderRepositoryMarkdown(content, query) |
| 419 | + return renderRepositoryMarkdownWith(content, query, a.blocks()) |
| 420 | } |
| 421 | return "" |
| 422 | } |
| 423 | @@ -925,6 +958,12 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) { |
| 424 | if tab == "people" && actor != "" { |
| 425 | a.peoplePage(request, actor, &data) |
| 426 | } |
| 427 | + if tab == "views" && actor != "" { |
| 428 | + // The listing is owner-only; anyone else keeps the built-in controls. |
| 429 | + if result, err := a.backend.Query(request.Context(), "listcustomviews", nil, actor); err == nil { |
| 430 | + data.CustomViews = browseRows(result) |
| 431 | + } |
| 432 | + } |
| 433 | if tab != "" { |
| 434 | data.Title = tab + " | " + a.backend.Slug() |
| 435 | } |
| 436 | diff --git a/internal/webui/wiki.go b/internal/webui/wiki.go |
| 437 | index d438c68..63831f7 100644 |
| 438 | --- a/internal/webui/wiki.go |
| 439 | +++ b/internal/webui/wiki.go |
| 440 | @@ -153,8 +153,8 @@ func wikiURL(d string, pairs ...string) string { |
| 441 | return path + "?" + values.Encode() |
| 442 | } |
| 443 | |
| 444 | -func wikiHTML(content any) template.HTML { |
| 445 | - return wiki.RenderHTML(plainString(content)) |
| 446 | +func (a *App) wikiHTML(content any) template.HTML { |
| 447 | + return wiki.RenderHTMLWith(plainString(content), wiki.Options{Block: a.blocks()}) |
| 448 | } |
| 449 | |
| 450 | // wikiPageName is the name in a /wiki/<d> path, normalized. |
| 451 | diff --git a/internal/wiki/wiki.go b/internal/wiki/wiki.go |
| 452 | index 887ad54..5c6b692 100644 |
| 453 | --- a/internal/wiki/wiki.go |
| 454 | +++ b/internal/wiki/wiki.go |
| 455 | @@ -89,6 +89,20 @@ func Links(content string) []string { |
| 456 | // escaped text. Link targets are limited to http, https, mailto, nostr and |
| 457 | // site-relative paths. |
| 458 | func RenderHTML(content string) template.HTML { |
| 459 | + return RenderHTMLWith(content, Options{}) |
| 460 | +} |
| 461 | + |
| 462 | +// Options adjusts rendering. Block, when set, is offered every fenced code |
| 463 | +// block with its language and source and returns markup to show in its |
| 464 | +// place, or false to keep the code block. The relay uses it for custom |
| 465 | +// views without this package knowing about them. |
| 466 | +type Options struct { |
| 467 | + Block func(lang, source string) (html string, ok bool) |
| 468 | +} |
| 469 | + |
| 470 | +// RenderHTMLWith renders Djot the way RenderHTML does, with the options |
| 471 | +// applied. |
| 472 | +func RenderHTMLWith(content string, options Options) template.HTML { |
| 473 | refs := definitions(content) |
| 474 | var b strings.Builder |
| 475 | for _, block := range blocks(content) { |
| 476 | @@ -96,6 +110,12 @@ func RenderHTML(content string) template.HTML { |
| 477 | case "def": |
| 478 | continue |
| 479 | case "pre": |
| 480 | + if options.Block != nil { |
| 481 | + if html, ok := options.Block(block.lang, strings.TrimSuffix(block.text, "\n")); ok { |
| 482 | + b.WriteString(html) |
| 483 | + continue |
| 484 | + } |
| 485 | + } |
| 486 | b.WriteString("<pre><code") |
| 487 | if block.lang != "" { |
| 488 | b.WriteString(` data-lang="` + template.HTMLEscapeString(block.lang) + `"`) |
| 489 | @@ -184,10 +204,11 @@ func blocks(content string) []block { |
| 490 | } |
| 491 | if fence := trimmed[:min(3, len(trimmed))]; fence == "```" || fence == "~~~" { |
| 492 | flush() |
| 493 | - lang := append(strings.Fields(trimmed[3:]), "")[0] |
| 494 | + run := len(trimmed) - len(strings.TrimLeft(trimmed, fence[:1])) |
| 495 | + lang := strings.ToLower(append(strings.Fields(trimmed[run:]), "")[0]) |
| 496 | var body []string |
| 497 | for i++; i < len(lines); i++ { |
| 498 | - if t := strings.TrimSpace(lines[i]); len(t) >= 3 && strings.Trim(t, fence[:1]) == "" { |
| 499 | + if t := strings.TrimSpace(lines[i]); len(t) >= run && strings.Trim(t, fence[:1]) == "" { |
| 500 | break |
| 501 | } |
| 502 | body = append(body, lines[i]) |