tinyrelayfile
tinyrelay/tinyclient/markdown.go/

tinyclient/markdown.go raw
1package tinyclient
2
3import (
4 "html"
5 "html/template"
6 "net/url"
7 "path"
8 "regexp"
9 "strings"
10
11 "github.com/FelineStateMachine/tinyrelay/internal/views"
12)
13
14// blockRenderer decides how a fenced code block is shown. It returns the
15// markup for blocks a custom view renders and false for the rest, which
16// stay code blocks.
17type blockRenderer func(lang, source string) (string, bool)
18
19// renderMarkdown turns a small, common subset of Markdown into HTML: ATX
20// headings, paragraphs, tables, fenced code, unordered and ordered lists, inline
21// code, emphasis, and links. Every piece of text is escaped first and only
22// the tags this function emits reach the page, so untrusted README files and
23// relay descriptions cannot inject markup. Anything else stays plain text.
24func renderMarkdown(source string) template.HTML {
25 return markdownHTML(source, false, nil)
26}
27
28// renderMarkdownWith is renderMarkdown with fenced blocks offered to a
29// custom view renderer first.
30func renderMarkdownWith(source string, blocks blockRenderer) template.HTML {
31 return markdownHTML(source, false, blocks)
32}
33
34// renderChatMarkdown is the same subset for chat messages, where a single
35// newline is a line break and bare http(s) and nostr: references become
36// links, as people and agents type them.
37func renderChatMarkdown(source string) template.HTML {
38 return renderChatMarkdownWith(source, nil)
39}
40
41func renderChatMarkdownWith(source string, blocks blockRenderer) template.HTML {
42 return template.HTML(autolinkOutsideTags(string(markdownHTML(source, true, blocks))))
43}
44
45func markdownHTML(source string, breaks bool, blocks blockRenderer) template.HTML {
46 lines := strings.Split(strings.ReplaceAll(source, "\r\n", "\n"), "\n")
47 var out strings.Builder
48 var paragraph []string
49 flush := func() {
50 if len(paragraph) == 0 {
51 return
52 }
53 joiner := " "
54 if breaks {
55 joiner = "<br>"
56 }
57 parts := make([]string, len(paragraph))
58 for i, line := range paragraph {
59 parts[i] = inlineMarkdown(line)
60 }
61 out.WriteString("<p>" + strings.Join(parts, joiner) + "</p>\n")
62 paragraph = paragraph[:0]
63 }
64 for i := 0; i < len(lines); i++ {
65 line := lines[i]
66 marker, lang, fenced := views.OpenFence(line)
67 switch {
68 case !fenced && i+1 < len(lines) && isTableCandidate(line, lines[i+1]):
69 flush()
70 table, end := renderTable(lines, i)
71 out.WriteString(table)
72 i = end
73 case fenced:
74 flush()
75 var code []string
76 for i++; i < len(lines) && !views.ClosesFence(lines[i], marker); i++ {
77 code = append(code, lines[i])
78 }
79 body := strings.Join(code, "\n")
80 if blocks != nil {
81 if figure, ok := blocks(lang, body); ok {
82 out.WriteString(figure)
83 continue
84 }
85 }
86 out.WriteString("<pre><code")
87 if lang != "" {
88 out.WriteString(` data-lang="` + html.EscapeString(lang) + `"`)
89 }
90 out.WriteString(">" + html.EscapeString(body) + "</code></pre>\n")
91 case strings.HasPrefix(line, "#"):
92 flush()
93 level := len(line) - len(strings.TrimLeft(line, "#"))
94 if level > 6 {
95 level = 6
96 }
97 text := strings.TrimSpace(line[level:])
98 tag := "h" + string(rune('0'+level))
99 out.WriteString("<" + tag + ">" + inlineMarkdown(text) + "</" + tag + ">\n")
100 case isListItem(line):
101 flush()
102 ordered := orderedItem.MatchString(line)
103 tag := "ul"
104 if ordered {
105 tag = "ol"
106 }
107 out.WriteString("<" + tag + ">")
108 for ; i < len(lines) && isListItem(lines[i]); i++ {
109 out.WriteString("<li>" + inlineMarkdown(listText(lines[i])) + "</li>")
110 }
111 i--
112 out.WriteString("</" + tag + ">\n")
113 case strings.TrimSpace(line) == "":
114 flush()
115 default:
116 paragraph = append(paragraph, strings.TrimSpace(line))
117 }
118 }
119 flush()
120 return template.HTML(out.String())
121}
122
123var tableDelimiter = regexp.MustCompile(`^:?-{3,}:?$`)
124
125func isTableCandidate(header, delimiter string) bool {
126 headerCells, headerHasPipes := splitTableRow(header)
127 delimiterCells, delimiterHasPipes := splitTableRow(delimiter)
128 if !headerHasPipes || !delimiterHasPipes || len(headerCells) == 0 || len(headerCells) != len(delimiterCells) {
129 return false
130 }
131 for _, cell := range delimiterCells {
132 if !tableDelimiter.MatchString(strings.TrimSpace(cell)) {
133 return false
134 }
135 }
136 return true
137}
138
139func splitTableRow(line string) ([]string, bool) {
140 line = strings.TrimSpace(line)
141 var cells []string
142 var cell strings.Builder
143 inCode := false
144 hasPipe := false
145 for i := 0; i < len(line); i++ {
146 if line[i] == '`' {
147 inCode = !inCode
148 }
149 if line[i] == '|' && !inCode && !escapedAt(line, i) {
150 hasPipe = true
151 cells = append(cells, strings.TrimSpace(cell.String()))
152 cell.Reset()
153 continue
154 }
155 cell.WriteByte(line[i])
156 }
157 cells = append(cells, strings.TrimSpace(cell.String()))
158 if len(cells) > 1 && cells[0] == "" {
159 cells = cells[1:]
160 }
161 if len(cells) > 1 && cells[len(cells)-1] == "" {
162 cells = cells[:len(cells)-1]
163 }
164 return cells, hasPipe
165}
166
167func escapedAt(value string, index int) bool {
168 backslashes := 0
169 for index > 0 && value[index-1] == '\\' {
170 backslashes++
171 index--
172 }
173 return backslashes%2 == 1
174}
175
176func renderTable(lines []string, start int) (string, int) {
177 header, _ := splitTableRow(lines[start])
178 separator, _ := splitTableRow(lines[start+1])
179 alignments := tableAlignments(separator)
180 var out strings.Builder
181 out.WriteString(`<div data-markdown-table="" role="region" aria-label="Table" tabindex="0"><table><thead><tr>`)
182 for i, cell := range header {
183 out.WriteString(`<th scope="col"` + tableAlignAttribute(alignments[i]) + `>` + inlineMarkdown(unescapeTablePipes(cell)) + `</th>`)
184 }
185 out.WriteString(`</tr></thead><tbody>`)
186 end := start + 1
187 for end+1 < len(lines) {
188 cells, pipes := splitTableRow(lines[end+1])
189 if !isTableBodyRow(lines[end+1], pipes) {
190 break
191 }
192 if len(cells) > len(header) {
193 cells = cells[:len(header)]
194 }
195 for len(cells) < len(header) {
196 cells = append(cells, "")
197 }
198 out.WriteString("<tr>")
199 for i, cell := range cells {
200 out.WriteString(`<td` + tableAlignAttribute(alignments[i]) + `>` + inlineMarkdown(unescapeTablePipes(cell)) + `</td>`)
201 }
202 out.WriteString("</tr>")
203 end++
204 }
205 out.WriteString(`</tbody></table></div>` + "\n")
206 return out.String(), end
207}
208
209func isTableBodyRow(line string, pipes bool) bool {
210 if !pipes || strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
211 return false
212 }
213 _, _, fenced := views.OpenFence(line)
214 return !fenced
215}
216
217func tableAlignments(cells []string) []string {
218 alignments := make([]string, len(cells))
219 for i, cell := range cells {
220 cell = strings.TrimSpace(cell)
221 switch {
222 case strings.HasPrefix(cell, ":") && strings.HasSuffix(cell, ":"):
223 alignments[i] = "center"
224 case strings.HasSuffix(cell, ":"):
225 alignments[i] = "right"
226 case strings.HasPrefix(cell, ":"):
227 alignments[i] = "left"
228 }
229 }
230 return alignments
231}
232
233func tableAlignAttribute(alignment string) string {
234 if alignment == "" {
235 return ""
236 }
237 return ` data-align="` + alignment + `"`
238}
239
240func unescapeTablePipes(value string) string {
241 return strings.ReplaceAll(value, `\|`, `|`)
242}
243
244var (
245 orderedItem = regexp.MustCompile(`^\s*\d+[.)]\s+`)
246 bulletItem = regexp.MustCompile(`^\s*[-*+]\s+`)
247 codeSpan = regexp.MustCompile("`([^`]+)`")
248 linkSpan = regexp.MustCompile(`\[([^\]]+)\]\(([^)\s]+)\)`)
249 strongSpan = regexp.MustCompile(`\*\*([^*]+)\*\*`)
250 emSpan = regexp.MustCompile(`(^|[^*\w])\*([^*]+)\*`)
251)
252
253func isListItem(line string) bool {
254 return bulletItem.MatchString(line) || orderedItem.MatchString(line)
255}
256
257func listText(line string) string {
258 if loc := bulletItem.FindStringIndex(line); loc != nil {
259 return line[loc[1]:]
260 }
261 return line[orderedItem.FindStringIndex(line)[1]:]
262}
263
264// inlineMarkdown escapes text, then restores the few inline forms it
265// recognises. Link targets are restricted to http, https, relative paths,
266// fragments and mailto so scripts and data URLs stay text.
267func inlineMarkdown(text string) string {
268 escaped := html.EscapeString(text)
269 escaped = codeSpan.ReplaceAllString(escaped, "<code>$1</code>")
270 escaped = linkSpan.ReplaceAllStringFunc(escaped, func(value string) string {
271 match := linkSpan.FindStringSubmatch(value)
272 if len(match) != 3 || !safeMarkdownLink(html.UnescapeString(match[2])) {
273 return value
274 }
275 return `<a href="` + match[2] + `">` + match[1] + `</a>`
276 })
277 escaped = strongSpan.ReplaceAllString(escaped, "<strong>$1</strong>")
278 escaped = emSpan.ReplaceAllString(escaped, "$1<em>$2</em>")
279 return escaped
280}
281
282func safeMarkdownLink(target string) bool {
283 return strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "http://") ||
284 (strings.HasPrefix(target, "/") && !strings.HasPrefix(target, "//")) ||
285 strings.HasPrefix(target, "./") || strings.HasPrefix(target, "../") ||
286 strings.HasPrefix(target, "#") || strings.HasPrefix(target, "mailto:") ||
287 (!strings.Contains(target, ":") && !strings.HasPrefix(target, "//"))
288}
289
290var markdownAnchor = regexp.MustCompile(`<a href="([^"]+)">`)
291
292// renderRepositoryMarkdown resolves relative README links to relay file
293// viewers while retaining external links and fragment-only links.
294func renderRepositoryMarkdown(source string, query url.Values) template.HTML {
295 return renderRepositoryMarkdownWith(source, query, nil)
296}
297
298func renderRepositoryMarkdownWith(source string, query url.Values, blocks blockRenderer) template.HTML {
299 rendered := string(renderMarkdownWith(source, blocks))
300 return template.HTML(markdownAnchor.ReplaceAllStringFunc(rendered, func(anchor string) string {
301 match := markdownAnchor.FindStringSubmatch(anchor)
302 target, err := url.Parse(html.UnescapeString(match[1]))
303 if err != nil || target.IsAbs() || target.Host != "" || target.Path == "" || strings.HasPrefix(target.Path, "/") {
304 return anchor
305 }
306 if target.Path == ".." || strings.HasPrefix(target.Path, "../") {
307 return anchor
308 }
309 name := strings.TrimPrefix(path.Clean("/"+target.Path), "/")
310 if name == "" {
311 return anchor
312 }
313 destination, err := url.Parse(repoURL(query, "file", name))
314 if err != nil {
315 return anchor
316 }
317 params := destination.Query()
318 for key, values := range target.Query() {
319 if !params.Has(key) {
320 params[key] = values
321 }
322 }
323 destination.RawQuery = params.Encode()
324 destination.Fragment = target.Fragment
325 return `<a href="` + html.EscapeString(destination.String()) + `">`
326 }))
327}
328
329// tagOrText splits rendered HTML into tags and the text between them.
330var tagOrText = regexp.MustCompile(`<[^>]+>|[^<]+`)
331
332// chatLinkPattern matches bare links in escaped text: http(s) URLs and
333// nostr: references, as roomLinkPattern does before escaping.
334var chatLinkPattern = regexp.MustCompile(`https?://[^\s<>"']+|(?:web\+)?nostr:[a-z0-9]+`)
335
336// autolinkOutsideTags links bare URLs and nostr: references in the text
337// between tags, leaving anything already inside a link or code alone.
338func autolinkOutsideTags(rendered string) string {
339 var out strings.Builder
340 skip := 0
341 for _, part := range tagOrText.FindAllString(rendered, -1) {
342 if strings.HasPrefix(part, "<") {
343 lower := strings.ToLower(part)
344 switch {
345 case strings.HasPrefix(lower, "<a ") || strings.HasPrefix(lower, "<code") || strings.HasPrefix(lower, "<pre") || strings.HasPrefix(lower, "<object"):
346 skip++
347 case strings.HasPrefix(lower, "</a") || strings.HasPrefix(lower, "</code") || strings.HasPrefix(lower, "</pre") || strings.HasPrefix(lower, "</object"):
348 if skip > 0 {
349 skip--
350 }
351 }
352 out.WriteString(part)
353 continue
354 }
355 if skip > 0 {
356 out.WriteString(part)
357 continue
358 }
359 last := 0
360 for _, match := range chatLinkPattern.FindAllStringIndex(part, -1) {
361 out.WriteString(part[last:match[0]])
362 link := part[match[0]:match[1]]
363 trimmed := strings.TrimRight(link, ".,;:!?)")
364 href := trimmed
365 if !strings.HasPrefix(trimmed, "http") {
366 href = "/open?target=" + template.URLQueryEscaper(html.UnescapeString(trimmed))
367 }
368 out.WriteString(`<a href="` + html.EscapeString(html.UnescapeString(href)) + `"`)
369 if strings.HasPrefix(trimmed, "http") {
370 out.WriteString(` rel="noopener"`)
371 }
372 out.WriteString(`>` + trimmed + `</a>` + link[len(trimmed):])
373 last = match[1]
374 }
375 out.WriteString(part[last:])
376 }
377 return out.String()
378}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs