views.go
·
342 строки
·
8.9 KB
| 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 |