From d741cacc000fb5cfb4bf55112f867faa95057eab Mon Sep 17 00:00:00 2001 From: Minecon724 Date: Tue, 3 Jun 2025 13:50:33 +0200 Subject: [PATCH] Add theme utilities --- models/theme/theme_loader.go | 107 ++++++++++++++++++++++++++++++++++ models/theme/theme_meta.go | 108 +++++++++++++++++++++++++++++++++++ models/theme/theme_name.go | 14 +++++ 3 files changed, 229 insertions(+) create mode 100644 models/theme/theme_loader.go create mode 100644 models/theme/theme_meta.go create mode 100644 models/theme/theme_name.go diff --git a/models/theme/theme_loader.go b/models/theme/theme_loader.go new file mode 100644 index 0000000000..9611410784 --- /dev/null +++ b/models/theme/theme_loader.go @@ -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 +} diff --git a/models/theme/theme_meta.go b/models/theme/theme_meta.go new file mode 100644 index 0000000000..e0fc1fb175 --- /dev/null +++ b/models/theme/theme_meta.go @@ -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 +} \ No newline at end of file diff --git a/models/theme/theme_name.go b/models/theme/theme_name.go new file mode 100644 index 0000000000..eeaa4bf568 --- /dev/null +++ b/models/theme/theme_name.go @@ -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 +}