clone

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

main
tinyrelay / tinyclient / source_render.go
source_render.go 8.2K
1package tinyclient23import (4 "html"5 "html/template"6 "path/filepath"7 "strings"8 "unicode"9 "unicode/utf8"10)1112const sourcePreviewLimit = 256 * 102413const sourcePreviewLines = 100001415// A byte bound alone permits hundreds of thousands of empty table rows.16// Bound the preview markup separately; raw downloads remain complete.17func previewLines(content string) ([]string, bool) {18 if content == "" {19 return nil, false20 }21 content = strings.TrimSuffix(content, "\n")22 lines := strings.SplitN(content, "\n", sourcePreviewLines+1)23 if len(lines) > sourcePreviewLines {24 return lines[:sourcePreviewLines], true25 }26 return lines, false27}2829// sourceHTML renders a bounded source preview. It returns template.HTML only30// after escaping every source byte and constructing the surrounding markup.31// The markup is wrapped in a <source-view lang="..."> element and uses only32// semantic elements so styling needs no class hooks.33func sourceHTML(value any) template.HTML {34 m := valueMap(value)35 content, _ := m["content"].(string)36 name, _ := m["path"].(string)37 if name == "" {38 name, _ = m["name"].(string)39 }40 language := sourceLanguage(name)41 providedTruncated, _ := m["truncated"].(bool)42 truncated := providedTruncated || len(content) > sourcePreviewLimit43 if truncated {44 content = boundedContent(content)45 }46 lines, linesTruncated := previewLines(content)47 var out strings.Builder48 out.Grow(len(content) + len(lines)*120)49 out.WriteString(`<source-view lang="`)50 out.WriteString(language)51 out.WriteString(`"><table><tbody>`)52 state := highlightState{}53 for number, line := range lines {54 out.WriteString(`<tr id="L`)55 writeUint(&out, number+1)56 out.WriteString(`"><td><a href="#L`)57 writeUint(&out, number+1)58 out.WriteString(`">`)59 writeUint(&out, number+1)60 out.WriteString(`</a></td><td><code>`)61 highlightLine(&out, line, language, &state)62 out.WriteString(`</code></td></tr>`)63 }64 out.WriteString(`</tbody>`)65 if truncated || linesTruncated {66 out.WriteString(`<tfoot><tr><td colspan="2">Preview truncated (256 KiB or 10,000 lines). Use the raw/download control to view the complete file.</td></tr></tfoot>`)67 }68 out.WriteString(`</table></source-view>`)69 return template.HTML(out.String())70}7172// diffHTML renders a line-oriented diff without interpreting its content.73// Added lines are wrapped in <ins>, removed lines in <del>, and hunk headers74// in <b>; context lines carry no wrapper.75func diffHTML(value any) template.HTML {76 m := valueMap(value)77 diff, _ := m["diff"].(string)78 if diff == "" {79 if text, ok := value.(string); ok {80 diff = text81 }82 }83 providedTruncated, _ := m["truncated"].(bool)84 truncated := providedTruncated || len(diff) > sourcePreviewLimit85 if truncated {86 diff = boundedContent(diff)87 }88 lines, linesTruncated := previewLines(diff)89 var out strings.Builder90 out.Grow(len(diff) + len(lines)*140)91 out.WriteString(`<diff-view><table><tbody>`)92 for number, line := range lines {93 wrapper := ""94 switch {95 case strings.HasPrefix(line, "@@"):96 wrapper = "b"97 case strings.HasPrefix(line, "+"):98 wrapper = "ins"99 case strings.HasPrefix(line, "-"):100 wrapper = "del"101 }102 out.WriteString(`<tr id="D`)103 writeUint(&out, number+1)104 out.WriteString(`"><td><a href="#D`)105 writeUint(&out, number+1)106 out.WriteString(`">`)107 writeUint(&out, number+1)108 out.WriteString(`</a></td><td><code>`)109 writeWrapped(&out, wrapper, line)110 out.WriteString(`</code></td></tr>`)111 }112 out.WriteString(`</tbody>`)113 if truncated || linesTruncated {114 out.WriteString(`<tfoot><tr><td colspan="2">Diff preview truncated (256 KiB or 10,000 lines).</td></tr></tfoot>`)115 }116 out.WriteString(`</table></diff-view>`)117 return template.HTML(out.String())118}119120func boundedContent(content string) string {121 if len(content) <= sourcePreviewLimit {122 return content123 }124 content = content[:sourcePreviewLimit]125 for len(content) > 0 && !utf8.ValidString(content) {126 content = content[:len(content)-1]127 }128 return content129}130131func sourceLanguage(name string) string {132 switch strings.ToLower(filepath.Ext(name)) {133 case ".go":134 return "go"135 case ".js", ".jsx":136 return "javascript"137 case ".ts", ".tsx":138 return "typescript"139 case ".css":140 return "css"141 case ".html", ".htm":142 return "html"143 case ".json":144 return "json"145 case ".md", ".markdown":146 return "markdown"147 case ".sh", ".bash":148 return "shell"149 case ".py":150 return "python"151 case ".sql":152 return "sql"153 default:154 return "text"155 }156}157158type highlightState struct{ blockComment bool }159160func highlightLine(out *strings.Builder, line, language string, state *highlightState) {161 for len(line) > 0 {162 if state.blockComment {163 end := strings.Index(line, "*/")164 if end < 0 {165 writeToken(out, "comment", line)166 return167 }168 writeToken(out, "comment", line[:end+2])169 line = line[end+2:]170 state.blockComment = false171 continue172 }173 if strings.HasPrefix(line, "/*") {174 end := strings.Index(line[2:], "*/")175 if end < 0 {176 state.blockComment = true177 writeToken(out, "comment", line)178 return179 }180 end += 4181 writeToken(out, "comment", line[:end])182 line = line[end:]183 continue184 }185 if prefix := commentPrefix(line, language); prefix != "" {186 writeToken(out, "comment", line)187 return188 }189 if line[0] == '\'' || line[0] == '"' || line[0] == '`' {190 quote := line[0]191 end := quotedEnd(line, quote)192 writeToken(out, "string", line[:end])193 line = line[end:]194 continue195 }196 value, size := utf8.DecodeRuneInString(line)197 if isWordStart(value) {198 end := size199 for end < len(line) {200 next, width := utf8.DecodeRuneInString(line[end:])201 if !isWordPart(next) {202 break203 }204 end += width205 }206 word := line[:end]207 if keyword(language, word) {208 writeToken(out, "keyword", word)209 } else {210 writeToken(out, "", word)211 }212 line = line[end:]213 continue214 }215 if unicode.IsDigit(value) {216 end := size217 for end < len(line) {218 next, width := utf8.DecodeRuneInString(line[end:])219 if !unicode.IsDigit(next) && !strings.ContainsRune("._", next) {220 break221 }222 end += width223 }224 writeToken(out, "number", line[:end])225 line = line[end:]226 continue227 }228 writeToken(out, "", line[:size])229 line = line[size:]230 }231}232233// tokenElements maps token kinds to the semantic element that carries them.234var tokenElements = map[string]string{"keyword": "b", "comment": "i", "string": "q", "number": "var"}235236func writeToken(out *strings.Builder, kind, value string) {237 writeWrapped(out, tokenElements[kind], value)238}239240// writeWrapped escapes value and, when element is not empty, wraps it in that241// attribute-free element.242func writeWrapped(out *strings.Builder, element, value string) {243 if element != "" {244 out.WriteByte('<')245 out.WriteString(element)246 out.WriteByte('>')247 }248 out.WriteString(html.EscapeString(value))249 if element != "" {250 out.WriteString("</")251 out.WriteString(element)252 out.WriteByte('>')253 }254}255256func quotedEnd(line string, quote byte) int {257 for index := 1; index < len(line); index++ {258 if line[index] == '\\' {259 index++260 continue261 }262 if line[index] == quote {263 return index + 1264 }265 }266 return len(line)267}268269func commentPrefix(line, language string) string {270 if strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") {271 return line[:1]272 }273 if language == "sql" && strings.HasPrefix(line, "--") {274 return "--"275 }276 return ""277}278279func isWordStart(value rune) bool { return unicode.IsLetter(value) || value == '_' }280func isWordPart(value rune) bool {281 return unicode.IsLetter(value) || unicode.IsDigit(value) || value == '_'282}283284func keyword(language, word string) bool {285 switch language {286 case "go":287 return containsWord(word, "package import func type struct interface var const if else for range return go defer switch case select chan map")288 case "javascript", "typescript":289 return containsWord(word, "const let var function return if else for class interface type import export async await")290 case "python":291 return containsWord(word, "def class return if else for import from in with")292 default:293 return false294 }295}296297func containsWord(word, list string) bool {298 for _, candidate := range strings.Fields(list) {299 if candidate == word {300 return true301 }302 }303 return false304}305306func writeUint(out *strings.Builder, value int) {307 if value == 0 {308 out.WriteByte('0')309 return310 }311 var digits [20]byte312 index := len(digits)313 for value > 0 {314 index--315 digits[index] = byte('0' + value%10)316 value /= 10317 }318 out.Write(digits[index:])319}