diff --git a/docs/content/administration/customizing-gitea.en-us.md b/docs/content/administration/customizing-gitea.en-us.md index 8475f6d13143..e472fefbbf03 100644 --- a/docs/content/administration/customizing-gitea.en-us.md +++ b/docs/content/administration/customizing-gitea.en-us.md @@ -383,13 +383,28 @@ To make a custom theme available to all users: The value of `$GITEA_CUSTOM` of your instance can be queried by calling `gitea help` and looking up the value of "CustomPath". 2. Add `` to the comma-separated list of setting `THEMES` in `app.ini`, or leave `THEMES` empty to allow all themes. +A custom theme file named `theme-my-theme.css` will be displayed as `my-theme` on the user's theme selection page. +It could add theme meta information into the custom theme CSS file to provide more information about the theme. + +If a custom theme is a dark theme, please set the global css variable `--is-dark-theme: true` in the `:root` block. +This allows Gitea to adjust the Monaco code editor's theme accordingly. +An "auto" theme could be implemented by using "theme-gitea-auto.css" as a reference. + +```css +gitea-theme-meta-info { + --theme-display-name: "My Awesome Theme"; /* this theme will be display as "My Awesome Theme" on the UI */ +} +:root { + --is-dark-theme: true; /* if it is a dark theme */ + --color-primary: #112233; + /* more custom theme variables ... */ +} +``` + Community themes are listed in [gitea/awesome-gitea#themes](https://gitea.com/gitea/awesome-gitea#themes). The default theme sources can be found [here](https://github.com/go-gitea/gitea/blob/main/web_src/css/themes). -If your custom theme is considered a dark theme, set the global css variable `--is-dark-theme` to `true`. -This allows Gitea to adjust the Monaco code editor's theme accordingly. - ## Customizing fonts Fonts can be customized using CSS variables: diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go index e5ff8570cf95..32735882e0db 100644 --- a/routers/web/user/setting/profile.go +++ b/routers/web/user/setting/profile.go @@ -319,13 +319,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() var hiddenCommentTypes *big.Int val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes) diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go index dc801e1ff7ef..58aea3bc74f6 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 - } - name, ok = strings.CutSuffix(name, ".css") - 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))) } - 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 000000000000..587953ab0c82 --- /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 4fa248910a1f..362f73bcb802 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.css b/web_src/css/themes/theme-gitea-auto.css index 509889e80225..cca49be99e8e 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 c1a6edaf35e4..0a009a93c1aa 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 45102b64f594..6b6159323e55 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 f42fa1db2c21..88520879cefa 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 8c7fc8a00d9a..f503a9d030a8 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;