// Package templates holds the templ components and their view-models. View-models // are plain structs assembled by the server layer; templ files render them. package templates import ( "fmt" "html/template" "strings" "time" "custard/internal/backlog" "custard/internal/gitread" "custard/internal/license" "custard/internal/render" ) // Meta is the shared chrome data every page needs. type Meta struct { Title string Repo string // current repo name, "" on the index Ref string // current ref, when relevant HasIssues bool // show the issues tab only when backlog tasks exist HasReadme bool // show the readme tab only when a README exists Theme string // active data-theme, resolved from cookie (default flexoki) Tab string // active repo tab: code | readme | log | refs | issues CloneURL string // read-only HTTP clone URL, shown in the footer bar License *license.License // detected repo license, shown as a badge } // LicenseCategoryColor maps a license category to a theme color family. func LicenseCategoryColor(cat string) string { switch cat { case "permissive": return "green" case "public-domain": return "cyan" case "weak-copyleft": return "yellow" case "copyleft": return "orange" case "cc": return "purple" default: return "accent" } } // Families is what the picker offers: a palette choice only. Light vs dark is // resolved from the OS (prefers-color-scheme), not chosen here. E-ink is its own // fixed mode. The cookie stores one of these; the client resolves the actual // data-theme (e.g. flexoki → flexoki-dark) before paint. var Families = []string{"flexoki", "uchu", "humdrum", "eink"} // DefaultTheme (family) applies when no valid cookie is present. const DefaultTheme = "flexoki" // ValidTheme returns t if it is a known family, else DefaultTheme. func ValidTheme(t string) string { for _, k := range Families { if k == t { return t } } return DefaultTheme } // Crumb is one breadcrumb segment with the cumulative path up to it. type Crumb struct { Name string Path string } // IndexPage lists all repositories. type IndexPage struct { Meta Meta Repos []gitread.Repo } // RepoPage is the repo home (code tab): the root file tree plus a ref summary. // The README lives on its own tab, not here. type RepoPage struct { Meta Meta DefaultBranch string Entries []gitread.Entry Last *gitread.Commit Branches int Tags int } // ReadmePage renders a repo's README on its own tab. type ReadmePage struct { Meta Meta Readme template.HTML } // TreePage browses a directory at a ref. type TreePage struct { Meta Meta Path string Crumbs []Crumb Entries []gitread.Entry } // BlobPage shows one file. type BlobPage struct { Meta Meta Path string Crumbs []Crumb Size int64 IsMarkdown bool Markdown template.HTML Frontmatter []render.FMPair // YAML frontmatter of a Markdown file, if any Code template.HTML IsBinary bool } // LogPage is a commit list for a ref. type LogPage struct { Meta Meta Commits []gitread.Commit } // CommitPage shows a single commit and its diff, split per file. type CommitPage struct { Meta Meta Detail *gitread.CommitDetail Files []render.FileDiff } // RefsPage lists branches and tags. type RefsPage struct { Meta Meta Refs *gitread.Refs } // IssueGroup is a status bucket of tasks in the issues list. type IssueGroup struct { Status string Tasks []backlog.Task } // IssuesPage is the GitHub-issues-style list, grouped by status. type IssuesPage struct { Meta Meta Groups []IssueGroup Total int } // IssuePage is a single task with its rendered Markdown body. type IssuePage struct { Meta Meta Task backlog.Task Body template.HTML } // GroupByStatus buckets tasks following the repo's configured status order, // appending any statuses not in that order at the end. Empty buckets are // dropped. An empty order falls back to the Backlog.md defaults. func GroupByStatus(tasks []backlog.Task, order []string) []IssueGroup { if len(order) == 0 { order = backlog.DefaultStatuses } known := make(map[string]bool, len(order)) for _, s := range order { known[s] = true } byStatus := map[string][]backlog.Task{} var extra []string for _, t := range tasks { if _, seen := byStatus[t.Status]; !seen && !known[t.Status] { extra = append(extra, t.Status) } byStatus[t.Status] = append(byStatus[t.Status], t) } var groups []IssueGroup for _, s := range append(append([]string{}, order...), extra...) { if ts := byStatus[s]; len(ts) > 0 { groups = append(groups, IssueGroup{Status: s, Tasks: ts}) } } return groups } // LabelColor maps a label to a theme color-family name (phase-3 tokens key off // these via .chip--). Unknown labels get the neutral accent. func LabelColor(label string) string { switch strings.ToLower(label) { case "bug": return "red" case "feature": return "green" case "enhancement", "ui": return "blue" case "docs", "documentation": return "cyan" case "chore", "refactor": return "purple" case "question": return "yellow" default: return "accent" } } // PriorityClass maps a priority to a css-class-safe level (high/medium/low), // or "" when absent so the template can skip the pill. func PriorityClass(p string) string { switch strings.ToLower(strings.TrimSpace(p)) { case "high", "critical", "urgent": return "high" case "medium", "med", "normal": return "medium" case "low", "minor": return "low" default: return "" } } // StatusKind maps an arbitrary status label (emoji and all) to a stable // css-class-safe kind, so themes can style columns regardless of the exact // wording a repo uses. Unrecognized statuses fall back to an alnum slug. func StatusKind(status string) string { s := strings.ToLower(status) switch { case strings.Contains(s, "progress"): return "in-progress" case strings.Contains(s, "done"), strings.Contains(s, "ship"), strings.Contains(s, "complete"): return "done" case strings.Contains(s, "paus"), strings.Contains(s, "block"), strings.Contains(s, "hold"): return "paused" case strings.Contains(s, "backlog"), strings.Contains(s, "to do"), strings.Contains(s, "todo"): return "backlog" } return slugify(s) } // slugify reduces a string to lowercase alphanumerics joined by single dashes. func slugify(s string) string { var b strings.Builder dash := false for _, r := range strings.ToLower(s) { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': if dash && b.Len() > 0 { b.WriteByte('-') } b.WriteRune(r) dash = false default: dash = true } } if b.Len() == 0 { return "other" } return b.String() } // HumanSize formats a byte count as a short human-readable string. func HumanSize(n int64) string { const unit = 1024 if n < unit { return fmt.Sprintf("%d B", n) } div, exp := int64(unit), 0 for m := n / unit; m >= unit; m /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp]) } // ShortHash returns the first 8 characters of a hash, or the whole thing. func ShortHash(h string) string { if len(h) >= 8 { return h[:8] } return h } // FmtTime renders a timestamp in a compact, stable form. func FmtTime(t time.Time) string { return t.Format("2006-01-02 15:04") } // BuildCrumbs splits a "/"-separated path into cumulative breadcrumbs. func BuildCrumbs(path string) []Crumb { path = strings.Trim(path, "/") if path == "" { return nil } parts := strings.Split(path, "/") crumbs := make([]Crumb, 0, len(parts)) acc := "" for _, p := range parts { if acc == "" { acc = p } else { acc = acc + "/" + p } crumbs = append(crumbs, Crumb{Name: p, Path: acc}) } return crumbs }