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.

Derive theme metadata from the stylesheet file name

d222b0cf5d23444ea5df9429ec7d3690407dd00c · Keenan Nemetz · 1d ago · parent f7a34d87

Derive theme metadata from the stylesheet file name

profile/themes/index.json is gone. Each profile/themes/<id>.css is self-describing: the file name is the id and the label (acme-ocean.css becomes Acme Ocean), and the rules are scoped to that id. The loader discovers the files, validates the names, and sorts them by label.

Remove the manifest from the starter repository, its README, the Admin Configuration path table, and the configuration docs. An old index.json in an existing repository is ignored.

forge/instancerepo/instancerepo.go +45−54

@@ -12,17 +12,17 @@
12 12 package instancerepo
13 13
14 14 import (
15 "encoding/json"
16 15 "errors"
17 16 "fmt"
18 17 "os"
19 18 "path"
20 19 "regexp"
20 "sort"
21 21 "strings"
22 22 "sync"
23 23 "sync/atomic"
24 24 "time"
25 "unicode/utf8"
25 "unicode"
26 26
27 27 "github.com/rodent-software/vole/core/git"
28 28 "github.com/rodent-software/vole/forge/repo"
@@ -35,10 +35,9 @@ const (
35 35 PrivateReadmePath = "profile/private/README.md"
36 36 // CSSPath is appended after the built-in stylesheets on every page.
37 37 CSSPath = "profile/custom.css"
38 // ThemesDir holds one stylesheet per selectable theme.
38 // ThemesDir holds one stylesheet per selectable theme. The file name is the
39 // theme id and the label: acme-ocean.css is the "Acme Ocean" theme.
39 40 ThemesDir = "profile/themes/"
40 // ThemesIndexPath lists the themes the Appearance picker offers.
41 ThemesIndexPath = ThemesDir + "index.json"
42 41 // FaviconPath replaces the built-in favicon links when it exists.
43 42 FaviconPath = "profile/favicon.svg"
44 43 // AssetsPrefix is served under /static/custom/assets/.
@@ -50,7 +49,6 @@ const (
50 49 maxFileBytes = 1 << 20
51 50 maxAssets = 100
52 51 maxThemes = 24
53 maxThemeName = 60
54 52 )
55 53
56 54 var themeIDRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,40}$`)
@@ -60,8 +58,9 @@ type Asset struct {
60 58 Body []byte
61 59 }
62 60
63 // Theme is one selectable stylesheet from the instance repository. The
64 // stylesheet is expected to scope its tokens to :root[data-theme="<ID>"].
61 // Theme is one selectable stylesheet from the instance repository. The file
62 // name is the id and the source of the label; the stylesheet is expected to
63 // scope its tokens to :root[data-theme="<ID>"].
65 64 type Theme struct {
66 65 ID string
67 66 Name string
@@ -204,7 +203,7 @@ func load(g *git.Repo, tip string) (*Snapshot, error) {
204 203 continue
205 204 }
206 205 switch {
207 case e.Name == ReadmePath, e.Name == PrivateReadmePath, e.Name == CSSPath, e.Name == FaviconPath, e.Name == ThemesIndexPath:
206 case e.Name == ReadmePath, e.Name == PrivateReadmePath, e.Name == CSSPath, e.Name == FaviconPath:
208 207 case strings.HasPrefix(e.Name, ThemesDir) && strings.HasSuffix(e.Name, ".css") &&
209 208 !strings.Contains(strings.TrimPrefix(e.Name, ThemesDir), "/"):
210 209 themes++
@@ -265,65 +264,57 @@ func load(g *git.Repo, tip string) (*Snapshot, error) {
265 264 snap.CSS = byPath[p.path]
266 265 case p.path == FaviconPath:
267 266 snap.Favicon = byPath[p.path]
268 case p.path == ThemesIndexPath, strings.HasPrefix(p.path, ThemesDir):
269 // Assembled from the manifest below.
267 case strings.HasPrefix(p.path, ThemesDir):
268 // Assembled from the file name below.
270 269 default:
271 270 snap.Assets[strings.TrimPrefix(p.path, AssetsPrefix)] = Asset{Body: byPath[p.path]}
272 271 }
273 272 }
274 if manifest, ok := byPath[ThemesIndexPath]; ok {
275 themes, err := parseThemes(manifest, byPath)
276 if err != nil {
273 for _, p := range picks {
274 if !strings.HasPrefix(p.path, ThemesDir) {
275 continue
276 }
277 id := strings.TrimSuffix(strings.TrimPrefix(p.path, ThemesDir), ".css")
278 if err := validThemeID(id); err != nil {
277 279 return nil, err
278 280 }
279 snap.Themes = themes
281 snap.Themes = append(snap.Themes, Theme{ID: id, Name: themeName(id), CSS: byPath[p.path]})
280 282 }
283 sort.Slice(snap.Themes, func(i, j int) bool {
284 if snap.Themes[i].Name != snap.Themes[j].Name {
285 return snap.Themes[i].Name < snap.Themes[j].Name
286 }
287 return snap.Themes[i].ID < snap.Themes[j].ID
288 })
281 289 return snap, nil
282 290 }
283 291
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"`
292 // validThemeID rejects a file name that cannot be a theme id. The file name is
293 // the whole declaration, so a bad one is a load error the Admin page shows; the
294 // previous theme stays in use.
295 func validThemeID(id string) error {
296 if !themeIDRE.MatchString(id) {
297 return fmt.Errorf("%s%s.css: theme id must use lowercase letters, digits, and dashes (max 41 characters)", ThemesDir, id)
294 298 }
295 if err := json.Unmarshal(manifest, &doc); err != nil {
296 return nil, fmt.Errorf("%s: %w", ThemesIndexPath, err)
299 if id == "system" || strings.HasPrefix(id, "everforest-") {
300 return fmt.Errorf("%s%s.css: theme id %q is reserved", ThemesDir, id, id)
297 301 }
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)
302 return nil
303 }
304
305 // themeName turns a file name into the label the picker shows: acme-ocean.css
306 // becomes "Acme Ocean". There is no second place to keep a name.
307 func themeName(id string) string {
308 parts := strings.Split(id, "-")
309 for i, part := range parts {
310 if part == "" {
311 continue
323 312 }
324 out = append(out, Theme{ID: id, Name: name, CSS: body})
313 runes := []rune(part)
314 runes[0] = unicode.ToUpper(runes[0])
315 parts[i] = string(runes)
325 316 }
326 return out, nil
317 return strings.Join(parts, " ")
327 318 }
328 319
329 320 // allowedAsset is the extension whitelist. HTML and scripts are deliberately