tinyrelaycommit

Commit cd6f6d0eca9a

browse files at this commit | history

1diff --git a/internal/webui/components.js b/internal/webui/components.js
2index 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;
70diff --git a/internal/webui/custom_views.go b/internal/webui/custom_views.go
71new file mode 100644
72index 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+}
107diff --git a/internal/webui/manage.html b/internal/webui/manage.html
108index 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
144diff --git a/internal/webui/markdown.go b/internal/webui/markdown.go
145index 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 }
249diff --git a/internal/webui/markdown_test.go b/internal/webui/markdown_test.go
250index 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 &lt;x&gt;</code></pre>", "<ul><li>one</li><li>two</li></ul>", "<ol><li>first</li><li>second</li></ol>", "&lt;script&gt;alert(1)&lt;/script&gt;"} {
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 &lt;x&gt;</code></pre>`, "<ul><li>one</li><li>two</li></ul>", "<ol><li>first</li><li>second</li></ol>", "&lt;script&gt;alert(1)&lt;/script&gt;"} {
259 if !strings.Contains(got, want) {
260 t.Errorf("missing %q in %s", want, got)
261 }
262diff --git a/internal/webui/prefix.go b/internal/webui/prefix.go
263index 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.
275diff --git a/internal/webui/style.css b/internal/webui/style.css
276index 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}
299diff --git a/internal/webui/templates.go b/internal/webui/templates.go
300index 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,
335diff --git a/internal/webui/webui.go b/internal/webui/webui.go
336index 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 }
436diff --git a/internal/webui/wiki.go b/internal/webui/wiki.go
437index 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.
451diff --git a/internal/wiki/wiki.go b/internal/wiki/wiki.go
452index 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])

About

ownerc01503e8035f
visibilitypublic
headcd6f6d0eca9a

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs