diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go
index ebf682bf58..7577036a55 100644
--- a/routers/web/user/setting/profile.go
+++ b/routers/web/user/setting/profile.go
@@ -338,13 +338,7 @@ func Repos(ctx *context.Context) {
func Appearance(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings.appearance")
ctx.Data["PageIsSettingsAppearance"] = true
-
- allThemes := webtheme.GetAvailableThemes()
- if webtheme.IsThemeAvailable(setting.UI.DefaultTheme) {
- allThemes = util.SliceRemoveAll(allThemes, setting.UI.DefaultTheme)
- allThemes = append([]string{setting.UI.DefaultTheme}, allThemes...) // move the default theme to the top
- }
- ctx.Data["AllThemes"] = allThemes
+ ctx.Data["AllThemes"] = webtheme.GetAvailableThemes()
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
var hiddenCommentTypes *big.Int
diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go
index dc801e1ff7..58aea3bc74 100644
--- a/services/webtheme/webtheme.go
+++ b/services/webtheme/webtheme.go
@@ -4,6 +4,7 @@
package webtheme
import (
+ "regexp"
"sort"
"strings"
"sync"
@@ -12,63 +13,154 @@ import (
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
)
var (
- availableThemes []string
- availableThemesSet container.Set[string]
- themeOnce sync.Once
+ availableThemes []*ThemeMetaInfo
+ availableThemeInternalNames container.Set[string]
+ themeOnce sync.Once
)
+const (
+ fileNamePrefix = "theme-"
+ fileNameSuffix = ".css"
+)
+
+type ThemeMetaInfo struct {
+ FileName string
+ InternalName string
+ DisplayName string
+}
+
+func parseThemeMetaInfoToMap(cssContent string) map[string]string {
+ /*
+ The theme meta info is stored in the CSS file's variables of `gitea-theme-meta-info` element,
+ which is a privately defined and is only used by backend to extract the meta info.
+ Not using ":root" because it is difficult to parse various ":root" blocks when importing other files,
+ it is difficult to control the overriding, and it's difficult to avoid user's customized overridden styles.
+ */
+ metaInfoContent := cssContent
+ if pos := strings.LastIndex(metaInfoContent, "gitea-theme-meta-info"); pos >= 0 {
+ metaInfoContent = metaInfoContent[pos:]
+ }
+
+ reMetaInfoItem := `
+(
+\s*(--[-\w]+)
+\s*:
+\s*(
+("(\\"|[^"])*")
+|('(\\'|[^'])*')
+|([^'";]+)
+)
+\s*;
+\s*
+)
+`
+ reMetaInfoItem = strings.ReplaceAll(reMetaInfoItem, "\n", "")
+ reMetaInfoBlock := `\bgitea-theme-meta-info\s*\{(` + reMetaInfoItem + `+)\}`
+ re := regexp.MustCompile(reMetaInfoBlock)
+ matchedMetaInfoBlock := re.FindAllStringSubmatch(metaInfoContent, -1)
+ if len(matchedMetaInfoBlock) == 0 {
+ return nil
+ }
+ re = regexp.MustCompile(strings.ReplaceAll(reMetaInfoItem, "\n", ""))
+ matchedItems := re.FindAllStringSubmatch(matchedMetaInfoBlock[0][1], -1)
+ m := map[string]string{}
+ for _, item := range matchedItems {
+ v := item[3]
+ if strings.HasPrefix(v, `"`) {
+ v = strings.TrimSuffix(strings.TrimPrefix(v, `"`), `"`)
+ v = strings.ReplaceAll(v, `\"`, `"`)
+ } else if strings.HasPrefix(v, `'`) {
+ v = strings.TrimSuffix(strings.TrimPrefix(v, `'`), `'`)
+ v = strings.ReplaceAll(v, `\'`, `'`)
+ }
+ m[item[2]] = v
+ }
+ return m
+}
+
+func defaultThemeMetaInfoByFileName(fileName string) *ThemeMetaInfo {
+ themeInfo := &ThemeMetaInfo{
+ FileName: fileName,
+ InternalName: strings.TrimSuffix(strings.TrimPrefix(fileName, fileNamePrefix), fileNameSuffix),
+ }
+ themeInfo.DisplayName = themeInfo.InternalName
+ return themeInfo
+}
+
+func defaultThemeMetaInfoByInternalName(fileName string) *ThemeMetaInfo {
+ return defaultThemeMetaInfoByFileName(fileNamePrefix + fileName + fileNameSuffix)
+}
+
+func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo {
+ themeInfo := defaultThemeMetaInfoByFileName(fileName)
+ m := parseThemeMetaInfoToMap(cssContent)
+ if m == nil {
+ return themeInfo
+ }
+ themeInfo.DisplayName = m["--theme-display-name"]
+ return themeInfo
+}
+
func initThemes() {
availableThemes = nil
defer func() {
- availableThemesSet = container.SetOf(availableThemes...)
- if !availableThemesSet.Contains(setting.UI.DefaultTheme) {
+ availableThemeInternalNames = container.Set[string]{}
+ for _, theme := range availableThemes {
+ availableThemeInternalNames.Add(theme.InternalName)
+ }
+ if !availableThemeInternalNames.Contains(setting.UI.DefaultTheme) {
setting.LogStartupProblem(1, log.ERROR, "Default theme %q is not available, please correct the '[ui].DEFAULT_THEME' setting in the config file", setting.UI.DefaultTheme)
}
}()
cssFiles, err := public.AssetFS().ListFiles("/assets/css")
if err != nil {
log.Error("Failed to list themes: %v", err)
- availableThemes = []string{setting.UI.DefaultTheme}
+ availableThemes = []*ThemeMetaInfo{defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)}
return
}
- var foundThemes []string
- for _, name := range cssFiles {
- name, ok := strings.CutPrefix(name, "theme-")
- if !ok {
- continue
+ var foundThemes []*ThemeMetaInfo
+ for _, fileName := range cssFiles {
+ if strings.HasPrefix(fileName, fileNamePrefix) && strings.HasSuffix(fileName, fileNameSuffix) {
+ content, err := public.AssetFS().ReadFile("/assets/css/" + fileName)
+ if err != nil {
+ log.Error("Failed to read theme file %q: %v", fileName, err)
+ continue
+ }
+ foundThemes = append(foundThemes, parseThemeMetaInfo(fileName, util.UnsafeBytesToString(content)))
}
- name, ok = strings.CutSuffix(name, ".css")
- if !ok {
- continue
- }
- foundThemes = append(foundThemes, name)
}
if len(setting.UI.Themes) > 0 {
allowedThemes := container.SetOf(setting.UI.Themes...)
for _, theme := range foundThemes {
- if allowedThemes.Contains(theme) {
+ if allowedThemes.Contains(theme.InternalName) {
availableThemes = append(availableThemes, theme)
}
}
} else {
availableThemes = foundThemes
}
- sort.Strings(availableThemes)
+ sort.Slice(availableThemes, func(i, j int) bool {
+ if availableThemes[i].InternalName == setting.UI.DefaultTheme {
+ return true
+ }
+ return availableThemes[i].DisplayName < availableThemes[j].DisplayName
+ })
if len(availableThemes) == 0 {
setting.LogStartupProblem(1, log.ERROR, "No theme candidate in asset files, but Gitea requires there should be at least one usable theme")
- availableThemes = []string{setting.UI.DefaultTheme}
+ availableThemes = []*ThemeMetaInfo{defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)}
}
}
-func GetAvailableThemes() []string {
+func GetAvailableThemes() []*ThemeMetaInfo {
themeOnce.Do(initThemes)
return availableThemes
}
-func IsThemeAvailable(name string) bool {
+func IsThemeAvailable(internalName string) bool {
themeOnce.Do(initThemes)
- return availableThemesSet.Contains(name)
+ return availableThemeInternalNames.Contains(internalName)
}
diff --git a/services/webtheme/webtheme_test.go b/services/webtheme/webtheme_test.go
new file mode 100644
index 0000000000..587953ab0c
--- /dev/null
+++ b/services/webtheme/webtheme_test.go
@@ -0,0 +1,37 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webtheme
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestParseThemeMetaInfo(t *testing.T) {
+ m := parseThemeMetaInfoToMap(`gitea-theme-meta-info {
+ --k1: "v1";
+ --k2: "v\"2";
+ --k3: 'v3';
+ --k4: 'v\'4';
+ --k5: v5;
+}`)
+ assert.Equal(t, map[string]string{
+ "--k1": "v1",
+ "--k2": `v"2`,
+ "--k3": "v3",
+ "--k4": "v'4",
+ "--k5": "v5",
+ }, m)
+
+ // if an auto theme imports others, the meta info should be extracted from the last one
+ // the meta in imported themes should be ignored to avoid incorrect overriding
+ m = parseThemeMetaInfoToMap(`
+@media (prefers-color-scheme: dark) { gitea-theme-meta-info { --k1: foo; } }
+@media (prefers-color-scheme: light) { gitea-theme-meta-info { --k1: bar; } }
+gitea-theme-meta-info {
+ --k2: real;
+}`)
+ assert.Equal(t, map[string]string{"--k2": "real"}, m)
+}
diff --git a/templates/user/settings/appearance.tmpl b/templates/user/settings/appearance.tmpl
index 4fa248910a..362f73bcb8 100644
--- a/templates/user/settings/appearance.tmpl
+++ b/templates/user/settings/appearance.tmpl
@@ -18,7 +18,7 @@
diff --git a/web_src/css/themes/theme-gitea-auto-protanopia-deuteranopia.css b/web_src/css/themes/theme-gitea-auto-protanopia-deuteranopia.css
index bcbf67d13d..418d7daeab 100644
--- a/web_src/css/themes/theme-gitea-auto-protanopia-deuteranopia.css
+++ b/web_src/css/themes/theme-gitea-auto-protanopia-deuteranopia.css
@@ -1,2 +1,6 @@
@import "./theme-gitea-light-protanopia-deuteranopia.css" (prefers-color-scheme: light);
@import "./theme-gitea-dark-protanopia-deuteranopia.css" (prefers-color-scheme: dark);
+
+gitea-theme-meta-info {
+ --theme-display-name: "Auto (Red/Green Colorblind-friendly)";
+}
diff --git a/web_src/css/themes/theme-gitea-auto.css b/web_src/css/themes/theme-gitea-auto.css
index 509889e802..cca49be99e 100644
--- a/web_src/css/themes/theme-gitea-auto.css
+++ b/web_src/css/themes/theme-gitea-auto.css
@@ -1,2 +1,6 @@
@import "./theme-gitea-light.css" (prefers-color-scheme: light);
@import "./theme-gitea-dark.css" (prefers-color-scheme: dark);
+
+gitea-theme-meta-info {
+ --theme-display-name: "Auto";
+}
diff --git a/web_src/css/themes/theme-gitea-dark-protanopia-deuteranopia.css b/web_src/css/themes/theme-gitea-dark-protanopia-deuteranopia.css
index c1a6edaf35..928cb8ba19 100644
--- a/web_src/css/themes/theme-gitea-dark-protanopia-deuteranopia.css
+++ b/web_src/css/themes/theme-gitea-dark-protanopia-deuteranopia.css
@@ -1,5 +1,9 @@
@import "./theme-gitea-dark.css";
+gitea-theme-meta-info {
+ --theme-display-name: "Dark (Red/Green Colorblind-friendly)";
+}
+
/* red/green colorblind-friendly colors */
/* from GitHub: --diffBlob-addition-*, --diffBlob-deletion-*, etc */
:root {
diff --git a/web_src/css/themes/theme-gitea-dark.css b/web_src/css/themes/theme-gitea-dark.css
index 9bc7747697..5ddee0a746 100644
--- a/web_src/css/themes/theme-gitea-dark.css
+++ b/web_src/css/themes/theme-gitea-dark.css
@@ -1,6 +1,10 @@
@import "../chroma/dark.css";
@import "../codemirror/dark.css";
+gitea-theme-meta-info {
+ --theme-display-name: "Dark";
+}
+
:root {
--is-dark-theme: true;
--color-primary: #4183c4;
diff --git a/web_src/css/themes/theme-gitea-light-protanopia-deuteranopia.css b/web_src/css/themes/theme-gitea-light-protanopia-deuteranopia.css
index f42fa1db2c..32d920582c 100644
--- a/web_src/css/themes/theme-gitea-light-protanopia-deuteranopia.css
+++ b/web_src/css/themes/theme-gitea-light-protanopia-deuteranopia.css
@@ -1,5 +1,9 @@
@import "./theme-gitea-light.css";
+gitea-theme-meta-info {
+ --theme-display-name: "Light (Red/Green Colorblind-friendly)";
+}
+
/* red/green colorblind-friendly colors */
/* from GitHub: --diffBlob-addition-*, --diffBlob-deletion-*, etc */
:root {
diff --git a/web_src/css/themes/theme-gitea-light.css b/web_src/css/themes/theme-gitea-light.css
index d7f9debf90..1a4183c0d2 100644
--- a/web_src/css/themes/theme-gitea-light.css
+++ b/web_src/css/themes/theme-gitea-light.css
@@ -1,6 +1,10 @@
@import "../chroma/light.css";
@import "../codemirror/light.css";
+gitea-theme-meta-info {
+ --theme-display-name: "Light";
+}
+
:root {
--is-dark-theme: false;
--color-primary: #4183c4;