clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

render custom view artifacts in place of fenced code blocks

Dami committed cd6f6d0
commit
cd6f6d0eca9accf4d7e5a60bf842c97f1d3064ef
parent
8dd79a2655be8af901f089c924c060493daebb5b
author
Dami <dami.etoile@gmail.com>
11 files changed, +234 -26
Minternal/webui/components.js44+++++++++++++++++++++++++-
Ainternal/webui/custom_views.go31++++++++++++++++++
Minternal/webui/manage.html26+++++++++++++++
Minternal/webui/markdown.go51+++++++++++++++++++++++++-----
Minternal/webui/markdown_test.go2+-
Minternal/webui/prefix.go2+-
Minternal/webui/style.css13++++++++
Minternal/webui/templates.go11++++--
Minternal/webui/webui.go51++++++++++++++++++++++++++----
Minternal/webui/wiki.go4+-
Minternal/wiki/wiki.go25++++++++++++++-
M internal/webui/components.js +43 -1
@@ -181,6 +181,47 @@
181181 }182182 }183183 184+ // ViewForm adds a custom view: it composes the addcustomview object from185+ // its fields and shows the returned secret once, since the relay never186+ // shows it again.187+ class ViewForm extends RpcForm {188+ params(form) {189+ const value = name => (form.elements[name]?.value || "").trim();190+ const options = {191+ name: value("view-name"),192+ kinds: list(value("view-kinds")).map(Number),193+ languages: list(value("view-languages")),194+ transform: value("view-transform"),195+ trigger: value("view-trigger") || "write",196+ audience: value("view-audience") || "public"197+ };198+ if (value("view-max-bytes")) options.max_bytes = Number(value("view-max-bytes"));199+ if (value("view-secret")) options.secret = value("view-secret");200+ return [options];201+ }202+203+ show(result) {204+ const secret = result && typeof result === "object" ? result.secret : "";205+ if (!secret) return super.show(result);206+ const warning = el("p", "Copy the view's secret now. The relay signs each transform request with it and will not show it again.");207+ warning.setAttribute("role", "alert");208+ const label = el("label", "View secret ");209+ label.setAttribute("data-secret", "");210+ const input = el("input");211+ input.readOnly = true;212+ input.name = "secret";213+ input.value = secret;214+ input.addEventListener("focus", () => input.select());215+ label.append(input);216+ const link = el("a", "Show the new view");217+ link.href = globalThis.location?.pathname || "";218+ const note = el("p");219+ note.append(link);220+ this.form.reset();221+ this.append(warning, label, note);222+ }223+ }224+184225 class SignedForm extends FormElement {185226 async submit(form) {186227 const action = this.getAttribute("action");
@@ -1674,6 +1715,7 @@
16741715 ensureModules();16751716 16761717 customElements.define("rpc-form", RpcForm);1718+ customElements.define("view-form", ViewForm);16771719 customElements.define("connect-card", ConnectCard);16781720 customElements.define("connect-list", ConnectList);16791721 customElements.define("relay-lists", RelayLists);
@@ -1737,7 +1779,7 @@
17371779 document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg));17381780 window.addEventListener("pagehide", closeStreams);17391781 const repairComponents = () => {1740- document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,profile-form,nostr-react,wiki-compose,room-compose,room-create,room-action").forEach(node => {1782+ 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 => {17411783 if (node.form?.isConnected) return;17421784 node.form = null;17431785 node.output = null;
A internal/webui/custom_views.go +31 -0
M internal/webui/manage.html +26 -0
@@ -246,6 +246,32 @@
246246 <h1>Views</h1>247247 <rpc-form method="listviews"><button>List views</button></rpc-form>248248 <rpc-form method="publishview"><label>View name <input name="param" required></label><button>Publish view</button></rpc-form>249+{{if .Owner}}<h3>Custom views</h3>250+<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>251+{{if .CustomViews}}<table id="custom-views">252+<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>253+<tbody>{{range .CustomViews}}{{$v := asMap .}}<tr id="view-{{str (index $v "name")}}">254+<td><code>{{str (index $v "name")}}</code></td>255+<td>{{viewKinds $v}}</td>256+<td>{{viewLanguages $v}}</td>257+<td>{{str (index $v "trigger")}}</td>258+<td>{{str (index $v "audience")}}</td>259+<td data-state="{{viewState $v}}">{{viewState $v}}{{with str (index $v "lastStatus")}} | {{.}}{{end}}</td>260+<td>{{with index $v "lastRun"}}{{when .}}{{else}}never{{end}}</td>261+<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>262+</tr>{{end}}</tbody>263+</table>{{else}}<p id="custom-views-empty">No custom views yet.</p>{{end}}264+<view-form method="addcustomview">265+<label>Name <input name="view-name" pattern="[a-z0-9-]{1,32}" placeholder="diagrams" required></label>266+<label>Kinds <input name="view-kinds" placeholder="1, 30023, 30618" required></label>267+<label>Languages <input name="view-languages" placeholder="mermaid, dot" required></label>268+<label>Transform URL <input name="view-transform" type="url" placeholder="https://render.example/tiny" required></label>269+<label>Trigger <select name="view-trigger"><option value="write">write</option><option value="hourly">hourly</option></select></label>270+<label>Audience <select name="view-audience"><option value="public">public</option><option value="members">members</option></select></label>271+<label>Max artifact bytes <input name="view-max-bytes" type="number" min="1" max="4194304" placeholder="1048576"></label>272+<label>Secret <input name="view-secret" minlength="16" maxlength="128" placeholder="generated when empty"></label>273+<button>Add view</button>274+</view-form>{{end}}249275 </section>250276 {{end}}251277
M internal/webui/markdown.go +42 -9
@@ -7,25 +7,42 @@ import (
77 "path"88 "regexp"99 "strings"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/views"1012 )1113 14+// blockRenderer decides how a fenced code block is shown. It returns the15+// markup for blocks a custom view renders and false for the rest, which16+// stay code blocks.17+type blockRenderer func(lang, source string) (string, bool)18+1219 // renderMarkdown turns a small, common subset of Markdown into HTML: ATX1320 // headings, paragraphs, fenced code, unordered and ordered lists, inline1421 // code, emphasis, and links. Every piece of text is escaped first and only1522 // the tags this function emits reach the page, so untrusted README files and1623 // relay descriptions cannot inject markup. Anything else stays plain text.1724 func renderMarkdown(source string) template.HTML {18- return markdownHTML(source, false)25+ return markdownHTML(source, false, nil)26+}27+28+// renderMarkdownWith is renderMarkdown with fenced blocks offered to a29+// custom view renderer first.30+func renderMarkdownWith(source string, blocks blockRenderer) template.HTML {31+ return markdownHTML(source, false, blocks)1932 }2033 2134 // renderChatMarkdown is the same subset for chat messages, where a single2235 // newline is a line break and bare http(s) and nostr: references become2336 // links, as people and agents type them.2437 func renderChatMarkdown(source string) template.HTML {25- return template.HTML(autolinkOutsideTags(string(markdownHTML(source, true))))38+ return renderChatMarkdownWith(source, nil)39+}40+41+func renderChatMarkdownWith(source string, blocks blockRenderer) template.HTML {42+ return template.HTML(autolinkOutsideTags(string(markdownHTML(source, true, blocks))))2643 }2744 28-func markdownHTML(source string, breaks bool) template.HTML {45+func markdownHTML(source string, breaks bool, blocks blockRenderer) template.HTML {2946 lines := strings.Split(strings.ReplaceAll(source, "\r\n", "\n"), "\n")3047 var out strings.Builder3148 var paragraph []string
@@ -46,14 +63,26 @@ func markdownHTML(source string, breaks bool) template.HTML {
4663 }4764 for i := 0; i < len(lines); i++ {4865 line := lines[i]66+ marker, lang, fenced := views.OpenFence(line)4967 switch {50- case strings.HasPrefix(line, "```"):68+ case fenced:5169 flush()5270 var code []string53- for i++; i < len(lines) && !strings.HasPrefix(lines[i], "```"); i++ {71+ for i++; i < len(lines) && !views.ClosesFence(lines[i], marker); i++ {5472 code = append(code, lines[i])5573 }56- out.WriteString("<pre><code>" + html.EscapeString(strings.Join(code, "\n")) + "</code></pre>\n")74+ body := strings.Join(code, "\n")75+ if blocks != nil {76+ if figure, ok := blocks(lang, body); ok {77+ out.WriteString(figure)78+ continue79+ }80+ }81+ out.WriteString("<pre><code")82+ if lang != "" {83+ out.WriteString(` data-lang="` + html.EscapeString(lang) + `"`)84+ }85+ out.WriteString(">" + html.EscapeString(body) + "</code></pre>\n")5786 case strings.HasPrefix(line, "#"):5887 flush()5988 level := len(line) - len(strings.TrimLeft(line, "#"))
@@ -137,7 +166,11 @@ var markdownAnchor = regexp.MustCompile(`<a href="([^"]+)">`)
137166 // renderRepositoryMarkdown resolves relative README links to relay file138167 // viewers while retaining external links and fragment-only links.139168 func renderRepositoryMarkdown(source string, query url.Values) template.HTML {140- rendered := string(renderMarkdown(source))169+ return renderRepositoryMarkdownWith(source, query, nil)170+}171+172+func renderRepositoryMarkdownWith(source string, query url.Values, blocks blockRenderer) template.HTML {173+ rendered := string(renderMarkdownWith(source, blocks))141174 return template.HTML(markdownAnchor.ReplaceAllStringFunc(rendered, func(anchor string) string {142175 match := markdownAnchor.FindStringSubmatch(anchor)143176 target, err := url.Parse(html.UnescapeString(match[1]))
@@ -183,9 +216,9 @@ func autolinkOutsideTags(rendered string) string {
183216 if strings.HasPrefix(part, "<") {184217 lower := strings.ToLower(part)185218 switch {186- case strings.HasPrefix(lower, "<a ") || strings.HasPrefix(lower, "<code") || strings.HasPrefix(lower, "<pre"):219+ case strings.HasPrefix(lower, "<a ") || strings.HasPrefix(lower, "<code") || strings.HasPrefix(lower, "<pre") || strings.HasPrefix(lower, "<object"):187220 skip++188- case strings.HasPrefix(lower, "</a") || strings.HasPrefix(lower, "</code") || strings.HasPrefix(lower, "</pre"):221+ case strings.HasPrefix(lower, "</a") || strings.HasPrefix(lower, "</code") || strings.HasPrefix(lower, "</pre") || strings.HasPrefix(lower, "</object"):189222 if skip > 0 {190223 skip--191224 }
M internal/webui/markdown_test.go +1 -1
M internal/webui/prefix.go +1 -1
M internal/webui/style.css +13 -0
@@ -150,6 +150,19 @@ agent-card p>a{display:inline-block;font:12px var(--mono);line-height:29px;verti
150150 #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)}151151 #decided li[data-state=approved] small{color:var(--accent)}#decided li[data-state=denied] small{color:var(--alert)}152152 153+/* #custom-views: the owner's custom views with their controls. view-form adds one and shows its secret once. */154+#custom-views{border-collapse:collapse;width:100%;max-width:960px;font:13px var(--mono)}155+#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)}156+#custom-views td{padding:8px 6px;border-bottom:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere;line-height:1.35}157+#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}158+#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}159+#custom-views-empty{color:var(--muted);font:13px var(--sans)}160+view-form{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}161+view-form label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}162+view-form label input,view-form label select{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}163+view-form [role=alert]{color:var(--alert);font:13px var(--sans);margin:8px 0 4px}view-form label[data-secret] input{min-width:320px}164+/* figure[data-view]: an artifact a custom view rendered; the code inside the object shows when the artifact is missing. */165+figure[data-view]{margin:12px 0}figure[data-view] object{display:block;max-width:100%}figure[data-view] pre{margin:0}153166 /* agent-callback: one registered callback on an agent card, with its controls. */154167 agent-callback{display:block;margin:0 0 4px}agent-callback code{color:inherit}155168 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}
M internal/webui/templates.go +7 -4
M internal/webui/webui.go +45 -6
@@ -18,6 +18,7 @@ import (
1818 "time"1919 2020 "github.com/FelineStateMachine/tinyrelay/internal/policy"21+ "github.com/FelineStateMachine/tinyrelay/internal/views"2122 "github.com/skip2/go-qrcode"2223 )2324
@@ -42,6 +43,12 @@ type Backend interface {
4243 Identity() string4344 }4445 46+// CustomViewSource is the optional backend summary of the custom views the47+// renderers show in place of fenced code blocks: names and languages only.48+type CustomViewSource interface {49+ CustomViews() []views.View50+}51+4552 type ActorResolver func(*http.Request) (string, error)4653 type QRProvider func(string) ([]byte, error)4754
@@ -94,6 +101,8 @@ type PageData struct {
94101 Rooms []any95102 // Callbacks lists the event callbacks shown beside each agent.96103 Callbacks []any104+ // CustomViews lists the owner's custom views on the Views page.105+ CustomViews []any97106 // Requests lists the access requests on the People page, pending first;98107 // Review is true when the viewer may decide them.99108 Requests []any
@@ -104,15 +113,39 @@ func New(backend Backend, options Options) (*App, error) {
104113 if backend == nil {105114 return nil, fmt.Errorf("webui backend is required")106115 }107- tmpl, err := parseTemplates()108- if err != nil {109- return nil, err110- }111116 qr := options.QR112117 if qr == nil {113118 qr = defaultQR114119 }115- return &App{backend: backend, actor: options.Actor, qr: qr, tmpl: tmpl, version: options.Version, revision: options.Revision}, nil120+ a := &App{backend: backend, actor: options.Actor, qr: qr, version: options.Version, revision: options.Revision}121+ tmpl, err := parseTemplates(a)122+ if err != nil {123+ return nil, err124+ }125+ a.tmpl = tmpl126+ return a, nil127+}128+129+// blocks is the block renderer for the custom views the backend reports,130+// or nil when there are none so fenced blocks stay code.131+func (a *App) blocks() blockRenderer {132+ source, ok := a.backend.(CustomViewSource)133+ if !ok {134+ return nil135+ }136+ renderer := views.NewRenderer(source.CustomViews())137+ if len(renderer.Languages) == 0 {138+ return nil139+ }140+ return renderer.Block141+}142+143+func (a *App) markdown(source string) template.HTML {144+ return renderMarkdownWith(source, a.blocks())145+}146+147+func (a *App) chatMarkdown(source string) template.HTML {148+ return renderChatMarkdownWith(source, a.blocks())116149 }117150 118151 func defaultQR(text string) ([]byte, error) {
@@ -711,7 +744,7 @@ func (a *App) readme(ctx context.Context, actor string, query url.Values) templa
711744 if binary, _ := page["binary"].(bool); binary || content == "" {712745 continue713746 }714- return renderRepositoryMarkdown(content, query)747+ return renderRepositoryMarkdownWith(content, query, a.blocks())715748 }716749 return ""717750 }
@@ -925,6 +958,12 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) {
925958 if tab == "people" && actor != "" {926959 a.peoplePage(request, actor, &data)927960 }961+ if tab == "views" && actor != "" {962+ // The listing is owner-only; anyone else keeps the built-in controls.963+ if result, err := a.backend.Query(request.Context(), "listcustomviews", nil, actor); err == nil {964+ data.CustomViews = browseRows(result)965+ }966+ }928967 if tab != "" {929968 data.Title = tab + " | " + a.backend.Slug()930969 }
M internal/webui/wiki.go +2 -2
M internal/wiki/wiki.go +23 -2
@@ -89,6 +89,20 @@ func Links(content string) []string {
8989 // escaped text. Link targets are limited to http, https, mailto, nostr and9090 // site-relative paths.9191 func RenderHTML(content string) template.HTML {92+ return RenderHTMLWith(content, Options{})93+}94+95+// Options adjusts rendering. Block, when set, is offered every fenced code96+// block with its language and source and returns markup to show in its97+// place, or false to keep the code block. The relay uses it for custom98+// views without this package knowing about them.99+type Options struct {100+ Block func(lang, source string) (html string, ok bool)101+}102+103+// RenderHTMLWith renders Djot the way RenderHTML does, with the options104+// applied.105+func RenderHTMLWith(content string, options Options) template.HTML {92106 refs := definitions(content)93107 var b strings.Builder94108 for _, block := range blocks(content) {
@@ -96,6 +110,12 @@ func RenderHTML(content string) template.HTML {
96110 case "def":97111 continue98112 case "pre":113+ if options.Block != nil {114+ if html, ok := options.Block(block.lang, strings.TrimSuffix(block.text, "\n")); ok {115+ b.WriteString(html)116+ continue117+ }118+ }99119 b.WriteString("<pre><code")100120 if block.lang != "" {101121 b.WriteString(` data-lang="` + template.HTMLEscapeString(block.lang) + `"`)
@@ -184,10 +204,11 @@ func blocks(content string) []block {
184204 }185205 if fence := trimmed[:min(3, len(trimmed))]; fence == "```" || fence == "~~~" {186206 flush()187- lang := append(strings.Fields(trimmed[3:]), "")[0]207+ run := len(trimmed) - len(strings.TrimLeft(trimmed, fence[:1]))208+ lang := strings.ToLower(append(strings.Fields(trimmed[run:]), "")[0])188209 var body []string189210 for i++; i < len(lines); i++ {190- if t := strings.TrimSpace(lines[i]); len(t) >= 3 && strings.Trim(t, fence[:1]) == "" {211+ if t := strings.TrimSpace(lines[i]); len(t) >= run && strings.Trim(t, fence[:1]) == "" {191212 break192213 }193214 body = append(body, lines[i])