rin
Публичный Open-source lightweight git hosting.
views.go · 353 строки · 9.3 KB
Raw
1 package views
2
3 import (
4 "bytes"
5 "embed"
6 "fmt"
7 "html"
8 "html/template"
9 "io"
10 "strings"
11 "time"
12
13 "polar/internal/i18n"
14 "polar/internal/web/static"
15
16 "github.com/alecthomas/chroma/v2"
17 chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
18 "github.com/alecthomas/chroma/v2/lexers"
19 "github.com/alecthomas/chroma/v2/styles"
20 "github.com/microcosm-cc/bluemonday"
21 "github.com/yuin/goldmark"
22 "github.com/yuin/goldmark/extension"
23 "github.com/yuin/goldmark/parser"
24 )
25
26 //go:embed layouts/*.html partials/*.html pages/*.html
27 var templatesFS embed.FS
28
29 type HighlightedLine struct {
30 Num int
31 HTML template.HTML
32 }
33
34 type Engine struct {
35 pages map[string]*template.Template
36 }
37
38 func NewEngine() (*Engine, error) {
39 funcMap := template.FuncMap{
40 "icon": renderIcon,
41 "sprite": func() template.HTML { return template.HTML(static.SpriteSVG) },
42 "inlineCSS": func() template.CSS { return template.CSS(static.AppCSS) },
43 "assetVer": static.AssetVersion,
44 "t": i18n.T,
45 "timeAgo": formatTimeAgo,
46 "pluralT": pluralT,
47 "activityBadge": activityBadge,
48 "formatDate": func(tm time.Time) string { return tm.UTC().Format("2006-01-02 15:04 UTC") },
49 "formatShortDate": func(tm time.Time) string { return tm.UTC().Format("2006-01-02") },
50 "formatSize": formatBytes,
51 "avatarInitial": avatarInitial,
52 "avatar": renderAvatar,
53 "imageURL": renderImageURL,
54 "renderMD": func(s string) template.HTML { return RenderMarkdown([]byte(s)) },
55 "add": func(a, b int) int { return a + b },
56 "sub": func(a, b int) int { return a - b },
57 "dict": makeDict,
58 "safeHTML": func(s string) template.HTML { return template.HTML(s) },
59 "upper": strings.ToUpper,
60 "lower": strings.ToLower,
61 }
62
63 entries, err := templatesFS.ReadDir("pages")
64 if err != nil {
65 return nil, fmt.Errorf("read pages dir: %w", err)
66 }
67
68 pages := make(map[string]*template.Template, len(entries))
69 for _, entry := range entries {
70 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".html") {
71 continue
72 }
73 name := strings.TrimSuffix(entry.Name(), ".html")
74 tmpl, err := template.New("base.html").Funcs(funcMap).ParseFS(
75 templatesFS,
76 "layouts/base.html",
77 "partials/components.html",
78 "pages/"+entry.Name(),
79 )
80 if err != nil {
81 return nil, fmt.Errorf("parse template %s: %w", entry.Name(), err)
82 }
83 pages[name] = tmpl
84 }
85
86 return &Engine{pages: pages}, nil
87 }
88
89 func (e *Engine) Render(w io.Writer, page string, data any) error {
90 tmpl, ok := e.pages[page]
91 if !ok {
92 return fmt.Errorf("template %q not found", page)
93 }
94 return tmpl.ExecuteTemplate(w, "base.html", data)
95 }
96
97 func renderIcon(name string, classes ...string) template.HTML {
98 cls := "size-4"
99 if len(classes) > 0 && strings.TrimSpace(classes[0]) != "" {
100 cls = strings.Join(classes, " ")
101 }
102 safeName := html.EscapeString(strings.TrimSpace(name))
103 safeCls := html.EscapeString(cls)
104 return template.HTML(fmt.Sprintf(
105 `<svg class="icon %s" aria-hidden="true"><use href="#hi-%s"></use></svg>`,
106 safeCls, safeName,
107 ))
108 }
109
110 func makeDict(values ...any) (map[string]any, error) {
111 if len(values)%2 != 0 {
112 return nil, fmt.Errorf("dict requires an even number of arguments")
113 }
114 m := make(map[string]any, len(values)/2)
115 for i := 0; i < len(values); i += 2 {
116 k, ok := values[i].(string)
117 if !ok {
118 return nil, fmt.Errorf("dict keys must be strings")
119 }
120 m[k] = values[i+1]
121 }
122 return m, nil
123 }
124
125 func avatarInitial(username string) string {
126 username = strings.TrimSpace(username)
127 if username == "" {
128 return "?"
129 }
130 runes := []rune(username)
131 return strings.ToUpper(string(runes[0]))
132 }
133
134 func renderImageURL(value string) template.URL {
135 value = strings.TrimSpace(value)
136 for _, prefix := range []string{"data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64,", "data:image/gif;base64,", "/", "https://", "http://"} {
137 if strings.HasPrefix(value, prefix) {
138 return template.URL(value)
139 }
140 }
141 return ""
142 }
143
144 func renderAvatar(args ...any) template.HTML {
145 if len(args) == 0 {
146 return template.HTML(`<span class="avatar">?</span>`)
147 }
148 var username, avatarURL, extraClass string
149 if args[0] != nil {
150 username = fmt.Sprint(args[0])
151 }
152 if len(args) > 1 && args[1] != nil {
153 avatarURL = fmt.Sprint(args[1])
154 }
155 if len(args) > 2 {
156 var classes []string
157 for _, c := range args[2:] {
158 if c != nil && fmt.Sprint(c) != "" {
159 classes = append(classes, fmt.Sprint(c))
160 }
161 }
162 extraClass = strings.Join(classes, " ")
163 }
164 if strings.HasPrefix(avatarURL, "avatar-") && extraClass == "" {
165 extraClass = avatarURL
166 avatarURL = ""
167 }
168
169 cls := "avatar"
170 if extraClass != "" {
171 cls = "avatar " + extraClass
172 }
173 safeCls := html.EscapeString(cls)
174 safeUser := html.EscapeString(strings.TrimSpace(username))
175 avatarURL = strings.TrimSpace(avatarURL)
176 if avatarURL != "" && (strings.HasPrefix(avatarURL, "data:image/") || strings.HasPrefix(avatarURL, "http://") || strings.HasPrefix(avatarURL, "https://") || strings.HasPrefix(avatarURL, "/")) {
177 safeURL := html.EscapeString(avatarURL)
178 return template.HTML(fmt.Sprintf(
179 `<span class="%s" title="%s"><img src="%s" alt="%s" class="avatar-img" loading="lazy"></span>`,
180 safeCls, safeUser, safeURL, safeUser,
181 ))
182 }
183 return template.HTML(fmt.Sprintf(
184 `<span class="%s" title="%s">%s</span>`,
185 safeCls, safeUser, html.EscapeString(avatarInitial(username)),
186 ))
187 }
188
189 func formatBytes(b int64) string {
190 if b < 1024 {
191 return fmt.Sprintf("%d B", b)
192 }
193 kb := float64(b) / 1024.0
194 if kb < 1024 {
195 return fmt.Sprintf("%.1f KB", kb)
196 }
197 mb := kb / 1024.0
198 return fmt.Sprintf("%.1f MB", mb)
199 }
200
201 func formatTimeAgo(tm time.Time, lang i18n.Lang) string {
202 if tm.IsZero() {
203 return "—"
204 }
205 d := time.Since(tm)
206 isEN := i18n.Normalize(string(lang)) == i18n.EN
207 switch {
208 case d < time.Minute:
209 if isEN {
210 return "just now"
211 }
212 return "только что"
213 case d < time.Hour:
214 m := int(d.Minutes())
215 if isEN {
216 return fmt.Sprintf("%dm ago", m)
217 }
218 return fmt.Sprintf("%d мин. назад", m)
219 case d < 24*time.Hour:
220 h := int(d.Hours())
221 if isEN {
222 return fmt.Sprintf("%dh ago", h)
223 }
224 return fmt.Sprintf("%d ч. назад", h)
225 case d < 30*24*time.Hour:
226 days := int(d.Hours() / 24)
227 if isEN {
228 return fmt.Sprintf("%dd ago", days)
229 }
230 return fmt.Sprintf("%d дн. назад", days)
231 default:
232 return tm.UTC().Format("2006-01-02")
233 }
234 }
235
236 // pluralT returns the count-dependent form of key ("<key>_one", "<key>_few",
237 // "<key>_many") for the given language, falling back to the base key.
238 func pluralT(lang i18n.Lang, n int, key string) string {
239 suffix := "_many"
240 if i18n.Normalize(string(lang)) == i18n.EN {
241 if n == 1 {
242 suffix = "_one"
243 }
244 } else {
245 mod10, mod100 := n%10, n%100
246 switch {
247 case mod10 == 1 && mod100 != 11:
248 suffix = "_one"
249 case mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14):
250 suffix = "_few"
251 }
252 }
253 k := key + suffix
254 if v := i18n.T(lang, k); v != k {
255 return v
256 }
257 return i18n.T(lang, key)
258 }
259
260 // activityBadge localizes the status badge of an activity feed entry.
261 func activityBadge(lang i18n.Lang, itemType, badge string) string {
262 switch itemType {
263 case "commit":
264 return i18n.T(lang, "activity.badge_commit")
265 case "release":
266 return i18n.T(lang, "activity.badge_release")
267 case "pull":
268 return i18n.T(lang, "pulls.status."+badge)
269 case "issue":
270 return i18n.T(lang, "issue.status."+badge)
271 default:
272 return badge
273 }
274 }
275
276 var mdParser = goldmark.New(
277 goldmark.WithExtensions(
278 extension.GFM,
279 ),
280 goldmark.WithParserOptions(
281 parser.WithAutoHeadingID(),
282 ),
283 )
284
285 func RenderMarkdown(src []byte) template.HTML {
286 var buf bytes.Buffer
287 if err := mdParser.Convert(src, &buf); err != nil {
288 return template.HTML("<pre>" + html.EscapeString(string(src)) + "</pre>")
289 }
290 policy := bluemonday.UGCPolicy()
291 policy.AllowAttrs("class").OnElements("code", "pre", "span")
292 policy.AllowAttrs("type", "checked", "disabled").OnElements("input")
293 sanitized := policy.SanitizeBytes(buf.Bytes())
294 return template.HTML(sanitized)
295 }
296
297 func HighlightCode(filename, code string) []HighlightedLine {
298 code = strings.ReplaceAll(code, "\r\n", "\n")
299 lexer := lexers.Match(filename)
300 if lexer == nil {
301 lexer = lexers.Fallback
302 }
303 lexer = chroma.Coalesce(lexer)
304
305 formatter := chromahtml.New(
306 chromahtml.WithClasses(true),
307 chromahtml.PreventSurroundingPre(true),
308 )
309 style := styles.Get("monokai")
310 if style == nil {
311 style = styles.Fallback
312 }
313
314 rawLines := strings.Split(code, "\n")
315 if len(rawLines) > 0 && rawLines[len(rawLines)-1] == "" {
316 rawLines = rawLines[:len(rawLines)-1]
317 }
318
319 result := make([]HighlightedLine, 0, len(rawLines))
320 iterator, err := lexer.Tokenise(nil, code)
321 if err != nil {
322 for idx, l := range rawLines {
323 result = append(result, HighlightedLine{
324 Num: idx + 1,
325 HTML: template.HTML(html.EscapeString(l)),
326 })
327 }
328 return result
329 }
330
331 var buf bytes.Buffer
332 if err := formatter.Format(&buf, style, iterator); err != nil {
333 for idx, l := range rawLines {
334 result = append(result, HighlightedLine{
335 Num: idx + 1,
336 HTML: template.HTML(html.EscapeString(l)),
337 })
338 }
339 return result
340 }
341
342 htmlLines := strings.Split(buf.String(), "\n")
343 if len(htmlLines) > 0 && htmlLines[len(htmlLines)-1] == "" {
344 htmlLines = htmlLines[:len(htmlLines)-1]
345 }
346 for idx, hl := range htmlLines {
347 result = append(result, HighlightedLine{
348 Num: idx + 1,
349 HTML: template.HTML(hl),
350 })
351 }
352 return result
353 }
354