prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

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 }