| 1 | package slides |
| 2 | |
| 3 | import ( |
| 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 |
| 24 | var 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 |
| 32 | var 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. |
| 42 | var Locales = []string{"en"} |
| 43 | |
| 44 | // DefaultLocale is the locale an unprefixed request with no stored |
| 45 | // preference and no usable Accept-Language gets. |
| 46 | const 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. |
| 57 | var assets = rastrillo.NewAssets(staticFiles()) |
| 58 | |
| 59 | func 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. |
| 90 | var 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. |
| 100 | var 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. |
| 130 | var 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. |
| 141 | var fragmentPages = []string{"export_status"} |
| 142 | |
| 143 | func 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. |
| 168 | var 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. |
| 173 | func 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. |
| 187 | func 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. |
| 215 | func 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. |
| 228 | func 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 | |