2024-04-24 00:18:41 +08:00
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package webtheme
import (
2025-03-09 05:38:11 +08:00
"regexp"
2024-04-24 00:18:41 +08:00
"sort"
"strings"
"sync"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting"
2025-03-09 05:38:11 +08:00
"code.gitea.io/gitea/modules/util"
2024-04-24 00:18:41 +08:00
)
2026-02-17 22:46:42 +01:00
type themeCollection struct {
themeList [ ] * ThemeMetaInfo
themeMap map [ string ] * ThemeMetaInfo
}
2024-04-24 00:18:41 +08:00
var (
2026-02-17 22:46:42 +01:00
themeMu sync . RWMutex
availableThemes * themeCollection
2024-04-24 00:18:41 +08:00
)
2025-03-09 05:38:11 +08:00
const (
fileNamePrefix = "theme-"
fileNameSuffix = ".css"
)
type ThemeMetaInfo struct {
2025-10-28 18:25:00 +08:00
FileName string
InternalName string
DisplayName string
ColorblindType string
ColorScheme string
}
func ( info * ThemeMetaInfo ) GetDescription ( ) string {
if info . ColorblindType == "red-green" {
return "Red-green colorblind friendly"
}
2025-11-12 02:21:15 +08:00
if info . ColorblindType == "blue-yellow" {
return "Blue-yellow colorblind friendly"
}
2025-10-28 18:25:00 +08:00
return ""
}
func ( info * ThemeMetaInfo ) GetExtraIconName ( ) string {
if info . ColorblindType == "red-green" {
return "gitea-colorblind-redgreen"
}
2025-11-12 02:21:15 +08:00
if info . ColorblindType == "blue-yellow" {
return "gitea-colorblind-blueyellow"
}
2025-10-28 18:25:00 +08:00
return ""
2025-03-09 05:38:11 +08:00
}
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*(
("(\\"|[^"])*")
|('(\\'|[^'])*')
|([^'";]+)
)
2025-10-28 18:25:00 +08:00
\s*;?
2025-03-09 05:38:11 +08:00
\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 ]
2025-06-18 03:48:09 +02:00
if after , ok := strings . CutPrefix ( v , ` " ` ) ; ok {
v = strings . TrimSuffix ( after , ` " ` )
2025-03-09 05:38:11 +08:00
v = strings . ReplaceAll ( v , ` \" ` , ` " ` )
2025-06-18 03:48:09 +02:00
} else if after , ok := strings . CutPrefix ( v , ` ' ` ) ; ok {
v = strings . TrimSuffix ( after , ` ' ` )
2025-03-09 05:38:11 +08:00
v = strings . ReplaceAll ( v , ` \' ` , ` ' ` )
}
m [ item [ 2 ] ] = v
}
return m
}
func defaultThemeMetaInfoByFileName ( fileName string ) * ThemeMetaInfo {
2026-03-29 12:24:30 +02:00
internalName := strings . TrimSuffix ( strings . TrimPrefix ( fileName , fileNamePrefix ) , fileNameSuffix )
// For built-in themes, the manifest knows the unhashed entry name (e.g. "theme-gitea-dark")
// which lets us correctly strip the content hash without guessing.
// Custom themes are not in the manifest and never have content hashes.
if name := public . AssetNameFromHashedPath ( "css/" + fileName ) ; name != "" {
internalName = strings . TrimPrefix ( name , fileNamePrefix )
}
2025-03-09 05:38:11 +08:00
themeInfo := & ThemeMetaInfo {
FileName : fileName ,
2026-03-29 12:24:30 +02:00
InternalName : internalName ,
2025-03-09 05:38:11 +08:00
}
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" ]
2025-10-28 18:25:00 +08:00
themeInfo . ColorblindType = m [ "--theme-colorblind-type" ]
themeInfo . ColorScheme = m [ "--theme-color-scheme" ]
2025-03-09 05:38:11 +08:00
return themeInfo
}
2026-02-17 22:46:42 +01:00
func loadThemesFromAssets ( ) ( themeList [ ] * ThemeMetaInfo , themeMap map [ string ] * ThemeMetaInfo ) {
2026-01-26 10:34:38 +08:00
cssFiles , err := public . AssetFS ( ) . ListFiles ( "assets/css" )
2024-04-24 00:18:41 +08:00
if err != nil {
log . Error ( "Failed to list themes: %v" , err )
2026-02-17 22:46:42 +01:00
return nil , nil
2024-04-24 00:18:41 +08:00
}
2026-02-17 22:46:42 +01:00
2025-03-09 05:38:11 +08:00
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 ) ) )
2024-04-24 00:18:41 +08:00
}
}
2026-02-17 22:46:42 +01:00
themeList = foundThemes
2024-04-24 00:18:41 +08:00
if len ( setting . UI . Themes ) > 0 {
2026-02-17 22:46:42 +01:00
themeList = nil // only allow the themes specified in the setting
2024-04-24 00:18:41 +08:00
allowedThemes := container . SetOf ( setting . UI . Themes ... )
for _ , theme := range foundThemes {
2025-03-09 05:38:11 +08:00
if allowedThemes . Contains ( theme . InternalName ) {
2026-02-17 22:46:42 +01:00
themeList = append ( themeList , theme )
2024-04-24 00:18:41 +08:00
}
}
}
2026-02-17 22:46:42 +01:00
sort . Slice ( themeList , func ( i , j int ) bool {
if themeList [ i ] . InternalName == setting . UI . DefaultTheme {
2025-03-09 05:38:11 +08:00
return true
}
2026-02-17 22:46:42 +01:00
if themeList [ i ] . ColorblindType != themeList [ j ] . ColorblindType {
return themeList [ i ] . ColorblindType < themeList [ j ] . ColorblindType
2025-10-28 18:25:00 +08:00
}
2026-02-17 22:46:42 +01:00
return themeList [ i ] . DisplayName < themeList [ j ] . DisplayName
2025-03-09 05:38:11 +08:00
} )
2026-02-17 22:46:42 +01:00
themeMap = map [ string ] * ThemeMetaInfo { }
for _ , theme := range themeList {
themeMap [ theme . InternalName ] = theme
}
return themeList , themeMap
}
func getAvailableThemes ( ) ( themeList [ ] * ThemeMetaInfo , themeMap map [ string ] * ThemeMetaInfo ) {
themeMu . RLock ( )
if availableThemes != nil {
themeList , themeMap = availableThemes . themeList , availableThemes . themeMap
}
themeMu . RUnlock ( )
if len ( themeList ) != 0 {
return themeList , themeMap
}
themeMu . Lock ( )
defer themeMu . Unlock ( )
// no need to double-check "availableThemes.themeList" since the loading isn't really slow, to keep code simple
themeList , themeMap = loadThemesFromAssets ( )
hasAvailableThemes := len ( themeList ) > 0
if ! hasAvailableThemes {
defaultTheme := defaultThemeMetaInfoByInternalName ( setting . UI . DefaultTheme )
themeList = [ ] * ThemeMetaInfo { defaultTheme }
themeMap = map [ string ] * ThemeMetaInfo { setting . UI . DefaultTheme : defaultTheme }
}
if setting . IsProd {
if ! hasAvailableThemes {
setting . LogStartupProblem ( 1 , log . ERROR , "No theme candidate in asset files, but Gitea requires there should be at least one usable theme" )
}
if themeMap [ setting . UI . DefaultTheme ] == nil {
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 )
}
availableThemes = & themeCollection { themeList , themeMap }
return themeList , themeMap
}
// In dev mode, only store the loaded themes if the list is not empty, in case the frontend is still being built.
// TBH, there still could be a data-race that the themes are only partially built then the list is incomplete for first time loading.
// Such edge case can be handled by checking whether the loaded themes are the same in a period or there is a flag file, but it is an over-kill, so, no.
if hasAvailableThemes {
availableThemes = & themeCollection { themeList , themeMap }
2024-04-24 00:18:41 +08:00
}
2026-02-17 22:46:42 +01:00
return themeList , themeMap
2024-04-24 00:18:41 +08:00
}
2025-03-09 05:38:11 +08:00
func GetAvailableThemes ( ) [ ] * ThemeMetaInfo {
2026-02-17 22:46:42 +01:00
themes , _ := getAvailableThemes ( )
return themes
2024-04-24 00:18:41 +08:00
}
2025-10-28 18:25:00 +08:00
func GetThemeMetaInfo ( internalName string ) * ThemeMetaInfo {
2026-02-17 22:46:42 +01:00
_ , themeMap := getAvailableThemes ( )
return themeMap [ internalName ]
2025-10-28 18:25:00 +08:00
}
// GuaranteeGetThemeMetaInfo guarantees to return a non-nil ThemeMetaInfo,
// to simplify the caller's logic, especially for templates.
// There are already enough warnings messages if the default theme is not available.
func GuaranteeGetThemeMetaInfo ( internalName string ) * ThemeMetaInfo {
info := GetThemeMetaInfo ( internalName )
if info == nil {
info = GetThemeMetaInfo ( setting . UI . DefaultTheme )
}
if info == nil {
info = & ThemeMetaInfo { DisplayName : "unavailable" , InternalName : "unavailable" , FileName : "unavailable" }
}
return info
2024-04-24 00:18:41 +08:00
}