2024-04-24 00:18:41 +08:00
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package webtheme
import (
2026-03-30 16:59:10 +02:00
"io/fs"
2026-04-05 21:13:34 +02:00
"net/url"
2026-03-30 16:59:10 +02:00
"os"
"path"
2025-03-09 05:38:11 +08:00
"regexp"
2024-04-24 00:18:41 +08:00
"sort"
"strings"
2026-03-30 16:59:10 +02:00
"sync/atomic"
"time"
2024-04-24 00:18:41 +08:00
"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-03-30 16:59:10 +02:00
type themeCollectionStruct struct {
lastCheckTime time . Time
usingViteDevMode bool
2026-02-17 22:46:42 +01:00
themeList [ ] * ThemeMetaInfo
themeMap map [ string ] * ThemeMetaInfo
}
2026-03-30 16:59:10 +02:00
var themeCollection atomic . Pointer [ themeCollectionStruct ]
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
}
2026-04-05 21:13:34 +02:00
func ( info * ThemeMetaInfo ) PublicAssetURI ( ) string {
return public . AssetURI ( "css/theme-" + url . PathEscape ( info . InternalName ) + ".css" )
}
2025-10-28 18:25:00 +08:00
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-03-30 16:59:10 +02:00
func collectThemeFiles ( dirFS fs . ReadDirFS , fsPath string ) ( themes [ ] * ThemeMetaInfo , _ error ) {
files , err := dirFS . ReadDir ( fsPath )
2024-04-24 00:18:41 +08:00
if err != nil {
2026-03-30 16:59:10 +02:00
return nil , err
2024-04-24 00:18:41 +08:00
}
2026-03-30 16:59:10 +02:00
for _ , file := range files {
fileName := file . Name ( )
if ! strings . HasPrefix ( fileName , fileNamePrefix ) || ! strings . HasSuffix ( fileName , fileNameSuffix ) {
continue
}
content , err := fs . ReadFile ( dirFS , path . Join ( fsPath , file . Name ( ) ) )
if err != nil {
log . Error ( "Failed to read theme file %q: %v" , fileName , err )
continue
2024-04-24 00:18:41 +08:00
}
2026-03-30 16:59:10 +02:00
themes = append ( themes , parseThemeMetaInfo ( fileName , util . UnsafeBytesToString ( content ) ) )
}
return themes , nil
}
func loadThemesFromAssets ( isViteDevMode bool ) ( themeList [ ] * ThemeMetaInfo , themeMap map [ string ] * ThemeMetaInfo ) {
var themeDir fs . ReadDirFS
var themePath string
if isViteDevMode {
// In vite dev mode, Vite serves themes directly from source files.
themeDir , themePath = os . DirFS ( setting . StaticRootPath ) . ( fs . ReadDirFS ) , "web_src/css/themes"
} else {
// Without vite dev server, use built assets from AssetFS.
themeDir , themePath = public . AssetFS ( ) , "assets/css"
}
foundThemes , err := collectThemeFiles ( themeDir , themePath )
if err != nil {
log . Error ( "Failed to load theme files: %v" , err )
return themeList , themeMap
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
}
2026-03-30 16:59:10 +02:00
func getAvailableThemes ( ) * themeCollectionStruct {
themes := themeCollection . Load ( )
now := time . Now ( )
if themes != nil && now . Sub ( themes . lastCheckTime ) < time . Second {
return themes
2026-02-17 22:46:42 +01:00
}
2026-03-30 16:59:10 +02:00
isViteDevMode := public . IsViteDevMode ( )
useLoadedThemes := themes != nil && ( setting . IsProd || themes . usingViteDevMode == isViteDevMode )
if useLoadedThemes && len ( themes . themeList ) > 0 {
return themes
2026-02-17 22:46:42 +01:00
}
2026-03-30 16:59:10 +02:00
themeList , themeMap := loadThemesFromAssets ( isViteDevMode )
2026-02-17 22:46:42 +01:00
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 )
}
}
2026-03-30 16:59:10 +02:00
themes = & themeCollectionStruct { now , isViteDevMode , themeList , themeMap }
themeCollection . Store ( themes )
return themes
2024-04-24 00:18:41 +08:00
}
2025-03-09 05:38:11 +08:00
func GetAvailableThemes ( ) [ ] * ThemeMetaInfo {
2026-03-30 16:59:10 +02:00
return getAvailableThemes ( ) . themeList
2024-04-24 00:18:41 +08:00
}
2025-10-28 18:25:00 +08:00
func GetThemeMetaInfo ( internalName string ) * ThemeMetaInfo {
2026-03-30 16:59:10 +02:00
return getAvailableThemes ( ) . 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
}