oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1package slides
2
3import (
4 "amadan.net/oficina/estilo.git/sky"
5 "bytes"
6 "embed"
7 "html/template"
8 "io/fs"
9 "net/http"
10 "testing/fstest"
11
12 "amadan.net/oficina/estilo.git/base"
13 "amadan.net/oficina/estilo.git/chrome"
14 "amadan.net/oficina/estilo.git/credits"
15 estilomenu "amadan.net/oficina/estilo.git/menu"
16 "amadan.net/oficina/estilo.git/savestatus"
17 "amadan.net/oficina/home/connect"
18 "amadan.net/oficina/slides/internal/slides/icons"
19 "amadan.net/rastrillo/rastrillo"
20 "amadan.net/rastrillo/rastrillo/ui"
21)
22
23//go:embed templates static
24var appFS embed.FS
25
26// LocaleFS carries the catalogs as locales/<code>.toml — the shape
27// rastrillo.Options.LocaleFS (and rastrillo.NewLocales) expects.
28// Separate from appFS because that one is also the fingerprinted asset
29// tree, and a catalog is not an asset anybody fetches.
30//
31//go:embed locales
32var LocaleFS embed.FS
33
34// Locales and DefaultLocale declare this app's locale set.
35// cmd/slides/main.go wires all three of these into rastrillo.Options;
36// without them rastrillo installs no locale middleware and every
37// rastrillo.T call returns its key verbatim, so this is what makes the
38// catalog actually resolve on a real page.
39//
40// English only for now. Twelve locales are the suite's target and every
41// user-visible string is already a key, so adding one is adding a file.
42var Locales = []string{"en"}
43
44// DefaultLocale is the locale an unprefixed request with no stored
45// preference and no usable Accept-Language gets.
46const DefaultLocale = "en"
47
48// assets fingerprints static/: {{asset "static/tokens.css"}} in a
49// template returns a content-hashed URL, served immutable-cacheable by
50// the handler New mounts (app.go).
51//
52// The tree it serves is the app's own static/ plus estilo's shared
53// chrome, under static/estilo/: base.css, menu.css, chrome.css and the
54// optional chrome.js. Served from here rather than linked from estilo,
55// so they are fingerprinted like every other asset and a new estilo pin
56// is a new URL.
57var assets = rastrillo.NewAssets(staticFiles())
58
59func staticFiles() fs.FS {
60 files := fstest.MapFS{}
61 err := fs.WalkDir(appFS, "static", func(name string, entry fs.DirEntry, err error) error {
62 if err != nil || entry.IsDir() {
63 return err
64 }
65 data, err := appFS.ReadFile(name)
66 if err != nil {
67 return err
68 }
69 files[name] = &fstest.MapFile{Data: data}
70 return nil
71 })
72 if err != nil {
73 panic(err)
74 }
75 files["static/estilo/base.css"] = &fstest.MapFile{Data: base.CSS()}
76 files["static/estilo/menu.css"] = &fstest.MapFile{Data: estilomenu.CSS()}
77 files["static/estilo/chrome.css"] = &fstest.MapFile{Data: chrome.CSS()}
78 // The sky behind the sign-in door (estilo sky); its images are
79 // served by sky.Handler.
80 files["static/estilo/sky.css"] = &fstest.MapFile{Data: sky.CSS()}
81 files["static/estilo/chrome.js"] = &fstest.MapFile{Data: chrome.JS()}
82 files["static/estilo/save-status.css"] = &fstest.MapFile{Data: savestatus.CSS()}
83 files["static/estilo/save-status.mjs"] = &fstest.MapFile{Data: savestatus.JS()}
84 return files
85}
86
87// pages is one *template.Template per page, each combining layout.html
88// with that page's own file — kept separate so every page can define a
89// template named "content" without colliding with the others.
90var pages = map[string]*template.Template{}
91
92// pageFiles is every page and the app partials it is built with. Every
93// page gets the same layout and the same estilo bar; what differs
94// between a signed-in screen, the sign-in page, a share link and a
95// projector is the Frame the handler builds (chrome.go), not which
96// templates were parsed. Before round 2 that choice was made here, in
97// four groups (public, signed in, share link, capability), and the
98// signed-in presenter was shown the anonymous shell's "Account → Sign
99// in" because the group, not the request, decided.
100var pageFiles = map[string][]string{
101 "signin": nil,
102 "signed_out": nil,
103 "errors": nil,
104 "home_link": nil,
105 "credits": nil,
106 "decks": nil,
107 "viewer": nil,
108 "editor": nil,
109 "deck_notes": nil,
110 "deck_new": nil,
111 "deck_settings": nil,
112 "deck_delete": nil,
113 "deck_export": nil,
114 "export_status": nil,
115 "share": nil,
116 "agents": nil,
117 // The presenting screens: three share one partial, so the remote
118 // and the presenter view cannot drift on what a button is called or
119 // where it posts.
120 "present": {"templates/present_controls.html"},
121 "present_notes": {"templates/present_controls.html"},
122 "remote": {"templates/present_controls.html"},
123 "remote_pair": nil,
124}
125
126// linkPages are names a handler uses for a page built from another
127// page's file. The viewer is ONE file serving the owner, a team member
128// and a stranger holding a share link (viewer.go says why); the Frame is
129// what tells them apart.
130var linkPages = map[string]string{"viewer_link": "viewer"}
131
132// fragmentPages are the templates that are ALSO rendered without the
133// layout: rastrillo's polling shim replaces one element with what it
134// fetches, so the polled endpoint answers a partial and not a page
135// (jobs.Config.RenderFragment — "render a whole page there and the
136// layout nests inside itself on the next poll").
137//
138// One file, two builds, exactly as viewer.html is built twice: the
139// status page and the fragment inside it cannot say different things
140// about the same job if they are one template.
141var fragmentPages = []string{"export_status"}
142
143func init() {
144 for name, shared := range pageFiles {
145 t := buildPage(name, shared...)
146 switch name {
147 case "credits":
148 t = template.Must(t.ParseFS(credits.Templates(), "templates/*.html"))
149 case "signin":
150 // The Keymail continuation's meta refresh (home-oauth-head).
151 t = template.Must(t.ParseFS(connect.Templates(), "templates/oauth.html"))
152 case "home_link":
153 t = template.Must(t.ParseFS(connect.Templates(), "templates/home_link.html"))
154 }
155 pages[name] = t
156 }
157 pages["editor"] = withSaveStatus(pages["editor"])
158 for name, file := range linkPages {
159 pages[name] = pages[file]
160 }
161 for _, name := range fragmentPages {
162 fragments[name] = buildFragment(name)
163 }
164}
165
166// fragments is one *template.Template per template that is also
167// rendered on its own, without the layout around it.
168var fragments = map[string]*template.Template{}
169
170// buildFragment parses a page's file with ui's partials and NOTHING
171// else — no layout, no shell — so executing one of its defines writes
172// the partial by itself.
173func buildFragment(name string) *template.Template {
174 return template.Must(
175 template.Must(template.New("fragment").
176 Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
177 Funcs(template.FuncMap{"asset": assets.Path}).
178 ParseFS(ui.Templates(), "*.html")).
179 ParseFS(appFS, "templates/"+name+".html"))
180}
181
182// buildPage parses one page: ui's partials, estilo's menu and chrome
183// templates, then the layout, then any shared app partials, then the
184// page's own file. Order is the whole mechanism — a later definition of
185// a name replaces an earlier one, so a page's "content" wins over the
186// layout's empty default.
187func buildPage(name string, shared ...string) *template.Template {
188 files := append([]string{"templates/layout.html"}, shared...)
189 files = append(files, "templates/"+name+".html")
190 t := template.Must(template.New("layout").
191 // ui's partials and their helpers first, so every page can
192 // call one without registering anything. WithIcons points
193 // ui's two icon seams at the app's own set
194 // (internal/slides/icons, scaffolded by rastrillo new) —
195 // both of them, always: passing only icon would leave
196 // iconAssets on the framework default and silently break
197 // cdn and js delivery. iconAssets is whatever <head> markup
198 // the chosen delivery needs, empty for the vendored-inline
199 // default, so the layout calls it unconditionally and
200 // switching delivery later needs no template edit.
201 Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
202 Funcs(template.FuncMap{"asset": assets.Path}).
203 ParseFS(ui.Templates(), "*.html"))
204 t = template.Must(t.ParseFS(estilomenu.Templates(), "templates/*.html"))
205 t = template.Must(t.ParseFS(chrome.Templates(), "templates/*.html"))
206 // Then the app's own: the shell, any shared partial, and this
207 // page's body.
208 return template.Must(t.ParseFS(appFS, files...))
209}
210
211// render executes name's layout into a buffer before anything touches
212// the wire: a template error becomes a clean 500 instead of garbage
213// appended to a half-written page, and headers (flash cookies, say)
214// stay settable until the status line is written.
215func render(w http.ResponseWriter, name string, data any) {
216 var buf bytes.Buffer
217 if err := pages[name].ExecuteTemplate(&buf, "layout", data); err != nil {
218 http.Error(w, "something went wrong", http.StatusInternalServerError)
219 return
220 }
221 buf.WriteTo(w)
222}
223
224// renderFragment executes one define out of a fragment build — the
225// markup a polled endpoint answers with, and nothing around it. Same
226// buffer-first rule as render: a template error is a clean 500 rather
227// than half a fragment swapped into somebody's page.
228func renderFragment(w http.ResponseWriter, name, define string, data any) {
229 var buf bytes.Buffer
230 if err := fragments[name].ExecuteTemplate(&buf, define, data); err != nil {
231 http.Error(w, "something went wrong", http.StatusInternalServerError)
232 return
233 }
234 w.Header().Set("Content-Type", "text/html; charset=utf-8")
235 buf.WriteTo(w)
236}
237