diff --git a/cmd/web.go b/cmd/web.go index 87965a7c1e..f455b97180 100644 --- a/cmd/web.go +++ b/cmd/web.go @@ -16,6 +16,7 @@ import ( _ "net/http/pprof" // Used for debugging if enabled and a web server is running + "forgejo.org/models/theme" "forgejo.org/modules/container" "forgejo.org/modules/graceful" "forgejo.org/modules/log" @@ -217,6 +218,13 @@ func serveInstalled(_ context.Context, ctx *cli.Command) error { } } + // Load themes + themes, tlerr := theme.GetThemeIds() + if (tlerr != nil) { + log.Error("Failed to load themes: ", tlerr) + } + setting.UI.Themes = themes + // Set up Chi routes webRoutes := routers.NormalRoutes() err := listen(webRoutes, true) 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..d5c117b2e9 --- /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 + // Recommended means the theme is guaranteed to be bug-free, usually a first-party theme. + Recommended 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.Recommended = kv["recommended"] == "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 +} diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index 3015be3ecd..977cc22e49 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -92,5 +92,9 @@ "discussion.locked": "This discussion has been locked. Commenting is limited to contributors.", "editor.textarea.tab_hint": "Line already indented. Press Tab again or Escape to leave the editor.", "editor.textarea.shift_tab_hint": "No indentation on this line. Press Shift + Tab again or Escape to leave the editor.", + "settings.theme_recommended": "Recommended", + "settings.theme_light": "Light", + "settings.theme_dark": "Dark", + "settings.theme_auto": "Auto (per your browser)", "meta.last_line": "Thank you for translating Forgejo! This line isn't seen by the users but it serves other purposes in the translation management. You can place a fun fact in the translation instead of translating it." } diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go index fe6ffb802d..464e99b55d 100644 --- a/routers/web/user/setting/profile.go +++ b/routers/web/user/setting/profile.go @@ -19,6 +19,7 @@ import ( "forgejo.org/models/db" "forgejo.org/models/organization" repo_model "forgejo.org/models/repo" + "forgejo.org/models/theme" user_model "forgejo.org/models/user" "forgejo.org/modules/base" "forgejo.org/modules/log" @@ -329,15 +330,15 @@ func Repos(ctx *context.Context) { func Appearance(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings.appearance") ctx.Data["PageIsSettingsAppearance"] = true - ctx.Data["AllThemes"] = setting.UI.Themes - ctx.Data["ThemeName"] = func(themeName string) string { - fullThemeName := "themes.names." + themeName - if ctx.Locale.HasKey(fullThemeName) { - return ctx.Locale.TrString(fullThemeName) - } - return themeName + + themes, err := theme.GetThemes() + if err != nil { + ctx.ServerError("Failed to load themes", err) + return } + ctx.Data["AllThemes"] = themes + var hiddenCommentTypes *big.Int val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes) if err != nil { diff --git a/templates/user/settings/appearance.tmpl b/templates/user/settings/appearance.tmpl index df4d6f3999..f9c6fd95fc 100644 --- a/templates/user/settings/appearance.tmpl +++ b/templates/user/settings/appearance.tmpl @@ -7,37 +7,66 @@