oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
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
29var 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
42var 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.
47var servedStaticFS = sharedStaticFiles()
48var assets = rastrillo.NewAssets(servedStaticFS)
49
50func 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.
94var 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.
108var 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.
112const 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.
123func 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.
156type 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.
172func (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.
201func 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.
205func staticFiles() fs.FS { return servedStaticFS }
206