mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in NOTICE, alongside LICENSE. The header lint accepts any copyright line above the SPDX line, so it no longer demands an upstream one. Co-authored-by: Hanzo Dev <[email protected]>
275 lines
8.2 KiB
Go
275 lines
8.2 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package webtheme
|
|
|
|
import (
|
|
"io/fs"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/hanzoai/git/modules/container"
|
|
"github.com/hanzoai/git/modules/log"
|
|
"github.com/hanzoai/git/modules/public"
|
|
"github.com/hanzoai/git/modules/setting"
|
|
"github.com/hanzoai/git/modules/util"
|
|
)
|
|
|
|
type themeCollectionStruct struct {
|
|
lastCheckTime time.Time
|
|
usingViteDevMode bool
|
|
|
|
themeList []*ThemeMetaInfo
|
|
themeMap map[string]*ThemeMetaInfo
|
|
}
|
|
|
|
var themeCollection atomic.Pointer[themeCollectionStruct]
|
|
|
|
const (
|
|
fileNamePrefix = "theme-"
|
|
fileNameSuffix = ".css"
|
|
)
|
|
|
|
type ThemeMetaInfo struct {
|
|
FileName string
|
|
InternalName string
|
|
DisplayName string
|
|
ColorblindType string
|
|
ColorScheme string
|
|
}
|
|
|
|
func (info *ThemeMetaInfo) PublicAssetURI() string {
|
|
return public.AssetURI("web_src/css/themes/theme-" + url.PathEscape(info.InternalName) + ".css")
|
|
}
|
|
|
|
func (info *ThemeMetaInfo) GetDescription() string {
|
|
if info.ColorblindType == "red-green" {
|
|
return "Red-green colorblind friendly"
|
|
}
|
|
if info.ColorblindType == "blue-yellow" {
|
|
return "Blue-yellow colorblind friendly"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (info *ThemeMetaInfo) GetExtraIconName() string {
|
|
if info.ColorblindType == "red-green" {
|
|
return "hanzo-colorblind-redgreen"
|
|
}
|
|
if info.ColorblindType == "blue-yellow" {
|
|
return "hanzo-colorblind-blueyellow"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseThemeMetaInfoToMap(cssContent string) map[string]string {
|
|
/*
|
|
The theme meta info is stored in the CSS file's variables of `hanzo-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, "hanzo-theme-meta-info"); pos >= 0 {
|
|
metaInfoContent = metaInfoContent[pos:]
|
|
}
|
|
|
|
reMetaInfoItem := `
|
|
(
|
|
\s*(--[-\w]+)
|
|
\s*:
|
|
\s*(
|
|
("(\\"|[^"])*")
|
|
|('(\\'|[^'])*')
|
|
|([^'";]+)
|
|
)
|
|
\s*;?
|
|
\s*
|
|
)
|
|
`
|
|
reMetaInfoItem = strings.ReplaceAll(reMetaInfoItem, "\n", "")
|
|
reMetaInfoBlock := `\bhanzo-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 after, ok := strings.CutPrefix(v, `"`); ok {
|
|
v = strings.TrimSuffix(after, `"`)
|
|
v = strings.ReplaceAll(v, `\"`, `"`)
|
|
} else if after, ok := strings.CutPrefix(v, `'`); ok {
|
|
v = strings.TrimSuffix(after, `'`)
|
|
v = strings.ReplaceAll(v, `\'`, `'`)
|
|
}
|
|
m[item[2]] = v
|
|
}
|
|
return m
|
|
}
|
|
|
|
func defaultThemeMetaInfoByFileName(fileName string) *ThemeMetaInfo {
|
|
internalName := strings.TrimSuffix(strings.TrimPrefix(fileName, fileNamePrefix), fileNameSuffix)
|
|
// For built-in themes, the manifest knows the unhashed entry name (e.g. "theme-hanzo-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)
|
|
}
|
|
themeInfo := &ThemeMetaInfo{
|
|
FileName: fileName,
|
|
InternalName: internalName,
|
|
}
|
|
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"]
|
|
themeInfo.ColorblindType = m["--theme-colorblind-type"]
|
|
themeInfo.ColorScheme = m["--theme-color-scheme"]
|
|
return themeInfo
|
|
}
|
|
|
|
func collectThemeFiles(dirFS fs.ReadDirFS, fsPath string) (themes []*ThemeMetaInfo, _ error) {
|
|
files, err := dirFS.ReadDir(fsPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
themeList = foundThemes
|
|
if len(setting.UI.Themes) > 0 {
|
|
themeList = nil // only allow the themes specified in the setting
|
|
allowedThemes := container.SetOf(setting.UI.Themes...)
|
|
for _, theme := range foundThemes {
|
|
if allowedThemes.Contains(theme.InternalName) {
|
|
themeList = append(themeList, theme)
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.Slice(themeList, func(i, j int) bool {
|
|
if themeList[i].InternalName == setting.UI.DefaultTheme {
|
|
return true
|
|
}
|
|
if themeList[i].ColorblindType != themeList[j].ColorblindType {
|
|
return themeList[i].ColorblindType < themeList[j].ColorblindType
|
|
}
|
|
return themeList[i].DisplayName < themeList[j].DisplayName
|
|
})
|
|
|
|
themeMap = map[string]*ThemeMetaInfo{}
|
|
for _, theme := range themeList {
|
|
themeMap[theme.InternalName] = theme
|
|
}
|
|
return themeList, themeMap
|
|
}
|
|
|
|
func getAvailableThemes() *themeCollectionStruct {
|
|
themes := themeCollection.Load()
|
|
|
|
now := time.Now()
|
|
if themes != nil && now.Sub(themes.lastCheckTime) < time.Second {
|
|
return themes
|
|
}
|
|
|
|
isViteDevMode := public.IsViteDevMode()
|
|
useLoadedThemes := themes != nil && (setting.IsProd || themes.usingViteDevMode == isViteDevMode)
|
|
if useLoadedThemes && len(themes.themeList) > 0 {
|
|
return themes
|
|
}
|
|
|
|
themeList, themeMap := loadThemesFromAssets(isViteDevMode)
|
|
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 Hanzo Git 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)
|
|
}
|
|
}
|
|
|
|
themes = &themeCollectionStruct{now, isViteDevMode, themeList, themeMap}
|
|
themeCollection.Store(themes)
|
|
return themes
|
|
}
|
|
|
|
func GetAvailableThemes() []*ThemeMetaInfo {
|
|
return getAvailableThemes().themeList
|
|
}
|
|
|
|
func GetThemeMetaInfo(internalName string) *ThemeMetaInfo {
|
|
return getAvailableThemes().themeMap[internalName]
|
|
}
|
|
|
|
// 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
|
|
}
|