Skip to content

Commit 6f13331

Browse files
authoredMar 8, 2025··

10 files changed

+177
-30
lines changed
 

‎routers/web/user/setting/profile.go

+1-7
Original file line numberDiff line numberDiff line change
@@ -338,13 +338,7 @@ func Repos(ctx *context.Context) {
338338
func Appearance(ctx *context.Context) {
339339
ctx.Data["Title"] = ctx.Tr("settings.appearance")
340340
ctx.Data["PageIsSettingsAppearance"] = true
341-
342-
allThemes := webtheme.GetAvailableThemes()
343-
if webtheme.IsThemeAvailable(setting.UI.DefaultTheme) {
344-
allThemes = util.SliceRemoveAll(allThemes, setting.UI.DefaultTheme)
345-
allThemes = append([]string{setting.UI.DefaultTheme}, allThemes...) // move the default theme to the top
346-
}
347-
ctx.Data["AllThemes"] = allThemes
341+
ctx.Data["AllThemes"] = webtheme.GetAvailableThemes()
348342
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
349343

350344
var hiddenCommentTypes *big.Int

‎services/webtheme/webtheme.go

+114-22
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package webtheme
55

66
import (
7+
"regexp"
78
"sort"
89
"strings"
910
"sync"
@@ -12,63 +13,154 @@ import (
1213
"code.gitea.io/gitea/modules/log"
1314
"code.gitea.io/gitea/modules/public"
1415
"code.gitea.io/gitea/modules/setting"
16+
"code.gitea.io/gitea/modules/util"
1517
)
1618

1719
var (
18-
availableThemes []string
19-
availableThemesSet container.Set[string]
20-
themeOnce sync.Once
20+
availableThemes []*ThemeMetaInfo
21+
availableThemeInternalNames container.Set[string]
22+
themeOnce sync.Once
2123
)
2224

25+
const (
26+
fileNamePrefix = "theme-"
27+
fileNameSuffix = ".css"
28+
)
29+
30+
type ThemeMetaInfo struct {
31+
FileName string
32+
InternalName string
33+
DisplayName string
34+
}
35+
36+
func parseThemeMetaInfoToMap(cssContent string) map[string]string {
37+
/*
38+
The theme meta info is stored in the CSS file's variables of `gitea-theme-meta-info` element,
39+
which is a privately defined and is only used by backend to extract the meta info.
40+
Not using ":root" because it is difficult to parse various ":root" blocks when importing other files,
41+
it is difficult to control the overriding, and it's difficult to avoid user's customized overridden styles.
42+
*/
43+
metaInfoContent := cssContent
44+
if pos := strings.LastIndex(metaInfoContent, "gitea-theme-meta-info"); pos >= 0 {
45+
metaInfoContent = metaInfoContent[pos:]
46+
}
47+
48+
reMetaInfoItem := `
49+
(
50+
\s*(--[-\w]+)
51+
\s*:
52+
\s*(
53+
("(\\"|[^"])*")
54+
|('(\\'|[^'])*')
55+
|([^'";]+)
56+
)
57+
\s*;
58+
\s*
59+
)
60+
`
61+
reMetaInfoItem = strings.ReplaceAll(reMetaInfoItem, "\n", "")
62+
reMetaInfoBlock := `\bgitea-theme-meta-info\s*\{(` + reMetaInfoItem + `+)\}`
63+
re := regexp.MustCompile(reMetaInfoBlock)
64+
matchedMetaInfoBlock := re.FindAllStringSubmatch(metaInfoContent, -1)
65+
if len(matchedMetaInfoBlock) == 0 {
66+
return nil
67+
}
68+
re = regexp.MustCompile(strings.ReplaceAll(reMetaInfoItem, "\n", ""))
69+
matchedItems := re.FindAllStringSubmatch(matchedMetaInfoBlock[0][1], -1)
70+
m := map[string]string{}
71+
for _, item := range matchedItems {
72+
v := item[3]
73+
if strings.HasPrefix(v, `"`) {
74+
v = strings.TrimSuffix(strings.TrimPrefix(v, `"`), `"`)
75+
v = strings.ReplaceAll(v, `\"`, `"`)
76+
} else if strings.HasPrefix(v, `'`) {
77+
v = strings.TrimSuffix(strings.TrimPrefix(v, `'`), `'`)
78+
v = strings.ReplaceAll(v, `\'`, `'`)
79+
}
80+
m[item[2]] = v
81+
}
82+
return m
83+
}
84+
85+
func defaultThemeMetaInfoByFileName(fileName string) *ThemeMetaInfo {
86+
themeInfo := &ThemeMetaInfo{
87+
FileName: fileName,
88+
InternalName: strings.TrimSuffix(strings.TrimPrefix(fileName, fileNamePrefix), fileNameSuffix),
89+
}
90+
themeInfo.DisplayName = themeInfo.InternalName
91+
return themeInfo
92+
}
93+
94+
func defaultThemeMetaInfoByInternalName(fileName string) *ThemeMetaInfo {
95+
return defaultThemeMetaInfoByFileName(fileNamePrefix + fileName + fileNameSuffix)
96+
}
97+
98+
func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo {
99+
themeInfo := defaultThemeMetaInfoByFileName(fileName)
100+
m := parseThemeMetaInfoToMap(cssContent)
101+
if m == nil {
102+
return themeInfo
103+
}
104+
themeInfo.DisplayName = m["--theme-display-name"]
105+
return themeInfo
106+
}
107+
23108
func initThemes() {
24109
availableThemes = nil
25110
defer func() {
26-
availableThemesSet = container.SetOf(availableThemes...)
27-
if !availableThemesSet.Contains(setting.UI.DefaultTheme) {
111+
availableThemeInternalNames = container.Set[string]{}
112+
for _, theme := range availableThemes {
113+
availableThemeInternalNames.Add(theme.InternalName)
114+
}
115+
if !availableThemeInternalNames.Contains(setting.UI.DefaultTheme) {
28116
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)
29117
}
30118
}()
31119
cssFiles, err := public.AssetFS().ListFiles("/assets/css")
32120
if err != nil {
33121
log.Error("Failed to list themes: %v", err)
34-
availableThemes = []string{setting.UI.DefaultTheme}
122+
availableThemes = []*ThemeMetaInfo{defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)}
35123
return
36124
}
37-
var foundThemes []string
38-
for _, name := range cssFiles {
39-
name, ok := strings.CutPrefix(name, "theme-")
40-
if !ok {
41-
continue
42-
}
43-
name, ok = strings.CutSuffix(name, ".css")
44-
if !ok {
45-
continue
125+
var foundThemes []*ThemeMetaInfo
126+
for _, fileName := range cssFiles {
127+
if strings.HasPrefix(fileName, fileNamePrefix) && strings.HasSuffix(fileName, fileNameSuffix) {
128+
content, err := public.AssetFS().ReadFile("/assets/css/" + fileName)
129+
if err != nil {
130+
log.Error("Failed to read theme file %q: %v", fileName, err)
131+
continue
132+
}
133+
foundThemes = append(foundThemes, parseThemeMetaInfo(fileName, util.UnsafeBytesToString(content)))
46134
}
47-
foundThemes = append(foundThemes, name)
48135
}
49136
if len(setting.UI.Themes) > 0 {
50137
allowedThemes := container.SetOf(setting.UI.Themes...)
51138
for _, theme := range foundThemes {
52-
if allowedThemes.Contains(theme) {
139+
if allowedThemes.Contains(theme.InternalName) {
53140
availableThemes = append(availableThemes, theme)
54141
}
55142
}
56143
} else {
57144
availableThemes = foundThemes
58145
}
59-
sort.Strings(availableThemes)
146+
sort.Slice(availableThemes, func(i, j int) bool {
147+
if availableThemes[i].InternalName == setting.UI.DefaultTheme {
148+
return true
149+
}
150+
return availableThemes[i].DisplayName < availableThemes[j].DisplayName
151+
})
60152
if len(availableThemes) == 0 {
61153
setting.LogStartupProblem(1, log.ERROR, "No theme candidate in asset files, but Gitea requires there should be at least one usable theme")
62-
availableThemes = []string{setting.UI.DefaultTheme}
154+
availableThemes = []*ThemeMetaInfo{defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)}
63155
}
64156
}
65157

66-
func GetAvailableThemes() []string {
158+
func GetAvailableThemes() []*ThemeMetaInfo {
67159
themeOnce.Do(initThemes)
68160
return availableThemes
69161
}
70162

71-
func IsThemeAvailable(name string) bool {
163+
func IsThemeAvailable(internalName string) bool {
72164
themeOnce.Do(initThemes)
73-
return availableThemesSet.Contains(name)
165+
return availableThemeInternalNames.Contains(internalName)
74166
}

‎services/webtheme/webtheme_test.go

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package webtheme
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestParseThemeMetaInfo(t *testing.T) {
13+
m := parseThemeMetaInfoToMap(`gitea-theme-meta-info {
14+
--k1: "v1";
15+
--k2: "v\"2";
16+
--k3: 'v3';
17+
--k4: 'v\'4';
18+
--k5: v5;
19+
}`)
20+
assert.Equal(t, map[string]string{
21+
"--k1": "v1",
22+
"--k2": `v"2`,
23+
"--k3": "v3",
24+
"--k4": "v'4",
25+
"--k5": "v5",
26+
}, m)
27+
28+
// if an auto theme imports others, the meta info should be extracted from the last one
29+
// the meta in imported themes should be ignored to avoid incorrect overriding
30+
m = parseThemeMetaInfoToMap(`
31+
@media (prefers-color-scheme: dark) { gitea-theme-meta-info { --k1: foo; } }
32+
@media (prefers-color-scheme: light) { gitea-theme-meta-info { --k1: bar; } }
33+
gitea-theme-meta-info {
34+
--k2: real;
35+
}`)
36+
assert.Equal(t, map[string]string{"--k2": "real"}, m)
37+
}

‎templates/user/settings/appearance.tmpl

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
<label>{{ctx.Locale.Tr "settings.ui"}}</label>
1919
<select name="theme" class="ui dropdown">
2020
{{range $theme := .AllThemes}}
21-
<option value="{{$theme}}" {{Iif (eq $.SignedUser.Theme $theme) "selected"}}>{{$theme}}</option>
21+
<option value="{{$theme.InternalName}}" {{Iif (eq $.SignedUser.Theme $theme.InternalName) "selected"}}>{{$theme.DisplayName}}</option>
2222
{{end}}
2323
</select>
2424
</div>
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
@import "./theme-gitea-light-protanopia-deuteranopia.css" (prefers-color-scheme: light);
22
@import "./theme-gitea-dark-protanopia-deuteranopia.css" (prefers-color-scheme: dark);
3+
4+
gitea-theme-meta-info {
5+
--theme-display-name: "Auto (Red/Green Colorblind-friendly)";
6+
}
+4
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
@import "./theme-gitea-light.css" (prefers-color-scheme: light);
22
@import "./theme-gitea-dark.css" (prefers-color-scheme: dark);
3+
4+
gitea-theme-meta-info {
5+
--theme-display-name: "Auto";
6+
}

‎web_src/css/themes/theme-gitea-dark-protanopia-deuteranopia.css

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
@import "./theme-gitea-dark.css";
22

3+
gitea-theme-meta-info {
4+
--theme-display-name: "Dark (Red/Green Colorblind-friendly)";
5+
}
6+
37
/* red/green colorblind-friendly colors */
48
/* from GitHub: --diffBlob-addition-*, --diffBlob-deletion-*, etc */
59
:root {

‎web_src/css/themes/theme-gitea-dark.css

+4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
@import "../chroma/dark.css";
22
@import "../codemirror/dark.css";
33

4+
gitea-theme-meta-info {
5+
--theme-display-name: "Dark";
6+
}
7+
48
:root {
59
--is-dark-theme: true;
610
--color-primary: #4183c4;

‎web_src/css/themes/theme-gitea-light-protanopia-deuteranopia.css

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
@import "./theme-gitea-light.css";
22

3+
gitea-theme-meta-info {
4+
--theme-display-name: "Light (Red/Green Colorblind-friendly)";
5+
}
6+
37
/* red/green colorblind-friendly colors */
48
/* from GitHub: --diffBlob-addition-*, --diffBlob-deletion-*, etc */
59
:root {

‎web_src/css/themes/theme-gitea-light.css

+4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
@import "../chroma/light.css";
22
@import "../codemirror/light.css";
33

4+
gitea-theme-meta-info {
5+
--theme-display-name: "Light";
6+
}
7+
48
:root {
59
--is-dark-theme: false;
610
--color-primary: #4183c4;

0 commit comments

Comments
 (0)
Please sign in to comment.