package render import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "sort" "strconv" "git.pablomurad.com/pablo/patchwork/internal/config" "git.pablomurad.com/pablo/patchwork/internal/store" ) // File is one published file. type File struct { Body []byte ContentType string ETag string } // Tree is the whole published output, keyed by path without a leading // slash ("api/v1/pages.json"). type Tree struct { Files map[string]File Model *Model } // Paths lists the tree's paths in order. func (t *Tree) Paths() []string { out := make([]string, 0, len(t.Files)) for p := range t.Files { out = append(out, p) } sort.Strings(out) return out } const ( typeJSON = "application/json; charset=utf-8" typeAtom = "application/atom+xml; charset=utf-8" typeJSONFeed = "application/feed+json; charset=utf-8" typeText = "text/plain; charset=utf-8" ) // Build loads the store and renders every file. func Build(ctx context.Context, st *store.Store, cfg *config.Config) (*Tree, error) { m, err := Load(ctx, st, cfg) if err != nil { return nil, err } return m.Tree() } // Tree renders a loaded model. func (m *Model) Tree() (*Tree, error) { t := &Tree{Files: map[string]File{}, Model: m} put := func(path, ctype string, body []byte) { sum := sha256.Sum256(body) t.Files[path] = File{Body: body, ContentType: ctype, ETag: `"` + hex.EncodeToString(sum[:12]) + `"`} } putJSON := func(path string, v any) error { b, err := marshal(v) if err != nil { return err } put(path, typeJSON, b) return nil } summaries := make([]PageSummary, 0, len(m.Pages)) for _, p := range m.Pages { summaries = append(summaries, p.PageSummary) } if err := putJSON("api/v1/pages.json", PageList{Pages: summaries}); err != nil { return nil, err } recent := m.Revisions if len(recent) > m.Cfg.Feeds.Limit { recent = recent[:m.Cfg.Feeds.Limit] } if err := putJSON("api/v1/revisions.json", RevisionList{Revisions: nonNil(recent)}); err != nil { return nil, err } atom, err := m.Atom(recent, "", m.Cfg.Site.Feeds.Atom) if err != nil { return nil, err } put("feeds/revisions.atom", typeAtom, atom) jf, err := m.JSONFeed(recent, "", m.Cfg.Site.Feeds.JSON) if err != nil { return nil, err } put("feeds/revisions.json", typeJSONFeed, jf) put("text/revisions.txt", typeText, []byte(m.TextRevisions(recent, TextOptions{Width: 80}))) for _, p := range m.Pages { if err := putJSON("api/v1/pages/"+p.Slug+".json", p); err != nil { return nil, err } for _, v := range m.Versions[p.Slug] { if err := putJSON("api/v1/pages/"+p.Slug+"/versions/"+strconv.Itoa(v.Number)+".json", v); err != nil { return nil, err } } revs := m.PageRevisions(p.Slug) if len(revs) > m.Cfg.Feeds.Limit { revs = revs[:m.Cfg.Feeds.Limit] } atom, err := m.Atom(revs, p.Slug, m.Cfg.Site.Feeds.PageAtom) if err != nil { return nil, err } put("feeds/pages/"+p.Slug+".atom", typeAtom, atom) jf, err := m.JSONFeed(revs, p.Slug, m.Cfg.Site.Feeds.PageJSON) if err != nil { return nil, err } put("feeds/pages/"+p.Slug+".json", typeJSONFeed, jf) put("text/pages/"+p.Slug+"/history.txt", typeText, []byte(m.TextHistory(p.Slug, TextOptions{Width: 80}))) } return t, nil } // marshal writes indented JSON without escaping <, > and &: the output is // data for sites and people, never inlined into HTML by Patchwork. func marshal(v any) ([]byte, error) { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) enc.SetIndent("", " ") if err := enc.Encode(v); err != nil { return nil, err } return buf.Bytes(), nil } func nonNil(r []Revision) []Revision { if r == nil { return []Revision{} } return r }