| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "amadan.net/oficina/estilo.git/sky" |
| 5 | "amadan.net/oficina/home/connect" |
| 6 | "bytes" |
| 7 | "embed" |
| 8 | "html/template" |
| 9 | "io/fs" |
| 10 | "net/http" |
| 11 | "path" |
| 12 | "strings" |
| 13 | "testing/fstest" |
| 14 | |
| 15 | "amadan.net/oficina/estilo.git/base" |
| 16 | "amadan.net/oficina/estilo.git/busy" |
| 17 | "amadan.net/oficina/estilo.git/chrome" |
| 18 | estilomenu "amadan.net/oficina/estilo.git/menu" |
| 19 | "amadan.net/oficina/estilo.git/theme" |
| 20 | |
| 21 | "amadan.net/rastrillo/rastrillo" |
| 22 | "amadan.net/rastrillo/rastrillo/ui" |
| 23 | ) |
| 24 | |
| 25 | // templatesFS holds this app's own pages. rastrillo's partials come |
| 26 | // from ui.Templates() and are parsed alongside them. |
| 27 | // |
| 28 | //go:embed templates/*.html |
| 29 | var templatesFS embed.FS |
| 30 | |
| 31 | // staticFS holds everything served under /static: rastrillo's vendored |
| 32 | // tokens.css (copied by the framework's own scaffold and verified by |
| 33 | // `rastrillo doctor`), our own CSS and JS, and the vendored grid. |
| 34 | // rastrillo.js is not on disk: it comes from estilo's busy package, with |
| 35 | // busy.js and busy.css, below. |
| 36 | // |
| 37 | // all: is required, not decorative — the default embed pattern skips |
| 38 | // files whose names begin with _ or ., and a vendored library that |
| 39 | // happens to ship one would go missing at run time and nowhere else. |
| 40 | // |
| 41 | //go:embed all:static |
| 42 | var staticFS embed.FS |
| 43 | |
| 44 | // assets fingerprints static files so they can be cached forever and |
| 45 | // still update on an ordinary reload: the URL carries the content hash, |
| 46 | // so the browser has never cached the new one stale. |
| 47 | var servedStaticFS = sharedStaticFiles() |
| 48 | var assets = rastrillo.NewAssets(servedStaticFS) |
| 49 | |
| 50 | func sharedStaticFiles() fs.FS { |
| 51 | files := fstest.MapFS{} |
| 52 | err := fs.WalkDir(staticFS, "static", func(name string, entry fs.DirEntry, err error) error { |
| 53 | if err != nil { |
| 54 | return err |
| 55 | } |
| 56 | if entry.IsDir() { |
| 57 | return nil |
| 58 | } |
| 59 | data, err := staticFS.ReadFile(name) |
| 60 | if err != nil { |
| 61 | return err |
| 62 | } |
| 63 | files[name] = &fstest.MapFile{Data: data} |
| 64 | return nil |
| 65 | }) |
| 66 | if err != nil { |
| 67 | panic(err) |
| 68 | } |
| 69 | // Calma, with Sheets' own accent from estilo's colour ring (estilo |
| 70 | // AGENTS.md, decisions table, row 8). |
| 71 | files["static/theme.css"] = &fstest.MapFile{Data: sheetsTheme} |
| 72 | // estilo's shared chrome, served under the app's own fingerprinted |
| 73 | // /static like the theme, in the suite's order: base, menu, chrome. |
| 74 | files["static/estilo/base.css"] = &fstest.MapFile{Data: base.CSS()} |
| 75 | files["static/estilo/menu.css"] = &fstest.MapFile{Data: estilomenu.CSS()} |
| 76 | files["static/estilo/chrome.css"] = &fstest.MapFile{Data: chrome.CSS()} |
| 77 | // The sky behind the sign-in door (estilo sky); its images are |
| 78 | // served by sky.Handler. |
| 79 | files["static/estilo/sky.css"] = &fstest.MapFile{Data: sky.CSS()} |
| 80 | files["static/estilo/chrome.js"] = &fstest.MapFile{Data: chrome.JS()} |
| 81 | // rastrillo's busy rule (Paul, 2026-09-29): a submit button's label |
| 82 | // gives way to a spinner for at least 650ms, the button disables, and |
| 83 | // Back resets it. estilo carries rastrillo's shim without the old busy |
| 84 | // rule, busy.js, and the spinner-in-place CSS; see estilo's busy. |
| 85 | files["static/rastrillo.js"] = &fstest.MapFile{Data: busy.ShimJS()} |
| 86 | files["static/estilo/busy.js"] = &fstest.MapFile{Data: busy.JS()} |
| 87 | files["static/estilo/busy.css"] = &fstest.MapFile{Data: busy.CSS()} |
| 88 | return files |
| 89 | } |
| 90 | |
| 91 | // sheetsTheme is estilo's theme for Sheets. "sheets" is on the ring, so |
| 92 | // ForApp cannot refuse it; a panic here is a broken estilo pin, found at |
| 93 | // boot rather than on a page. |
| 94 | var sheetsTheme = func() []byte { |
| 95 | css, ok := theme.ForApp("sheets") |
| 96 | if !ok { |
| 97 | panic("estilo's theme ring has no sheets") |
| 98 | } |
| 99 | return css |
| 100 | }() |
| 101 | |
| 102 | // pages is one parsed template tree per page. |
| 103 | // |
| 104 | // One tree each, rather than one tree holding everything, because every |
| 105 | // page defines a block called "content" and a single tree would keep |
| 106 | // only the last one parsed. This is the shape rastrillo's SKILL.md |
| 107 | // documents for exactly that reason. |
| 108 | var pages = parsePages() |
| 109 | |
| 110 | // layoutFile is the one template every page is wrapped in, and the one |
| 111 | // file under templates/ that is not itself a page. |
| 112 | const layoutFile = "layout.html" |
| 113 | |
| 114 | // parsePages builds one tree per page, discovered from the embedded |
| 115 | // directory rather than from a list kept in this file. |
| 116 | // |
| 117 | // Discovered, because a hand-kept list is a second place to remember: |
| 118 | // adding templates/foo.html and forgetting the list gives a page that |
| 119 | // silently never renders, and the mistake is invisible in the diff that |
| 120 | // causes it. It runs once, at startup, and panics on a broken template |
| 121 | // — which is right, since a template that cannot parse cannot serve any |
| 122 | // request and failing at boot is louder than failing per request. |
| 123 | func parsePages() map[string]*template.Template { |
| 124 | funcs := ui.Funcs() |
| 125 | funcs["asset"] = assets.Path |
| 126 | |
| 127 | files, err := fs.Glob(templatesFS, "templates/*.html") |
| 128 | if err != nil { |
| 129 | panic("sheets: reading templates: " + err.Error()) |
| 130 | } |
| 131 | out := make(map[string]*template.Template, len(files)) |
| 132 | for _, f := range files { |
| 133 | name := strings.TrimSuffix(path.Base(f), ".html") |
| 134 | if path.Base(f) == layoutFile { |
| 135 | continue |
| 136 | } |
| 137 | t := template.Must(template.New(name).Funcs(funcs). |
| 138 | ParseFS(ui.Templates(), "*.html")) |
| 139 | t = template.Must(t.ParseFS(connect.Templates(), "templates/*.html")) |
| 140 | t = template.Must(t.ParseFS(estilomenu.Templates(), "templates/*.html")) |
| 141 | t = template.Must(t.ParseFS(chrome.Templates(), "templates/*.html")) |
| 142 | t = template.Must(t.ParseFS(templatesFS, "templates/"+layoutFile, f)) |
| 143 | out[name] = t |
| 144 | } |
| 145 | return out |
| 146 | } |
| 147 | |
| 148 | // PageData is what every template receives. |
| 149 | // |
| 150 | // Frame is the chrome, built once per request in chrome.go: the bar, the |
| 151 | // <title> parts and the light/dark choice. The choice is resolved there, |
| 152 | // server-side, from a cookie — never fetched. AGENTS.md: it must resolve |
| 153 | // before first paint, because cell fills resolve against the theme and a |
| 154 | // late preference repaints every coloured cell. A page carrying it in |
| 155 | // its first byte cannot flash; one that asks for it can. |
| 156 | type PageData struct { |
| 157 | Frame Frame |
| 158 | // Data is the page's own payload. Named rather than embedded so a |
| 159 | // template always says .Data.Sheets and never has to guess whether |
| 160 | // a field belongs to the page or the shell. |
| 161 | Data any |
| 162 | } |
| 163 | |
| 164 | // render writes a page. f describes its chrome: the <title> parts, the |
| 165 | // open sheet, the bar's actions (see chrome.go). |
| 166 | // |
| 167 | // The template executes into a buffer first, and only a complete render |
| 168 | // reaches the client. Executing straight to the ResponseWriter means a |
| 169 | // template error halfway through has already sent a 200 and half a |
| 170 | // page, and the browser shows a truncated document with no indication |
| 171 | // anything went wrong. |
| 172 | func (a *App) render(w http.ResponseWriter, r *http.Request, page string, f frame, status int, data any) { |
| 173 | if nativePage(w, r, page, status, data) { |
| 174 | return |
| 175 | } |
| 176 | t, ok := pages[page] |
| 177 | if !ok { |
| 178 | a.fail(w, r, errNoTemplate) |
| 179 | return |
| 180 | } |
| 181 | var buf bytes.Buffer |
| 182 | if err := t.ExecuteTemplate(&buf, "layout", PageData{Frame: a.frameFor(r, f), Data: data}); err != nil { |
| 183 | // No cell content, sheet title or tab name reaches this — the |
| 184 | // error is dropped, because an html/template error can quote the |
| 185 | // data it was rendering (AGENTS.md / Mechanical rules). The 500 |
| 186 | // page is a template too; if the layout is what failed, it falls |
| 187 | // back to plain text itself (errorPage). |
| 188 | a.fail(w, r, err) |
| 189 | return |
| 190 | } |
| 191 | pageHeaders(w) |
| 192 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 193 | w.WriteHeader(status) |
| 194 | w.Write(buf.Bytes()) |
| 195 | } |
| 196 | |
| 197 | // staticHandler serves /static/* with a long cache lifetime for |
| 198 | // fingerprinted URLs. Mounted at the FS's own path, with no prefix |
| 199 | // stripped: rastrillo's Assets uses http.FileServerFS semantics, where |
| 200 | // the URL path is "/" plus the FS path. |
| 201 | func staticHandler() http.Handler { return assets.Handler() } |
| 202 | |
| 203 | // staticFiles is the embedded tree, exposed for the guard test that |
| 204 | // walks our JavaScript for the line cap. |
| 205 | func staticFiles() fs.FS { return servedStaticFS } |
| 206 | |