package views import ( "bytes" "embed" "fmt" "html" "html/template" "io" "strings" "time" "polar/internal/i18n" "polar/internal/web/static" "github.com/alecthomas/chroma/v2" chromahtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" "github.com/microcosm-cc/bluemonday" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" ) //go:embed layouts/*.html partials/*.html pages/*.html var templatesFS embed.FS type HighlightedLine struct { Num int HTML template.HTML } type Engine struct { pages map[string]*template.Template } func NewEngine() (*Engine, error) { funcMap := template.FuncMap{ "icon": renderIcon, "sprite": func() template.HTML { return template.HTML(static.SpriteSVG) }, "inlineCSS": func() template.CSS { return template.CSS(static.AppCSS) }, "assetVer": static.AssetVersion, "t": i18n.T, "timeAgo": formatTimeAgo, "pluralT": pluralT, "activityBadge": activityBadge, "formatDate": func(tm time.Time) string { return tm.UTC().Format("2006-01-02 15:04 UTC") }, "formatShortDate": func(tm time.Time) string { return tm.UTC().Format("2006-01-02") }, "formatSize": formatBytes, "avatarInitial": avatarInitial, "avatar": renderAvatar, "renderMD": func(s string) template.HTML { return RenderMarkdown([]byte(s)) }, "add": func(a, b int) int { return a + b }, "sub": func(a, b int) int { return a - b }, "dict": makeDict, "safeHTML": func(s string) template.HTML { return template.HTML(s) }, "upper": strings.ToUpper, "lower": strings.ToLower, } entries, err := templatesFS.ReadDir("pages") if err != nil { return nil, fmt.Errorf("read pages dir: %w", err) } pages := make(map[string]*template.Template, len(entries)) for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".html") { continue } name := strings.TrimSuffix(entry.Name(), ".html") tmpl, err := template.New("base.html").Funcs(funcMap).ParseFS( templatesFS, "layouts/base.html", "partials/components.html", "pages/"+entry.Name(), ) if err != nil { return nil, fmt.Errorf("parse template %s: %w", entry.Name(), err) } pages[name] = tmpl } return &Engine{pages: pages}, nil } func (e *Engine) Render(w io.Writer, page string, data any) error { tmpl, ok := e.pages[page] if !ok { return fmt.Errorf("template %q not found", page) } return tmpl.ExecuteTemplate(w, "base.html", data) } func renderIcon(name string, classes ...string) template.HTML { cls := "size-4" if len(classes) > 0 && strings.TrimSpace(classes[0]) != "" { cls = strings.Join(classes, " ") } safeName := html.EscapeString(strings.TrimSpace(name)) safeCls := html.EscapeString(cls) return template.HTML(fmt.Sprintf( ``, safeCls, safeName, )) } func makeDict(values ...any) (map[string]any, error) { if len(values)%2 != 0 { return nil, fmt.Errorf("dict requires an even number of arguments") } m := make(map[string]any, len(values)/2) for i := 0; i < len(values); i += 2 { k, ok := values[i].(string) if !ok { return nil, fmt.Errorf("dict keys must be strings") } m[k] = values[i+1] } return m, nil } func avatarInitial(username string) string { username = strings.TrimSpace(username) if username == "" { return "?" } runes := []rune(username) return strings.ToUpper(string(runes[0])) } func renderAvatar(args ...any) template.HTML { if len(args) == 0 { return template.HTML(`?`) } var username, avatarURL, extraClass string if args[0] != nil { username = fmt.Sprint(args[0]) } if len(args) > 1 && args[1] != nil { avatarURL = fmt.Sprint(args[1]) } if len(args) > 2 { var classes []string for _, c := range args[2:] { if c != nil && fmt.Sprint(c) != "" { classes = append(classes, fmt.Sprint(c)) } } extraClass = strings.Join(classes, " ") } if strings.HasPrefix(avatarURL, "avatar-") && extraClass == "" { extraClass = avatarURL avatarURL = "" } cls := "avatar" if extraClass != "" { cls = "avatar " + extraClass } safeCls := html.EscapeString(cls) safeUser := html.EscapeString(strings.TrimSpace(username)) avatarURL = strings.TrimSpace(avatarURL) if avatarURL != "" && (strings.HasPrefix(avatarURL, "data:image/") || strings.HasPrefix(avatarURL, "http://") || strings.HasPrefix(avatarURL, "https://") || strings.HasPrefix(avatarURL, "/")) { safeURL := html.EscapeString(avatarURL) return template.HTML(fmt.Sprintf( `%s`, safeCls, safeUser, safeURL, safeUser, )) } return template.HTML(fmt.Sprintf( `%s`, safeCls, safeUser, html.EscapeString(avatarInitial(username)), )) } func formatBytes(b int64) string { if b < 1024 { return fmt.Sprintf("%d B", b) } kb := float64(b) / 1024.0 if kb < 1024 { return fmt.Sprintf("%.1f KB", kb) } mb := kb / 1024.0 return fmt.Sprintf("%.1f MB", mb) } func formatTimeAgo(tm time.Time, lang i18n.Lang) string { if tm.IsZero() { return "—" } d := time.Since(tm) isEN := i18n.Normalize(string(lang)) == i18n.EN switch { case d < time.Minute: if isEN { return "just now" } return "только что" case d < time.Hour: m := int(d.Minutes()) if isEN { return fmt.Sprintf("%dm ago", m) } return fmt.Sprintf("%d мин. назад", m) case d < 24*time.Hour: h := int(d.Hours()) if isEN { return fmt.Sprintf("%dh ago", h) } return fmt.Sprintf("%d ч. назад", h) case d < 30*24*time.Hour: days := int(d.Hours() / 24) if isEN { return fmt.Sprintf("%dd ago", days) } return fmt.Sprintf("%d дн. назад", days) default: return tm.UTC().Format("2006-01-02") } } // pluralT returns the count-dependent form of key ("_one", "_few", // "_many") for the given language, falling back to the base key. func pluralT(lang i18n.Lang, n int, key string) string { suffix := "_many" if i18n.Normalize(string(lang)) == i18n.EN { if n == 1 { suffix = "_one" } } else { mod10, mod100 := n%10, n%100 switch { case mod10 == 1 && mod100 != 11: suffix = "_one" case mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14): suffix = "_few" } } k := key + suffix if v := i18n.T(lang, k); v != k { return v } return i18n.T(lang, key) } // activityBadge localizes the status badge of an activity feed entry. func activityBadge(lang i18n.Lang, itemType, badge string) string { switch itemType { case "commit": return i18n.T(lang, "activity.badge_commit") case "release": return i18n.T(lang, "activity.badge_release") case "pull": return i18n.T(lang, "pulls.status."+badge) case "issue": return i18n.T(lang, "issue.status."+badge) default: return badge } } var mdParser = goldmark.New( goldmark.WithExtensions( extension.GFM, ), goldmark.WithParserOptions( parser.WithAutoHeadingID(), ), ) func RenderMarkdown(src []byte) template.HTML { var buf bytes.Buffer if err := mdParser.Convert(src, &buf); err != nil { return template.HTML("
" + html.EscapeString(string(src)) + "
") } policy := bluemonday.UGCPolicy() policy.AllowAttrs("class").OnElements("code", "pre", "span") policy.AllowAttrs("type", "checked", "disabled").OnElements("input") sanitized := policy.SanitizeBytes(buf.Bytes()) return template.HTML(sanitized) } func HighlightCode(filename, code string) []HighlightedLine { code = strings.ReplaceAll(code, "\r\n", "\n") lexer := lexers.Match(filename) if lexer == nil { lexer = lexers.Fallback } lexer = chroma.Coalesce(lexer) formatter := chromahtml.New( chromahtml.WithClasses(true), chromahtml.PreventSurroundingPre(true), ) style := styles.Get("monokai") if style == nil { style = styles.Fallback } rawLines := strings.Split(code, "\n") if len(rawLines) > 0 && rawLines[len(rawLines)-1] == "" { rawLines = rawLines[:len(rawLines)-1] } result := make([]HighlightedLine, 0, len(rawLines)) iterator, err := lexer.Tokenise(nil, code) if err != nil { for idx, l := range rawLines { result = append(result, HighlightedLine{ Num: idx + 1, HTML: template.HTML(html.EscapeString(l)), }) } return result } var buf bytes.Buffer if err := formatter.Format(&buf, style, iterator); err != nil { for idx, l := range rawLines { result = append(result, HighlightedLine{ Num: idx + 1, HTML: template.HTML(html.EscapeString(l)), }) } return result } htmlLines := strings.Split(buf.String(), "\n") if len(htmlLines) > 0 && htmlLines[len(htmlLines)-1] == "" { htmlLines = htmlLines[:len(htmlLines)-1] } for idx, hl := range htmlLines { result = append(result, HighlightedLine{ Num: idx + 1, HTML: template.HTML(hl), }) } return result }