renderer.go (5887B)
1 package web 2 3 import ( 4 "crypto/sha256" 5 "embed" 6 "encoding/hex" 7 "fmt" 8 "html/template" 9 "io/fs" 10 "os" 11 "path" 12 "path/filepath" 13 "strconv" 14 "strings" 15 "sync" 16 "time" 17 18 "git.prime8.dev/moncheeta/prime8dev/internal/config" 19 "git.prime8.dev/moncheeta/prime8dev/internal/utils" 20 "git.prime8.dev/moncheeta/prime8dev/internal/vfs" 21 ) 22 23 //go:embed templates/*.gohtml templates/partials/*.gohtml styles/*.css 24 var templatesFS embed.FS 25 26 type Data struct { 27 Title string 28 Path string 29 Description string 30 ActiveTab string 31 ContentTemplate string 32 About config.About 33 Uptime string 34 Experience []config.Experience 35 SelectedExperienceID string 36 CurrentJob *config.Experience 37 Projects []config.Project 38 SelectedProjectID string 39 Project *config.Project 40 ProjectReadme template.HTML 41 Tabs []Tab 42 SiteURL string 43 } 44 45 type Tab struct { 46 ID string 47 Label string 48 Num int 49 } 50 51 type Renderer struct { 52 tpl *template.Template 53 } 54 55 // StaticDir is where the server serves /static/ from (relative to the working 56 // directory, /app in the Docker image). assetVersion hashes files from here. 57 var StaticDir = "static" 58 59 // assetVersions caches one content hash per static file for the life of the 60 // process. Static files only change on deploy, which restarts the server. 61 var ( 62 assetVersionsMu sync.Mutex 63 assetVersions = map[string]string{} 64 startupVersion = strconv.FormatInt(time.Now().Unix(), 36) 65 ) 66 67 // AssetVersion returns a short content hash of static/<name>, for cache 68 // busting: /static/app.js?v=<hash>. If the file can't be read (e.g. tests run 69 // from another directory) it falls back to the process start time, which 70 // still changes on every deploy. 71 func AssetVersion(name string) string { 72 assetVersionsMu.Lock() 73 defer assetVersionsMu.Unlock() 74 if v, ok := assetVersions[name]; ok { 75 return v 76 } 77 v := startupVersion 78 if b, err := os.ReadFile(filepath.Join(StaticDir, filepath.FromSlash(path.Clean("/"+name)))); err == nil { 79 sum := sha256.Sum256(b) 80 v = hex.EncodeToString(sum[:])[:12] 81 } 82 assetVersions[name] = v 83 return v 84 } 85 86 func New() (*Renderer, error) { 87 inlineStyles, err := loadInlineStyles() 88 if err != nil { 89 return nil, err 90 } 91 92 funcs := template.FuncMap{ 93 "tabPath": utils.TabPath, 94 "tabURL": utils.TabURL, 95 "tagClass": utils.TagClass, 96 "tagColor": func(label string) template.CSS { return template.CSS(utils.TagColor(label)) }, 97 "shellPath": ShellPath, 98 "windowTitle": func(p string) string { return utils.WindowTitle(ShellPath(p)) }, 99 "shellUser": func() string { return vfs.FallbackUser }, 100 "tagCSS": func() template.CSS { return template.CSS(utils.TagCSS()) }, 101 "inc": func(i int) int { return i + 1 }, 102 "techMeta": utils.TechMeta, 103 "shortMonthYear": utils.FormatShortMonthYear, 104 "shortEndDate": utils.FormatShortEndDate, 105 "formatRange": utils.FormatRange, 106 "shortDuration": utils.FormatDurationShort, 107 "monthStamp": utils.MonthStamp, 108 "endStamp": utils.EndStamp, 109 "experienceAxis": experienceAxis, 110 "pct": func(f float64) string { return strconv.FormatFloat(f*100, 'f', 3, 64) + "%" }, 111 "toHTML": toHTML, 112 "assetVersion": AssetVersion, 113 "gitCloneURL": config.GitCloneURL, 114 "gitSSHCloneURL": config.GitSSHCloneURL, 115 "wakaTimeUser": func() string { return config.WakaTimeUser }, 116 "inlineCSS": func(name string) template.CSS { 117 if css, ok := inlineStyles[name]; ok { 118 return css 119 } 120 return "" 121 }, 122 } 123 124 // hash the bundle once at startup rather than on the first request 125 AssetVersion("app.js") 126 127 tpl, err := template.New("site").Funcs(funcs).ParseFS(templatesFS, "templates/*.gohtml", "templates/partials/*.gohtml") 128 if err != nil { 129 return nil, fmt.Errorf("parse templates: %w", err) 130 } 131 132 return &Renderer{tpl: tpl}, nil 133 } 134 135 func (r *Renderer) Render(name string, data Data) (string, error) { 136 var b strings.Builder 137 if err := r.tpl.ExecuteTemplate(&b, name, data); err != nil { 138 return "", err 139 } 140 return b.String(), nil 141 } 142 143 func DefaultTabs() []Tab { 144 return []Tab{ 145 {ID: "about", Label: "About", Num: 1}, 146 {ID: "experience", Label: "Experience", Num: 2}, 147 {ID: "projects", Label: "Projects", Num: 3}, 148 } 149 } 150 151 func BaseData(title, activeTab string, about config.About) Data { 152 return Data{ 153 Title: title, 154 Description: fmt.Sprintf("Portfolio of %s", about.Name), 155 ActiveTab: activeTab, 156 ContentTemplate: activeTab, 157 About: about, 158 Uptime: utils.Uptime(config.Birthday, time.Now()), 159 Tabs: DefaultTabs(), 160 SiteURL: config.SiteURL, 161 } 162 } 163 164 func toHTML(s string) template.HTML { 165 return template.HTML(s) 166 } 167 168 func loadInlineStyles() (map[string]template.CSS, error) { 169 entries, err := fs.ReadDir(templatesFS, "styles") 170 if err != nil { 171 return nil, fmt.Errorf("read styles: %w", err) 172 } 173 174 styles := make(map[string]template.CSS, len(entries)) 175 for _, entry := range entries { 176 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".css") { 177 continue 178 } 179 180 stylePath := path.Join("styles", entry.Name()) 181 content, err := templatesFS.ReadFile(stylePath) 182 if err != nil { 183 return nil, fmt.Errorf("read style %s: %w", entry.Name(), err) 184 } 185 186 styles[entry.Name()] = template.CSS(string(content)) 187 } 188 189 return styles, nil 190 } 191 192 // ShellPath renders a URL path the way a shell prompt shows cwd: "/" is "~". 193 func ShellPath(path string) string { 194 return "~" + strings.TrimRight(path, "/") 195 } 196 197 // experienceAxis lays every role on one month axis for the timeline strip. 198 func experienceAxis(exps []config.Experience) utils.Axis { 199 spans := make([]utils.Span, len(exps)) 200 for i, e := range exps { 201 spans[i] = utils.Span{Start: e.StartDate, End: e.EndDate, Current: e.Current} 202 } 203 return utils.NewAxis(spans) 204 }