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 @@
-
- {{ctx.Locale.Tr "settings.theme_desc"}} -
- -
- {{.CsrfTokenHtml}} -
- - + {{$theme := index .AllThemes $.SignedUser.Theme}} + {{if $theme.Author}} +
+

+ {{$theme.Name}} + by {{$theme.Author}} {{if $theme.Recommended}}{{svg "octicon-star" 16 "dropdown icon"}}{{end}} +

+ {{if $theme.Description}} +

"{{$theme.Description}}"

+ {{end}}
-
- -
- +
+ {{ctx.Locale.Tr "settings.theme_desc"}} +
+ {{else}} +
+ {{ctx.Locale.Tr "settings.theme_desc"}} +
+ {{end}} + +
+ {{.CsrfTokenHtml}} +
+ + +
+ +
+ +
+
diff --git a/tools/watch.sh b/tools/watch.sh index 5e8defa49c..8c63d44b59 100644 --- a/tools/watch.sh +++ b/tools/watch.sh @@ -1,8 +1,13 @@ #!/bin/bash set -euo pipefail -make --no-print-directory watch-frontend & -make --no-print-directory watch-backend & +make --no-print-directory watch-frontend | tee >( + awk -v pattern="webpack" ' + $0 ~ pattern { + system("make --no-print-directory watch-backend &") + } + ' +) & trap 'kill $(jobs -p)' EXIT wait diff --git a/web_src/css/themes/theme-forgejo-auto-deuteranopia-protanopia.css b/web_src/css/themes/theme-forgejo-auto-deuteranopia-protanopia.css deleted file mode 100644 index 5f97fa377c..0000000000 --- a/web_src/css/themes/theme-forgejo-auto-deuteranopia-protanopia.css +++ /dev/null @@ -1,2 +0,0 @@ -@import "theme-forgejo-light-deuteranopia-protanopia.css"; -@import "theme-forgejo-dark-deuteranopia-protanopia.css" (prefers-color-scheme: dark); diff --git a/web_src/css/themes/theme-forgejo-auto-tritanopia.css b/web_src/css/themes/theme-forgejo-auto-tritanopia.css deleted file mode 100644 index 256a7038a2..0000000000 --- a/web_src/css/themes/theme-forgejo-auto-tritanopia.css +++ /dev/null @@ -1,2 +0,0 @@ -@import "theme-forgejo-light-tritanopia.css"; -@import "theme-forgejo-dark-tritanopia.css" (prefers-color-scheme: dark); diff --git a/web_src/css/themes/theme-forgejo-auto.css b/web_src/css/themes/theme-forgejo-auto.css index ebf59942ea..368ccbee54 100644 --- a/web_src/css/themes/theme-forgejo-auto.css +++ b/web_src/css/themes/theme-forgejo-auto.css @@ -1,2 +1,12 @@ +/* theme forgejo-auto + Name Forgejo + Family Forgejo + Author Forgejo authors + Url https://forgejo.org/ + Description The fresh of freedom. + Scheme Auto + Recommended +*/ + @import "theme-forgejo-light.css"; @import "theme-forgejo-dark.css" (prefers-color-scheme: dark); diff --git a/web_src/css/themes/theme-forgejo-dark-deuteranopia-protanopia.css b/web_src/css/themes/theme-forgejo-dark-deuteranopia-protanopia.css deleted file mode 100644 index e0765aba4a..0000000000 --- a/web_src/css/themes/theme-forgejo-dark-deuteranopia-protanopia.css +++ /dev/null @@ -1,9 +0,0 @@ -@import "./theme-forgejo-dark.css"; - -:root { - /* removed rows/words: use red colors from vanilla forgejo-dark */ - --color-diff-added-word-bg: #214d88; - --color-diff-added-row-border: #214d88; - --color-diff-added-row-bg: #18184f; - --color-code-bg: #0d1117; -} diff --git a/web_src/css/themes/theme-forgejo-dark-tritanopia.css b/web_src/css/themes/theme-forgejo-dark-tritanopia.css deleted file mode 100644 index e4fc303481..0000000000 --- a/web_src/css/themes/theme-forgejo-dark-tritanopia.css +++ /dev/null @@ -1,9 +0,0 @@ -@import "./theme-forgejo-dark.css"; - -:root { - /* removed rows/words: use red colors from vanilla forgejo-dark */ - --color-diff-added-word-bg: #214d88; - --color-diff-added-row-border: #214d88; - --color-diff-added-row-bg: #152846; - --color-code-bg: #0d1117; -} diff --git a/web_src/css/themes/theme-forgejo-dark.css b/web_src/css/themes/theme-forgejo-dark.css index b1b80510d4..0282535107 100644 --- a/web_src/css/themes/theme-forgejo-dark.css +++ b/web_src/css/themes/theme-forgejo-dark.css @@ -1,3 +1,13 @@ +/* theme forgejo-dark + Name Forgejo + Family Forgejo + Author Forgejo authors + Url https://forgejo.org/ + Description The fresh of freedom. + Scheme Dark + Recommended +*/ + @import "../chroma/dark.css"; @import "../codemirror/dark.css"; @import "../markup/dark.css"; diff --git a/web_src/css/themes/theme-forgejo-light-deuteranopia-protanopia.css b/web_src/css/themes/theme-forgejo-light-deuteranopia-protanopia.css deleted file mode 100644 index 8744cbb581..0000000000 --- a/web_src/css/themes/theme-forgejo-light-deuteranopia-protanopia.css +++ /dev/null @@ -1,11 +0,0 @@ -@import "./theme-forgejo-light.css"; - -:root { - --color-diff-removed-word-bg: #c8c850; - --color-diff-removed-row-border: #c8c850; - --color-diff-removed-row-bg: #ffecc4; - --color-diff-added-word-bg: #b8c0ff; - --color-diff-added-row-border: #b8c0ff; - --color-diff-added-row-bg: #e0e0ff; - --color-code-bg: #ffffff; -} diff --git a/web_src/css/themes/theme-forgejo-light-tritanopia.css b/web_src/css/themes/theme-forgejo-light-tritanopia.css deleted file mode 100644 index 3f875e4a7f..0000000000 --- a/web_src/css/themes/theme-forgejo-light-tritanopia.css +++ /dev/null @@ -1,11 +0,0 @@ -@import "./theme-forgejo-light.css"; - -:root { - --color-diff-removed-word-bg: #ffb8c0; - --color-diff-removed-row-border: #ffb8c0; - --color-diff-removed-row-bg: #ffd8d8; - --color-diff-added-word-bg: #b8c0ff; - --color-diff-added-row-border: #b8c0ff; - --color-diff-added-row-bg: #c0e8ff; - --color-code-bg: #ffffff; -} diff --git a/web_src/css/themes/theme-forgejo-light.css b/web_src/css/themes/theme-forgejo-light.css index 277b52165f..e99ffee75f 100644 --- a/web_src/css/themes/theme-forgejo-light.css +++ b/web_src/css/themes/theme-forgejo-light.css @@ -1,3 +1,13 @@ +/* theme forgejo-light + Name Forgejo + Family Forgejo + Author Forgejo authors + Url https://forgejo.org/ + Description The fresh of freedom. + Scheme Light + Recommended +*/ + @import "../chroma/light.css"; @import "../codemirror/light.css"; @import "../markup/light.css"; diff --git a/web_src/css/themes/theme-gitea-auto.css b/web_src/css/themes/theme-gitea-auto.css index 509889e802..41b977df1b 100644 --- a/web_src/css/themes/theme-gitea-auto.css +++ b/web_src/css/themes/theme-gitea-auto.css @@ -1,2 +1,12 @@ +/* theme gitea-auto + Name Gitea + Family Gitea + Author The Gitea Authors + Url https://github.com/go-gitea/gitea + Description The OG look. + Scheme Auto + Recommended +*/ + @import "./theme-gitea-light.css" (prefers-color-scheme: light); @import "./theme-gitea-dark.css" (prefers-color-scheme: dark); diff --git a/web_src/css/themes/theme-gitea-dark.css b/web_src/css/themes/theme-gitea-dark.css index 7ba428f0b7..cb49e4a628 100644 --- a/web_src/css/themes/theme-gitea-dark.css +++ b/web_src/css/themes/theme-gitea-dark.css @@ -1,3 +1,13 @@ +/* theme gitea-dark + Name Gitea + Family Gitea + Author The Gitea Authors + Url https://github.com/go-gitea/gitea + Description The OG look. + Scheme Dark + Recommended +*/ + @import "../chroma/dark.css"; @import "../codemirror/dark.css"; @import "../markup/dark.css"; diff --git a/web_src/css/themes/theme-gitea-light.css b/web_src/css/themes/theme-gitea-light.css index 8ad89f44a4..7b3f154c82 100644 --- a/web_src/css/themes/theme-gitea-light.css +++ b/web_src/css/themes/theme-gitea-light.css @@ -1,3 +1,13 @@ +/* theme gitea-light + Name Gitea + Family Gitea + Author The Gitea Authors + Url https://github.com/go-gitea/gitea + Description The OG look. + Scheme Light + Recommended +*/ + @import "../chroma/light.css"; @import "../codemirror/light.css"; @import "../markup/light.css"; diff --git a/webpack.config.js b/webpack.config.js index 7729035972..9260399396 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -157,6 +157,7 @@ export default { minify: true, css: true, legalComments: 'none', + exclude: [new RegExp(`css\\/(${Object.keys(themes).join('|')})\\.css$`)], }), ], splitChunks: {