Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Offer instance theme packs in the appearance picker

f7a34d8749054b7ff9e1a304a3fc90ccf3786877 · Keenan Nemetz · 1d ago · parent bc95767f

Offer instance theme packs in the appearance picker

Add profile/themes/index.json and one scoped stylesheet per theme. The loader validates ids and names, pairs each manifest entry with profile/themes/<id>.css, and exposes the list to the account picker and to theme.js before first paint. Theme stylesheets are linked before profile/custom.css, so an instance-wide override still wins.

The starter repository now includes an empty manifest and documents the format in its root README. The configuration docs and the Admin Configuration path table describe the same convention.

forge/instancerepo/instancerepo.go +97−13

@@ -12,14 +12,17 @@
12 12 package instancerepo
13 13
14 14 import (
15 "encoding/json"
15 16 "errors"
16 17 "fmt"
17 18 "os"
18 19 "path"
20 "regexp"
19 21 "strings"
20 22 "sync"
21 23 "sync/atomic"
22 24 "time"
25 "unicode/utf8"
23 26
24 27 "github.com/rodent-software/vole/core/git"
25 28 "github.com/rodent-software/vole/forge/repo"
@@ -32,6 +35,10 @@ const (
32 35 PrivateReadmePath = "profile/private/README.md"
33 36 // CSSPath is appended after the built-in stylesheets on every page.
34 37 CSSPath = "profile/custom.css"
38 // ThemesDir holds one stylesheet per selectable theme.
39 ThemesDir = "profile/themes/"
40 // ThemesIndexPath lists the themes the Appearance picker offers.
41 ThemesIndexPath = ThemesDir + "index.json"
35 42 // FaviconPath replaces the built-in favicon links when it exists.
36 43 FaviconPath = "profile/favicon.svg"
37 44 // AssetsPrefix is served under /static/custom/assets/.
@@ -42,13 +49,25 @@ const (
42 49 maxThemeBytes = 8 << 20
43 50 maxFileBytes = 1 << 20
44 51 maxAssets = 100
52 maxThemes = 24
53 maxThemeName = 60
45 54 )
46 55
56 var themeIDRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,40}$`)
57
47 58 // Asset is one file under assets/.
48 59 type Asset struct {
49 60 Body []byte
50 61 }
51 62
63 // Theme is one selectable stylesheet from the instance repository. The
64 // stylesheet is expected to scope its tokens to :root[data-theme="<ID>"].
65 type Theme struct {
66 ID string
67 Name string
68 CSS []byte
69 }
70
52 71 // Snapshot is one commit's contribution to the instance. It is immutable after
53 72 // it is published, so a request can hold it without a lock.
54 73 type Snapshot struct {
@@ -57,6 +76,8 @@ type Snapshot struct {
57 76 PrivateReadme []byte
58 77 CSS []byte
59 78 Favicon []byte
79 // Themes are the selectable theme stylesheets, in manifest order.
80 Themes []Theme
60 81 // Assets maps the path below assets/ to its contents.
61 82 Assets map[string]Asset
62 83 }
@@ -177,13 +198,19 @@ func load(g *git.Repo, tip string) (*Snapshot, error) {
177 198 sha string
178 199 }
179 200 var picks []pick
180 assets := 0
201 assets, themes := 0, 0
181 202 for _, e := range entries {
182 203 if e.Type != "blob" {
183 204 continue
184 205 }
185 206 switch {
186 case e.Name == ReadmePath, e.Name == PrivateReadmePath, e.Name == CSSPath, e.Name == FaviconPath:
207 case e.Name == ReadmePath, e.Name == PrivateReadmePath, e.Name == CSSPath, e.Name == FaviconPath, e.Name == ThemesIndexPath:
208 case strings.HasPrefix(e.Name, ThemesDir) && strings.HasSuffix(e.Name, ".css") &&
209 !strings.Contains(strings.TrimPrefix(e.Name, ThemesDir), "/"):
210 themes++
211 if themes > maxThemes {
212 return nil, fmt.Errorf("%s holds more than %d themes", ThemesDir, maxThemes)
213 }
187 214 case strings.HasPrefix(e.Name, AssetsPrefix):
188 215 if !allowedAsset(e.Name) {
189 216 continue
@@ -223,25 +250,82 @@ func load(g *git.Repo, tip string) (*Snapshot, error) {
223 250 if err != nil {
224 251 return nil, err
225 252 }
253 byPath := make(map[string][]byte, len(picks))
254 for _, p := range picks {
255 byPath[p.path] = bodies[p.sha]
256 }
226 257 snap := &Snapshot{Commit: tip, Assets: map[string]Asset{}}
227 258 for _, p := range picks {
228 body := bodies[p.sha]
229 switch p.path {
230 case ReadmePath:
231 snap.Readme = body
232 case PrivateReadmePath:
233 snap.PrivateReadme = body
234 case CSSPath:
235 snap.CSS = body
236 case FaviconPath:
237 snap.Favicon = body
259 switch {
260 case p.path == ReadmePath:
261 snap.Readme = byPath[p.path]
262 case p.path == PrivateReadmePath:
263 snap.PrivateReadme = byPath[p.path]
264 case p.path == CSSPath:
265 snap.CSS = byPath[p.path]
266 case p.path == FaviconPath:
267 snap.Favicon = byPath[p.path]
268 case p.path == ThemesIndexPath, strings.HasPrefix(p.path, ThemesDir):
269 // Assembled from the manifest below.
238 270 default:
239 snap.Assets[strings.TrimPrefix(p.path, AssetsPrefix)] = Asset{Body: body}
271 snap.Assets[strings.TrimPrefix(p.path, AssetsPrefix)] = Asset{Body: byPath[p.path]}
240 272 }
241 273 }
274 if manifest, ok := byPath[ThemesIndexPath]; ok {
275 themes, err := parseThemes(manifest, byPath)
276 if err != nil {
277 return nil, err
278 }
279 snap.Themes = themes
280 }
242 281 return snap, nil
243 282 }
244 283
284 // parseThemes reads profile/themes/index.json and pairs each entry with the
285 // stylesheet named after its id. A bad manifest is a load error, not a silent
286 // omission: the Admin page shows the message and the previous theme stays in
287 // use, which is how an admin finds the typo.
288 func parseThemes(manifest []byte, byPath map[string][]byte) ([]Theme, error) {
289 var doc struct {
290 Themes []struct {
291 ID string `json:"id"`
292 Name string `json:"name"`
293 } `json:"themes"`
294 }
295 if err := json.Unmarshal(manifest, &doc); err != nil {
296 return nil, fmt.Errorf("%s: %w", ThemesIndexPath, err)
297 }
298 if len(doc.Themes) > maxThemes {
299 return nil, fmt.Errorf("%s lists %d themes, more than the %d allowed", ThemesIndexPath, len(doc.Themes), maxThemes)
300 }
301 seen := map[string]bool{}
302 out := make([]Theme, 0, len(doc.Themes))
303 for _, entry := range doc.Themes {
304 id := strings.TrimSpace(entry.ID)
305 name := strings.TrimSpace(entry.Name)
306 if !themeIDRE.MatchString(id) {
307 return nil, fmt.Errorf("%s: theme id %q must use lowercase letters, digits, and dashes (max 41 characters)", ThemesIndexPath, id)
308 }
309 if id == "system" || strings.HasPrefix(id, "everforest-") {
310 return nil, fmt.Errorf("%s: theme id %q is reserved", ThemesIndexPath, id)
311 }
312 if seen[id] {
313 return nil, fmt.Errorf("%s: theme id %q is listed twice", ThemesIndexPath, id)
314 }
315 seen[id] = true
316 if name == "" || utf8.RuneCountInString(name) > maxThemeName {
317 return nil, fmt.Errorf("%s: theme %q needs a name of 1-%d characters", ThemesIndexPath, id, maxThemeName)
318 }
319 cssPath := ThemesDir + id + ".css"
320 body, ok := byPath[cssPath]
321 if !ok {
322 return nil, fmt.Errorf("%s lists theme %q but %s is missing", ThemesIndexPath, id, cssPath)
323 }
324 out = append(out, Theme{ID: id, Name: name, CSS: body})
325 }
326 return out, nil
327 }
328
245 329 // allowedAsset is the extension whitelist. HTML and scripts are deliberately
246 330 // absent: an asset is served from the forge's origin, and a same-origin document
247 331 // or script is the whole session, not a theme.