szabadpalya/api/internal/shell/shell.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 }