oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The editor bundle (spec §6.5: ProseMirror stays only because there
2// is no build step) is a committed, prebuilt artifact — never built
3// by this app or by CI — produced by fixtures/build-bundles.mjs and
4// committed under internal/docs/static/. This file embeds that tree
5// and serves it, independent of everything render.go's
6// vendoredStaticFS already vendors (rastrillo's own design-system
7// CSS/JS, an in-memory tree with no files on disk at all).
8package docs
9
10import (
11 "bytes"
12 "embed"
13 "fmt"
14 "io/fs"
15 "net/http"
16 "strings"
17 "time"
18
19 "github.com/carlosframework/docs/internal/fonts"
20)
21
22// brandFS embeds this app's own hand-authored assets — today, the tab
23// icon served at iconPath.
24//
25// A directory of its own, deliberately not static/ below. static/ is
26// the editor build's output: .amadan/ci.d/10-gate rebuilds the bundle
27// into it and then fails on any dirt at all in that directory, before
28// restoring it with git checkout and git clean. So a hand-written file
29// there does not merely sit oddly beside generated output — it turns
30// the gate red, and the gate's own cleanup then deletes it. That is
31// not hypothetical; icon.svg was written to static/ first and this is
32// what happened to it.
33//
34//go:embed brand
35var brandFS embed.FS
36
37// staticFS embeds the editor build's committed output — the vendored
38// editor bundle, and nothing else, for the reason brandFS's own doc
39// comment gives. internal/docs/static/, not a
40// top-level static/: go:embed cannot reach outside (or above) the
41// directory containing the .go file that embeds it — no ".." is ever
42// allowed in an embed pattern — so this lives under internal/docs/
43// the same way views/*.html (render.go) already does.
44//
45//go:embed static
46var staticFS embed.FS
47
48// bundleNames maps each committed bundle's name to its filename under
49// static/ — "editor" to "editor.f13f5e0ce6fa7467.js", and so on —
50// resolved once, at package init, from staticFS.
51//
52// There are two. "editor" is ProseMirror and Yjs, about 300KB, and
53// only a document page needs it; "jumpto" is the keyboard palette
54// (fixtures/jump-to.mjs), a few KB, and every signed-in page needs it
55// including the list. Keeping them apart is the whole reason this is a
56// registry rather than one filename: opening the list should not
57// download an editor in order to offer a keyboard shortcut.
58//
59// Exactly one file must ever be present per name.
60// fixtures/build-bundles.mjs's own rmSync loop deletes every previous
61// "<name>.<hash>.js" before writing the new one, so a build that
62// leaves two behind (a script edited to skip that step, a hand-added
63// file, a bad merge) is a mistake — this panics at init rather than
64// silently picking one, because a wrong bundle silently served is
65// indistinguishable from a correct one until it disagrees with the
66// server at runtime.
67var bundleNames = mustResolveBundleNames()
68
69// bundleKinds is every bundle that must exist. Listed rather than
70// discovered from the directory: a build that produced no jump-to
71// bundle at all would otherwise start cleanly and simply never offer
72// the palette, which is the same silent-failure shape the
73// exactly-one rule above exists to prevent.
74var bundleKinds = []string{"editor", "jumpto", "share"}
75
76func mustResolveBundleNames() map[string]string {
77 entries, err := fs.ReadDir(staticFS, "static")
78 if err != nil {
79 panic(fmt.Sprintf("docs: read embedded static/: %v", err))
80 }
81 out := make(map[string]string, len(bundleKinds))
82 for _, kind := range bundleKinds {
83 var found []string
84 for _, e := range entries {
85 if e.IsDir() || !strings.HasSuffix(e.Name(), ".js") {
86 continue
87 }
88 if strings.HasPrefix(e.Name(), kind+".") {
89 found = append(found, e.Name())
90 }
91 }
92 if len(found) != 1 {
93 panic(fmt.Sprintf("docs: want exactly one %q bundle in static/, found %v — run fixtures/build-bundles.mjs and commit its output", kind, found))
94 }
95 out[kind] = found[0]
96 }
97 return out
98}
99
100// bundlePath is the absolute URL path a committed bundle is served at.
101// The templates' <script> tags use this rather than a literal, so they
102// can never drift from what staticHandler actually serves.
103func bundlePath(kind string) string {
104 name, ok := bundleNames[kind]
105 if !ok {
106 // Unreachable while every caller passes a member of
107 // bundleKinds, which init has already proved resolvable.
108 panic(fmt.Sprintf("docs: no committed bundle named %q", kind))
109 }
110 return "/static/" + name
111}
112
113// editorScriptPath is the document editor's bundle — views/show.html's
114// <script> tag.
115func editorScriptPath() string { return bundlePath("editor") }
116
117// jumpToScriptPath is the keyboard palette's bundle (search.go,
118// fixtures/jump-to.mjs), loaded by both the list and a document.
119func jumpToScriptPath() string { return bundlePath("jumpto") }
120
121// shareScriptPath is the share page's Copy button (fixtures/share-link.mjs).
122func shareScriptPath() string { return bundlePath("share") }
123
124// iconPath is the absolute URL path this app's tab icon is served at,
125// and views/head.html's <link rel="icon"> href. Fixed rather than
126// content-hashed the way the editor bundle above is: a favicon is
127// requested by the browser for every page and changes about never, so
128// the hash would buy nothing, and a stable path is also what a client
129// that ignores the <link> and asks for a conventional location can be
130// pointed at.
131//
132// assets_test.go holds this against what views/head.html actually
133// writes, so the two can never drift into a 404 that only ever shows
134// up as a missing tab icon nobody files a bug about.
135const iconPath = "/static/icon.svg"
136
137// iconContentType is set explicitly for the same reason
138// editorBundleContentType below is: not left to the host's mime
139// registry. A browser will not render an icon served as
140// text/plain or application/octet-stream.
141const iconContentType = "image/svg+xml; charset=utf-8"
142
143// serveIcon writes the embedded tab icon. A day's cache, not the
144// editor bundle's immutable year: the path carries no content hash,
145// so a cached copy has to expire on its own for a redrawn icon ever
146// to reach anyone.
147func serveIcon(w http.ResponseWriter, r *http.Request) {
148 b, err := fs.ReadFile(brandFS, "brand/icon.svg")
149 if err != nil {
150 http.NotFound(w, r)
151 return
152 }
153 w.Header().Set("Content-Type", iconContentType)
154 w.Header().Set("Cache-Control", "public, max-age=86400")
155 http.ServeContent(w, r, "icon.svg", time.Time{}, bytes.NewReader(b))
156}
157
158// editorBundleContentType is set explicitly (http.ServeContent would
159// otherwise guess from the ".js" extension via the OS mime registry,
160// which on some platforms maps it to "application/javascript" and on
161// others to "text/javascript" — RFC 9239's now-preferred type) rather
162// than left to vary by host.
163const editorBundleContentType = "text/javascript; charset=utf-8"
164
165// serveEditorBundle writes the embedded bundle with a year-long,
166// immutable Cache-Control. That is safe only because the filename
167// carries the content hash: the URL changes whenever the bytes do
168// (fixtures/build-bundles.mjs), so there is nothing for a stale
169// browser cache entry to collide with — see that script's own doc
170// comment for the reasoning this depends on.
171func serveBundle(w http.ResponseWriter, r *http.Request, fileName string) {
172 b, err := fs.ReadFile(staticFS, "static/"+fileName)
173 if err != nil {
174 http.NotFound(w, r)
175 return
176 }
177 w.Header().Set("Content-Type", editorBundleContentType)
178 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
179 http.ServeContent(w, r, fileName, time.Time{}, bytes.NewReader(b))
180}
181
182// staticHandler composes this app's own committed static assets (the
183// editor bundle, at its one fixed resolved-at-init path, and the tab
184// icon at iconPath) with fallback — render.go's rastrillo.Assets tree (the
185// vendored design-system CSS/JS) — into the single handler app.go
186// mounts at "/static/*". Both trees share that URL prefix, so this is
187// one composed handler rather than two competing route registrations
188// (chi does not allow registering the same pattern twice, and a
189// second wildcard route would only ever shadow or conflict with the
190// first).
191//
192// Not behind RequireSession: app.go mounts this alongside /signin and
193// the other unauthenticated routes, ahead of the r.Group that adds
194// RequireSession — a signed-out reader loading the sign-in-adjacent
195// shell, or the editor script itself, must not be redirected in a
196// loop or 403 on essential assets.
197func staticHandler(fallback http.Handler) http.Handler {
198 // Resolved once, into a path→filename map, rather than compared
199 // against a growing list of paths on every request.
200 paths := make(map[string]string, len(bundleNames))
201 for _, name := range bundleNames {
202 paths["/static/"+name] = name
203 }
204 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
205 // The committed bundles first, by exact path. A map rather
206 // than a case per bundle: there are two now (editor and
207 // jumpto) and the switch below would have to grow a case for
208 // each, which is the shape that eventually forgets one.
209 if name, ok := paths[r.URL.Path]; ok {
210 serveBundle(w, r, name)
211 return
212 }
213 switch r.URL.Path {
214 case iconPath:
215 serveIcon(w, r)
216 return
217 }
218 if serveFont(w, r) {
219 return
220 }
221 fallback.ServeHTTP(w, r)
222 })
223}
224
225// serveFont answers a request for one of the core font faces
226// (internal/fonts) or for the licence they are redistributed under,
227// and reports whether it did.
228//
229// Same immutable caching as the editor bundle above, safe for the same
230// reason: internal/fonts puts a content hash in every face's own path,
231// so the URL changes whenever the bytes do. The path is matched
232// against the registry rather than joined onto a directory, which is
233// what makes a traversal attempt ("/static/fonts/../../etc/passwd")
234// simply not match anything rather than something to defend against.
235//
236// Unauthenticated, like everything else under /static: a font is not
237// document content, and a signed-out reader on the sign-in page is
238// served the same shell.
239func serveFont(w http.ResponseWriter, r *http.Request) bool {
240 if !strings.HasPrefix(r.URL.Path, fonts.PathPrefix) {
241 return false
242 }
243 if r.URL.Path == fonts.LicencePath {
244 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
245 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
246 http.ServeContent(w, r, "LICENSE-OFL-1.1.txt", time.Time{}, strings.NewReader(fonts.Licence()))
247 return true
248 }
249 face, ok := fonts.Asset(r.URL.Path)
250 if !ok {
251 return false
252 }
253 w.Header().Set("Content-Type", "font/ttf")
254 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
255 http.ServeContent(w, r, face.File, time.Time{}, bytes.NewReader(face.Data))
256 return true
257}
258