v0.9.0 dd8597a

assets.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"crypto/sha256"
7	"embed"
8	"encoding/hex"
9	"errors"
10	"io/fs"
11	"net/http"
12	"path"
13	"sort"
14	"strings"
15)
16
17//go:embed static
18var staticFS embed.FS
19
20// StaticPrefix is where the embedded assets are served.
21const StaticPrefix = "/-/static/"
22
23// Assets are the URLs of the assets a page loads. Their names carry a hash of
24// the content, so they can be cached for ever.
25type Assets struct {
26	CSS, HTMX, Live, SSE, App, Files, Passkey, Log, Icon string
27	// Font is the UI font, preloaded by every page.
28	Font string
29	// Rules is the speculation rules file named by the Speculation-Rules header.
30	Rules string
31}
32
33// asset is one embedded file under its content-hashed name.
34type asset struct {
35	body []byte
36	typ  string
37	// gz is the gzip form of body, compressed once at load; nil when the type
38	// is already compressed or gzip does not make the body smaller.
39	gz []byte
40}
41
42// assetSet is the embedded files by hashed URL path (under StaticPrefix).
43type assetSet struct {
44	byName map[string]asset
45	urls   Assets
46}
47
48// scripts are the JavaScript files of the page, in embedded static/js: the
49// file to serve, as it is named in Assets.
50var scripts = []struct {
51	file string
52	set  func(*Assets, string)
53}{
54	{"htmx.min.js", func(a *Assets, u string) { a.HTMX = u }},
55	{"hx-live.min.js", func(a *Assets, u string) { a.Live = u }},
56	{"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
57	{"ermite.js", func(a *Assets, u string) { a.App = u }},
58	{"files.js", func(a *Assets, u string) { a.Files = u }},
59	{"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
60	{"log.js", func(a *Assets, u string) { a.Log = u }},
61}
62
63// sheetAssets are the files the stylesheet references as url(<dir>/<file>):
64// fonts and icons (pages hold no inline SVG; icons are CSS masks).
65var sheetAssets = []struct{ glob, typ string }{
66	{"static/fonts/*.woff2", "font/woff2"},
67	{"static/icons/*.svg", "image/svg+xml"},
68}
69
70// loadAssets hashes the embedded files. The stylesheet is the concatenation
71// of static/css/*.css in name order; its url(<dir>/<file>) references become
72// the hashed URLs of the files of sheetAssets.
73func loadAssets() (*assetSet, error) {
74	s := &assetSet{byName: map[string]asset{}}
75	put := func(dir, file string, body []byte, typ string) string {
76		sum := sha256.Sum256(body)
77		ext := path.Ext(file)
78		name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext
79		a := asset{body: body, typ: typ}
80		if compressible(typ) {
81			if z, err := gzipBest(body); err == nil && len(z) < len(body) {
82				a.gz = z
83			}
84		}
85		s.byName[name] = a
86		return StaticPrefix + name
87	}
88
89	var refs []string
90	for _, sa := range sheetAssets {
91		files, err := fs.Glob(staticFS, sa.glob)
92		if err != nil {
93			return nil, err
94		}
95		for _, f := range files {
96			b, err := staticFS.ReadFile(f)
97			if err != nil {
98				return nil, err
99			}
100			dir := path.Base(path.Dir(f))
101			u := put(dir, path.Base(f), b, sa.typ)
102			refs = append(refs, "url("+dir+"/"+path.Base(f)+")", "url("+u+")")
103			if f == "static/fonts/atkinson-next.woff2" {
104				s.urls.Font = u
105			}
106		}
107	}
108
109	files, err := fs.Glob(staticFS, "static/css/*.css")
110	if err != nil {
111		return nil, err
112	}
113	sort.Strings(files)
114	var css strings.Builder
115	for _, f := range files {
116		b, err := staticFS.ReadFile(f)
117		if err != nil {
118			return nil, err
119		}
120		css.WriteString("/* " + path.Base(f) + " */\n")
121		css.Write(b)
122	}
123	if s.urls.Font == "" {
124		return nil, errors.New("web: the UI font static/fonts/atkinson-next.woff2 is missing")
125	}
126	sheet := strings.NewReplacer(refs...).Replace(css.String())
127	s.urls.CSS = put("css", "ermite.css", []byte(sheet), "text/css; charset=utf-8")
128
129	rules, err := staticFS.ReadFile("static/rules.json")
130	if err != nil {
131		return nil, err
132	}
133	s.urls.Rules = put("rules", "rules.json", rules, "application/speculationrules+json")
134
135	for _, sc := range scripts {
136		b, err := staticFS.ReadFile("static/js/" + sc.file)
137		if err != nil {
138			return nil, err
139		}
140		sc.set(&s.urls, put("js", sc.file, b, "text/javascript; charset=utf-8"))
141	}
142
143	// The favicon is the brand picture of the header.
144	icon, err := staticFS.ReadFile("static/icons/brand.svg")
145	if err != nil {
146		return nil, err
147	}
148	s.urls.Icon = put("img", "icon.svg", icon, "image/svg+xml")
149	return s, nil
150}
151
152// serve answers GET /-/static/<hashed name>. A name that does not match a
153// current asset is a 404, so a stale hash never serves the wrong content.
154func (s *assetSet) serve(w http.ResponseWriter, r *http.Request) {
155	a, ok := s.byName[strings.TrimPrefix(r.URL.Path, StaticPrefix)]
156	if !ok {
157		http.NotFound(w, r)
158		return
159	}
160	h := w.Header()
161	h.Set("Content-Type", a.typ)
162	h.Set("Cache-Control", "public, max-age=31536000, immutable")
163	body := a.body
164	if a.gz != nil {
165		addVary(h, "Accept-Encoding")
166		if acceptsGzip(r.Header) {
167			body = a.gz
168			h.Set("Content-Encoding", "gzip")
169		}
170	}
171	h.Set("Content-Length", itoa(len(body)))
172	if r.Method != http.MethodHead {
173		_, _ = w.Write(body)
174	}
175}
176
177// compressible tells whether a content type is worth gzip: text and
178// structured text. Fonts (woff2) and raster images are compressed already.
179func compressible(typ string) bool {
180	mt, _, _ := strings.Cut(typ, ";")
181	mt = strings.TrimSpace(strings.ToLower(mt))
182	return strings.HasPrefix(mt, "text/") || mt == "image/svg+xml" ||
183		mt == "application/javascript" || strings.HasSuffix(mt, "+json") || strings.HasSuffix(mt, "/json")
184}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close