1package tinyclient23import (4 "html"5 "html/template"6 "net/url"7 "path"8 "regexp"9 "strings"1011 "github.com/FelineStateMachine/tinyrelay/internal/views"12)1314// 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.17type blockRenderer func(lang, source string) (string, bool)1819// renderMarkdown turns a small, common subset of Markdown into HTML: ATX20// headings, paragraphs, tables, fenced code, unordered and ordered lists, inline21// code, emphasis, and links. Every piece of text is escaped first and only22// the tags this function emits reach the page, so untrusted README files and23// relay descriptions cannot inject markup. Anything else stays plain text.24func renderMarkdown(source string) template.HTML {25 return markdownHTML(source, false, nil)26}2728// renderMarkdownWith is renderMarkdown with fenced blocks offered to a29// custom view renderer first.30func renderMarkdownWith(source string, blocks blockRenderer) template.HTML {31 return markdownHTML(source, false, blocks)32}3334// renderChatMarkdown is the same subset for chat messages, where a single35// newline is a line break and bare http(s) and nostr: references become36// links, as people and agents type them.37func renderChatMarkdown(source string) template.HTML {38 return renderChatMarkdownWith(source, nil)39}4041func renderChatMarkdownWith(source string, blocks blockRenderer) template.HTML {42 return template.HTML(autolinkOutsideTags(string(markdownHTML(source, true, blocks))))43}4445func markdownHTML(source string, breaks bool, blocks blockRenderer) template.HTML {46 lines := strings.Split(strings.ReplaceAll(source, "\r\n", "\n"), "\n")47 var out strings.Builder48 var paragraph []string49 flush := func() {50 if len(paragraph) == 0 {51 return52 }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 = end73 case fenced:74 flush()75 var code []string76 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 continue84 }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 = 696 }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}122123var tableDelimiter = regexp.MustCompile(`^:?-{3,}:?$`)124125func 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 false130 }131 for _, cell := range delimiterCells {132 if !tableDelimiter.MatchString(strings.TrimSpace(cell)) {133 return false134 }135 }136 return true137}138139func splitTableRow(line string) ([]string, bool) {140 line = strings.TrimSpace(line)141 var cells []string142 var cell strings.Builder143 inCode := false144 hasPipe := false145 for i := 0; i < len(line); i++ {146 if line[i] == '`' {147 inCode = !inCode148 }149 if line[i] == '|' && !inCode && !escapedAt(line, i) {150 hasPipe = true151 cells = append(cells, strings.TrimSpace(cell.String()))152 cell.Reset()153 continue154 }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, hasPipe165}166167func escapedAt(value string, index int) bool {168 backslashes := 0169 for index > 0 && value[index-1] == '\\' {170 backslashes++171 index--172 }173 return backslashes%2 == 1174}175176func 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.Builder181 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 + 1187 for end+1 < len(lines) {188 cells, pipes := splitTableRow(lines[end+1])189 if !isTableBodyRow(lines[end+1], pipes) {190 break191 }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(), end207}208209func isTableBodyRow(line string, pipes bool) bool {210 if !pipes || strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {211 return false212 }213 _, _, fenced := views.OpenFence(line)214 return !fenced215}216217func 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 alignments231}232233func tableAlignAttribute(alignment string) string {234 if alignment == "" {235 return ""236 }237 return ` data-align="` + alignment + `"`238}239240func unescapeTablePipes(value string) string {241 return strings.ReplaceAll(value, `\|`, `|`)242}243244var (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)252253func isListItem(line string) bool {254 return bulletItem.MatchString(line) || orderedItem.MatchString(line)255}256257func 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}263264// inlineMarkdown escapes text, then restores the few inline forms it265// 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 value274 }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 escaped280}281282func 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}289290var markdownAnchor = regexp.MustCompile(`<a href="([^"]+)">`)291292// renderRepositoryMarkdown resolves relative README links to relay file293// 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}297298func 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 anchor305 }306 if target.Path == ".." || strings.HasPrefix(target.Path, "../") {307 return anchor308 }309 name := strings.TrimPrefix(path.Clean("/"+target.Path), "/")310 if name == "" {311 return anchor312 }313 destination, err := url.Parse(repoURL(query, "file", name))314 if err != nil {315 return anchor316 }317 params := destination.Query()318 for key, values := range target.Query() {319 if !params.Has(key) {320 params[key] = values321 }322 }323 destination.RawQuery = params.Encode()324 destination.Fragment = target.Fragment325 return `<a href="` + html.EscapeString(destination.String()) + `">`326 }))327}328329// tagOrText splits rendered HTML into tags and the text between them.330var tagOrText = regexp.MustCompile(`<[^>]+>|[^<]+`)331332// chatLinkPattern matches bare links in escaped text: http(s) URLs and333// nostr: references, as roomLinkPattern does before escaping.334var chatLinkPattern = regexp.MustCompile(`https?://[^\s<>"']+|(?:web\+)?nostr:[a-z0-9]+`)335336// autolinkOutsideTags links bare URLs and nostr: references in the text337// between tags, leaving anything already inside a link or code alone.338func autolinkOutsideTags(rendered string) string {339 var out strings.Builder340 skip := 0341 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 continue354 }355 if skip > 0 {356 out.WriteString(part)357 continue358 }359 last := 0360 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 := trimmed365 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}