Add theme utilities
This commit is contained in:
parent
c23238aaa3
commit
d741cacc00
3 changed files with 229 additions and 0 deletions
107
models/theme/theme_loader.go
Normal file
107
models/theme/theme_loader.go
Normal file
|
@ -0,0 +1,107 @@
|
|||
package theme
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"forgejo.org/modules/assetfs"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/public"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
const (
|
||||
themeFilenamePrefix = "theme-"
|
||||
themeFilenameSuffix = ".css"
|
||||
)
|
||||
|
||||
var (
|
||||
group singleflight.Group
|
||||
assetFs *assetfs.LayeredFS
|
||||
loaded bool
|
||||
loadedThemes map[string]Theme // this static variable feels suspicious
|
||||
)
|
||||
|
||||
func GetThemeIds() ([]string, error) {
|
||||
themes, err := GetThemes()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return slices.Collect(maps.Keys(themes)), nil
|
||||
}
|
||||
|
||||
// GetThemes gets the installed Themes.
|
||||
//
|
||||
// It doesn't install them to settings.UI.Themes.
|
||||
func GetThemes() (map[string]Theme, error) {
|
||||
if loaded {
|
||||
return loadedThemes, nil
|
||||
}
|
||||
|
||||
if assetFs == nil {
|
||||
assetFs = public.AssetFS()
|
||||
}
|
||||
|
||||
_, err, _ := group.Do("load-themes", func() (any, error) {
|
||||
_themes, err := loadThemes(assetFs)
|
||||
|
||||
log.Info("Loaded %d themes", len(_themes))
|
||||
|
||||
if err != nil {
|
||||
// Retry on error TODO might want to improve that
|
||||
group.Forget("load-themes")
|
||||
} else {
|
||||
loadedThemes = _themes
|
||||
loaded = true
|
||||
}
|
||||
|
||||
return nil, err
|
||||
})
|
||||
|
||||
return loadedThemes, err
|
||||
}
|
||||
|
||||
// loadThemes scans for and loads themes from an asset filesystem.
|
||||
//
|
||||
// Returns: a map of theme ID: theme metadata.
|
||||
func loadThemes(assetFs *assetfs.LayeredFS) (map[string]Theme, error) {
|
||||
entries, err := assetFs.ListFiles("assets/css")
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
themes := make(map[string]Theme)
|
||||
|
||||
for _, entry := range entries {
|
||||
if !(strings.HasPrefix(entry, themeFilenamePrefix) && strings.HasSuffix(entry, themeFilenameSuffix)) {
|
||||
continue
|
||||
}
|
||||
|
||||
id := entry[len(themeFilenamePrefix) : len(entry) - len(themeFilenameSuffix)]
|
||||
|
||||
theme := Theme{
|
||||
ID: id,
|
||||
Name: getFriendlyThemeName(id),
|
||||
}
|
||||
|
||||
if err := loadThemeMeta(assetFs, entry, &theme); err != nil {
|
||||
log.Warn("Failed to load meta of theme %s: %s", entry, err)
|
||||
}
|
||||
|
||||
themes[id] = theme
|
||||
|
||||
log.Debug("Found theme: %s (%s)", theme.Name, id)
|
||||
}
|
||||
|
||||
if len(themes) == 0 {
|
||||
return nil, fmt.Errorf("no themes found")
|
||||
}
|
||||
|
||||
return themes, nil
|
||||
}
|
108
models/theme/theme_meta.go
Normal file
108
models/theme/theme_meta.go
Normal file
|
@ -0,0 +1,108 @@
|
|||
package theme
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"forgejo.org/modules/assetfs"
|
||||
)
|
||||
|
||||
// The Theme struct represents the metadata of a theme. Most fields are allowed to be null / blank.
|
||||
type Theme struct {
|
||||
// ID is the theme id, e.g., forgejo-dark
|
||||
ID string
|
||||
// Name is the theme's readable name, e.g., Forgejo. Note that the scheme is excluded in favor of the Scheme property.
|
||||
Name string
|
||||
// Family is the theme's family, e.g. Catppuccin.
|
||||
Family string
|
||||
// Author is the theme's author. It may be an invididual, a group, or an entity.
|
||||
Author string
|
||||
// Url is the URL to the theme's website, e.g. https://forgejo.org.
|
||||
Url string
|
||||
// Description is the theme's short description.
|
||||
Description string
|
||||
// Scheme is the theme's color scheme. It must be either: dark, light, or auto.
|
||||
Scheme string
|
||||
// Verified is whether the theme is verified. It means the theme is guaranteed to be bug-free, which happens more with third-party themes.
|
||||
Verified bool
|
||||
}
|
||||
|
||||
// loadThemeMeta loads theme metadata from a file in a layered FS.
|
||||
//
|
||||
// Theme ID is expected to be prefilled. The metadata will be assigned to the passed `theme` object.
|
||||
func loadThemeMeta(assetFs *assetfs.LayeredFS, filename string, theme *Theme) error {
|
||||
file, err := assetFs.Open("assets/css/" + filename)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
kv, err := scanThemeForKeyValueMetaMap(file, theme.ID) // ID is expected to be prefilled
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("error scanning theme for meta: %w", err)
|
||||
}
|
||||
|
||||
theme.Name = kv["name"]
|
||||
theme.Family = kv["family"]
|
||||
theme.Author = kv["author"]
|
||||
theme.Url = kv["url"]
|
||||
theme.Description = kv["description"]
|
||||
theme.Scheme = strings.ToLower(kv["scheme"])
|
||||
theme.Verified = kv["verified"] == "yes"
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanThemeForKeyValueMetaMap finds raw key-value metadata in an open file.
|
||||
func scanThemeForKeyValueMetaMap(file io.Reader, id string) (map[string]string, error) {
|
||||
var kv = make(map[string]string)
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
ok := false
|
||||
|
||||
// webpack adds headers
|
||||
for scanner.Scan() {
|
||||
if strings.TrimSpace(scanner.Text()) == "/* theme "+id {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return nil, errors.New("theme has no meta")
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
parts := strings.Fields(line)
|
||||
|
||||
if len(parts) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.ToLower(parts[0])
|
||||
|
||||
if key == "*/" {
|
||||
break
|
||||
}
|
||||
|
||||
if len(parts) == 1 {
|
||||
kv[key] = "yes"
|
||||
} else {
|
||||
kv[key] = strings.Join(parts[1:], " ")
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("scanner error: %w", err)
|
||||
}
|
||||
|
||||
return kv, nil
|
||||
}
|
14
models/theme/theme_name.go
Normal file
14
models/theme/theme_name.go
Normal file
|
@ -0,0 +1,14 @@
|
|||
package theme
|
||||
|
||||
import "strings"
|
||||
|
||||
// getFriendlyThemeName converts an raw theme name to a friendly, readable name.
|
||||
// Example: forgejo-dark -> Forgejo Dark, catppuccin-maroon-auto -> Catppuccin Maroon Auto
|
||||
func getFriendlyThemeName(themeName string) string {
|
||||
themeName = strings.ReplaceAll(themeName, "-", " ")
|
||||
|
||||
themeName = strings.ToLower(themeName)
|
||||
themeName = strings.Title(themeName)
|
||||
|
||||
return themeName
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue