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 | |