156 lines
4.4 KiB
Go
156 lines
4.4 KiB
Go
// 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 <head> 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 "</script>" 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 }
|