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