tinyrelayfile
tinyrelay/tinyclient/source_render.go/

tinyclient/source_render.go raw
1package tinyclient
2
3import (
4 "html"
5 "html/template"
6 "path/filepath"
7 "strings"
8 "unicode"
9 "unicode/utf8"
10)
11
12const sourcePreviewLimit = 256 * 1024
13const sourcePreviewLines = 10000
14
15// 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, false
20 }
21 content = strings.TrimSuffix(content, "\n")
22 lines := strings.SplitN(content, "\n", sourcePreviewLines+1)
23 if len(lines) > sourcePreviewLines {
24 return lines[:sourcePreviewLines], true
25 }
26 return lines, false
27}
28
29// sourceHTML renders a bounded source preview. It returns template.HTML only
30// after escaping every source byte and constructing the surrounding markup.
31// The markup is wrapped in a <source-view lang="..."> element and uses only
32// 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) > sourcePreviewLimit
43 if truncated {
44 content = boundedContent(content)
45 }
46 lines, linesTruncated := previewLines(content)
47 var out strings.Builder
48 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}
71
72// diffHTML renders a line-oriented diff without interpreting its content.
73// Added lines are wrapped in <ins>, removed lines in <del>, and hunk headers
74// 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 = text
81 }
82 }
83 providedTruncated, _ := m["truncated"].(bool)
84 truncated := providedTruncated || len(diff) > sourcePreviewLimit
85 if truncated {
86 diff = boundedContent(diff)
87 }
88 lines, linesTruncated := previewLines(diff)
89 var out strings.Builder
90 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}
119
120func boundedContent(content string) string {
121 if len(content) <= sourcePreviewLimit {
122 return content
123 }
124 content = content[:sourcePreviewLimit]
125 for len(content) > 0 && !utf8.ValidString(content) {
126 content = content[:len(content)-1]
127 }
128 return content
129}
130
131func 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}
157
158type highlightState struct{ blockComment bool }
159
160func 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 return
167 }
168 writeToken(out, "comment", line[:end+2])
169 line = line[end+2:]
170 state.blockComment = false
171 continue
172 }
173 if strings.HasPrefix(line, "/*") {
174 end := strings.Index(line[2:], "*/")
175 if end < 0 {
176 state.blockComment = true
177 writeToken(out, "comment", line)
178 return
179 }
180 end += 4
181 writeToken(out, "comment", line[:end])
182 line = line[end:]
183 continue
184 }
185 if prefix := commentPrefix(line, language); prefix != "" {
186 writeToken(out, "comment", line)
187 return
188 }
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 continue
195 }
196 value, size := utf8.DecodeRuneInString(line)
197 if isWordStart(value) {
198 end := size
199 for end < len(line) {
200 next, width := utf8.DecodeRuneInString(line[end:])
201 if !isWordPart(next) {
202 break
203 }
204 end += width
205 }
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 continue
214 }
215 if unicode.IsDigit(value) {
216 end := size
217 for end < len(line) {
218 next, width := utf8.DecodeRuneInString(line[end:])
219 if !unicode.IsDigit(next) && !strings.ContainsRune("._", next) {
220 break
221 }
222 end += width
223 }
224 writeToken(out, "number", line[:end])
225 line = line[end:]
226 continue
227 }
228 writeToken(out, "", line[:size])
229 line = line[size:]
230 }
231}
232
233// tokenElements maps token kinds to the semantic element that carries them.
234var tokenElements = map[string]string{"keyword": "b", "comment": "i", "string": "q", "number": "var"}
235
236func writeToken(out *strings.Builder, kind, value string) {
237 writeWrapped(out, tokenElements[kind], value)
238}
239
240// writeWrapped escapes value and, when element is not empty, wraps it in that
241// 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}
255
256func quotedEnd(line string, quote byte) int {
257 for index := 1; index < len(line); index++ {
258 if line[index] == '\\' {
259 index++
260 continue
261 }
262 if line[index] == quote {
263 return index + 1
264 }
265 }
266 return len(line)
267}
268
269func 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}
278
279func isWordStart(value rune) bool { return unicode.IsLetter(value) || value == '_' }
280func isWordPart(value rune) bool {
281 return unicode.IsLetter(value) || unicode.IsDigit(value) || value == '_'
282}
283
284func 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 false
294 }
295}
296
297func containsWord(word, list string) bool {
298 for _, candidate := range strings.Fields(list) {
299 if candidate == word {
300 return true
301 }
302 }
303 return false
304}
305
306func writeUint(out *strings.Builder, value int) {
307 if value == 0 {
308 out.WriteByte('0')
309 return
310 }
311 var digits [20]byte
312 index := len(digits)
313 for value > 0 {
314 index--
315 digits[index] = byte('0' + value%10)
316 value /= 10
317 }
318 out.Write(digits[index:])
319}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs