| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 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. |
| 17 | type 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. |
| 24 | func 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. |
| 30 | func 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. |
| 37 | func renderChatMarkdown(source string) template.HTML { |
| 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)))) |
| 43 | } |
| 44 | |
| 45 | func 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 | |
| 123 | var tableDelimiter = regexp.MustCompile(`^:?-{3,}:?$` ) |
| 124 | |
| 125 | func 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 | |
| 139 | func 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 | |
| 167 | func 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 | |
| 176 | func 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 | |
| 209 | func 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 | |
| 217 | func 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 | |
| 233 | func tableAlignAttribute(alignment string) string { |
| 234 | if alignment == "" { |
| 235 | return "" |
| 236 | } |
| 237 | return ` data-align="` + alignment + `"` |
| 238 | } |
| 239 | |
| 240 | func unescapeTablePipes(value string) string { |
| 241 | return strings.ReplaceAll(value, `\|` , `|` ) |
| 242 | } |
| 243 | |
| 244 | var ( |
| 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 | |
| 253 | func isListItem(line string) bool { |
| 254 | return bulletItem.MatchString(line) || orderedItem.MatchString(line) |
| 255 | } |
| 256 | |
| 257 | func 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. |
| 267 | func 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 | |
| 282 | func 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 | |
| 290 | var 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. |
| 294 | func renderRepositoryMarkdown(source string, query url.Values) template.HTML { |
| 295 | return renderRepositoryMarkdownWith(source, query, nil) |
| 296 | } |
| 297 | |
| 298 | func 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. |
| 330 | var tagOrText = regexp.MustCompile(`<[^>]+>|[^<]+` ) |
| 331 | |
| 332 | // chatLinkPattern matches bare links in escaped text: http(s) URLs and |
| 333 | // nostr: references, as roomLinkPattern does before escaping. |
| 334 | var 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. |
| 338 | func 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 | } |