// Package shell renders the HTML document the browser receives before React // runs. // // This is the whole reason the Go process serves HTML at all. Every campaign // needs its own og: tags: the crawlers that build a Facebook share card do not // execute JavaScript, so a bare SPA shell makes every shared campaign look // identical. SPEC.md section 8 calls that a permanent self-inflicted wound and // it is the cheapest place for us to be better than the incumbents. // // The seam is deliberately sharp. Go owns and nothing else; React owns // everything inside #root. No markup is produced in two places, so no markup // can drift out of sync. package shell import ( "bytes" "encoding/json" "fmt" "html/template" "net/http" "os" "path/filepath" ) type OpenGraph struct { Title string Description string Type string URL string Image string ImageAlt string } type Document struct { Title string Description string Canonical string OG OpenGraph // Bootstrap is the data the first route would otherwise have to fetch. It // is already JSON; see Render for why that is safe to inline. Bootstrap template.JS Styles []string Scripts []string ModulePreload []string } type Renderer struct { tmpl *template.Template assets assets siteURL string } type assets struct { styles []string scripts []string modulePreload []string } // viteManifest is the subset of Vite's manifest.json we care about. type viteManifest map[string]struct { File string `json:"file"` CSS []string `json:"css"` Imports []string `json:"imports"` IsEntry bool `json:"isEntry"` } // New reads the built frontend's manifest once, at startup. A missing or // malformed manifest is a boot failure rather than a blank page later. func New(tmplText string, webDir string, siteURL string, dev bool) (*Renderer, error) { tmpl, err := template.New("document").Parse(tmplText) if err != nil { return nil, fmt.Errorf("parse document template: %w", err) } r := &Renderer{tmpl: tmpl, siteURL: siteURL} if dev { // The Vite dev server owns index.html in development, so the shell is // only exercised by the container. Point at the dev server's client // runtime so a stray render still works. r.assets = assets{scripts: []string{"/@vite/client", "/src/main.tsx"}} return r, nil } manifestPath := filepath.Join(webDir, ".vite", "manifest.json") raw, err := os.ReadFile(manifestPath) if err != nil { return nil, fmt.Errorf("read vite manifest at %s: %w", manifestPath, err) } var m viteManifest if err := json.Unmarshal(raw, &m); err != nil { return nil, fmt.Errorf("parse vite manifest: %w", err) } entry, ok := m["index.html"] if !ok { return nil, fmt.Errorf("vite manifest has no index.html entry") } r.assets.scripts = []string{"/" + entry.File} for _, css := range entry.CSS { r.assets.styles = append(r.assets.styles, "/"+css) } for _, imp := range entry.Imports { if chunk, ok := m[imp]; ok { r.assets.modulePreload = append(r.assets.modulePreload, "/"+chunk.File) for _, css := range chunk.CSS { r.assets.styles = append(r.assets.styles, "/"+css) } } } return r, nil } // Render writes a complete document. Bootstrap data is marshalled here rather // than by the caller because encoding/json escapes <, > and & by default, // which is exactly what keeps a campaign title containing "" from // closing the tag it sits in. func (r *Renderer) Render(w http.ResponseWriter, status int, doc Document, bootstrap any) error { if bootstrap != nil { raw, err := json.Marshal(bootstrap) if err != nil { return err } doc.Bootstrap = template.JS(raw) } doc.Styles = r.assets.styles doc.Scripts = r.assets.scripts doc.ModulePreload = r.assets.modulePreload if doc.OG.Title == "" { doc.OG.Title = doc.Title } if doc.OG.Description == "" { doc.OG.Description = doc.Description } if doc.OG.Type == "" { doc.OG.Type = "website" } var buf bytes.Buffer if err := r.tmpl.Execute(&buf, doc); err != nil { return err } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) _, err := buf.WriteTo(w) return err } // Abs turns a site-relative path into the absolute URL crawlers require: a // relative og:image is ignored by most of them. func (r *Renderer) Abs(path string) string { return r.siteURL + path }