Theme info and loader #3

Manually merged
Minecon724 merged 9 commits from theme-info into forgejo 2025-06-03 20:22:29 +02:00
21 changed files with 375 additions and 82 deletions

View file

@ -16,6 +16,7 @@ import (
_ "net/http/pprof" // Used for debugging if enabled and a web server is running
"forgejo.org/models/theme"
"forgejo.org/modules/container"
"forgejo.org/modules/graceful"
"forgejo.org/modules/log"
@ -217,6 +218,13 @@ func serveInstalled(_ context.Context, ctx *cli.Command) error {
}
}
// Load themes
themes, tlerr := theme.GetThemeIds()
if (tlerr != nil) {
log.Error("Failed to load themes: ", tlerr)
}
setting.UI.Themes = themes
// Set up Chi routes
webRoutes := routers.NormalRoutes()
err := listen(webRoutes, true)

View file

@ -0,0 +1,107 @@
package theme
import (
"fmt"
"maps"
"slices"
"strings"
"forgejo.org/modules/assetfs"
"forgejo.org/modules/log"
"forgejo.org/modules/public"
"golang.org/x/sync/singleflight"
)
const (
themeFilenamePrefix = "theme-"
themeFilenameSuffix = ".css"
)
var (
group singleflight.Group
assetFs *assetfs.LayeredFS
loaded bool
loadedThemes map[string]Theme // this static variable feels suspicious
)
func GetThemeIds() ([]string, error) {
themes, err := GetThemes()
if err != nil {
return nil, err
}
return slices.Collect(maps.Keys(themes)), nil
}
// GetThemes gets the installed Themes.
//
// It doesn't install them to settings.UI.Themes.
func GetThemes() (map[string]Theme, error) {
if loaded {
return loadedThemes, nil
}
if assetFs == nil {
assetFs = public.AssetFS()
}
_, err, _ := group.Do("load-themes", func() (any, error) {
_themes, err := loadThemes(assetFs)
log.Info("Loaded %d themes", len(_themes))
if err != nil {
// Retry on error TODO might want to improve that
group.Forget("load-themes")
} else {
loadedThemes = _themes
loaded = true
}
return nil, err
})
return loadedThemes, err
}
// loadThemes scans for and loads themes from an asset filesystem.
//
// Returns: a map of theme ID: theme metadata.
func loadThemes(assetFs *assetfs.LayeredFS) (map[string]Theme, error) {
entries, err := assetFs.ListFiles("assets/css")
if err != nil {
return nil, err
}
themes := make(map[string]Theme)
for _, entry := range entries {
if !(strings.HasPrefix(entry, themeFilenamePrefix) && strings.HasSuffix(entry, themeFilenameSuffix)) {
continue
}
id := entry[len(themeFilenamePrefix) : len(entry) - len(themeFilenameSuffix)]
theme := Theme{
ID: id,
Name: getFriendlyThemeName(id),
}
if err := loadThemeMeta(assetFs, entry, &theme); err != nil {
log.Warn("Failed to load meta of theme %s: %s", entry, err)
}
themes[id] = theme
log.Debug("Found theme: %s (%s)", theme.Name, id)
}
if len(themes) == 0 {
return nil, fmt.Errorf("no themes found")
}
return themes, nil
}

108
models/theme/theme_meta.go Normal file
View file

@ -0,0 +1,108 @@
package theme
import (
"bufio"
"errors"
"fmt"
"io"
"strings"
"forgejo.org/modules/assetfs"
)
// The Theme struct represents the metadata of a theme. Most fields are allowed to be null / blank.
type Theme struct {
// ID is the theme id, e.g., forgejo-dark
ID string
// Name is the theme's readable name, e.g., Forgejo. Note that the scheme is excluded in favor of the Scheme property.
Name string
// Family is the theme's family, e.g. Catppuccin.
Family string
// Author is the theme's author. It may be an invididual, a group, or an entity.
Author string
// Url is the URL to the theme's website, e.g. https://forgejo.org.
Url string
// Description is the theme's short description.
Description string
// Scheme is the theme's color scheme. It must be either: dark, light, or auto.
Scheme string
// Recommended means the theme is guaranteed to be bug-free, usually a first-party theme.
Recommended bool
}
// loadThemeMeta loads theme metadata from a file in a layered FS.
//
// Theme ID is expected to be prefilled. The metadata will be assigned to the passed `theme` object.
func loadThemeMeta(assetFs *assetfs.LayeredFS, filename string, theme *Theme) error {
file, err := assetFs.Open("assets/css/" + filename)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
kv, err := scanThemeForKeyValueMetaMap(file, theme.ID) // ID is expected to be prefilled
if err != nil {
return fmt.Errorf("error scanning theme for meta: %w", err)
}
theme.Name = kv["name"]
theme.Family = kv["family"]
theme.Author = kv["author"]
theme.Url = kv["url"]
theme.Description = kv["description"]
theme.Scheme = strings.ToLower(kv["scheme"])
theme.Recommended = kv["recommended"] == "yes"
return nil
}
// scanThemeForKeyValueMetaMap finds raw key-value metadata in an open file.
func scanThemeForKeyValueMetaMap(file io.Reader, id string) (map[string]string, error) {
var kv = make(map[string]string)
scanner := bufio.NewScanner(file)
ok := false
// webpack adds headers
for scanner.Scan() {
if strings.TrimSpace(scanner.Text()) == "/* theme "+id {
ok = true
break
}
}
if !ok {
return nil, errors.New("theme has no meta")
}
for scanner.Scan() {
line := scanner.Text()
parts := strings.Fields(line)
if len(parts) == 0 {
continue
}
key := strings.ToLower(parts[0])
if key == "*/" {
break
}
if len(parts) == 1 {
kv[key] = "yes"
} else {
kv[key] = strings.Join(parts[1:], " ")
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scanner error: %w", err)
}
return kv, nil
}

View file

@ -0,0 +1,14 @@
package theme
import "strings"
// getFriendlyThemeName converts an raw theme name to a friendly, readable name.
// Example: forgejo-dark -> Forgejo Dark, catppuccin-maroon-auto -> Catppuccin Maroon Auto
func getFriendlyThemeName(themeName string) string {
themeName = strings.ReplaceAll(themeName, "-", " ")
themeName = strings.ToLower(themeName)
themeName = strings.Title(themeName)
return themeName
}

View file

@ -92,5 +92,9 @@
"discussion.locked": "This discussion has been locked. Commenting is limited to contributors.",
"editor.textarea.tab_hint": "Line already indented. Press <kbd>Tab</kbd> again or <kbd>Escape</kbd> to leave the editor.",
"editor.textarea.shift_tab_hint": "No indentation on this line. Press <kbd>Shift</kbd> + <kbd>Tab</kbd> again or <kbd>Escape</kbd> to leave the editor.",
"settings.theme_recommended": "Recommended",
"settings.theme_light": "Light",
"settings.theme_dark": "Dark",
"settings.theme_auto": "Auto (per your browser)",
"meta.last_line": "Thank you for translating Forgejo! This line isn't seen by the users but it serves other purposes in the translation management. You can place a fun fact in the translation instead of translating it."
}

View file

@ -19,6 +19,7 @@ import (
"forgejo.org/models/db"
"forgejo.org/models/organization"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/theme"
user_model "forgejo.org/models/user"
"forgejo.org/modules/base"
"forgejo.org/modules/log"
@ -329,15 +330,15 @@ func Repos(ctx *context.Context) {
func Appearance(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings.appearance")
ctx.Data["PageIsSettingsAppearance"] = true
ctx.Data["AllThemes"] = setting.UI.Themes
ctx.Data["ThemeName"] = func(themeName string) string {
fullThemeName := "themes.names." + themeName
if ctx.Locale.HasKey(fullThemeName) {
return ctx.Locale.TrString(fullThemeName)
}
return themeName
themes, err := theme.GetThemes()
if err != nil {
ctx.ServerError("Failed to load themes", err)
return
}
ctx.Data["AllThemes"] = themes
var hiddenCommentTypes *big.Int
val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes)
if err != nil {

View file

@ -7,37 +7,66 @@
</h4>
<div class="ui attached segment">
<div class="ui email list">
<div class="item">
{{ctx.Locale.Tr "settings.theme_desc"}}
</div>
<form class="ui form" action="{{.Link}}/theme" method="post">
{{.CsrfTokenHtml}}
<div class="field">
<label for="ui">{{ctx.Locale.Tr "settings.ui"}}</label>
<div class="ui selection dropdown" id="ui">
<input name="theme" type="hidden" value="{{.SignedUser.Theme}}">
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="text">
{{- range $i,$a := .AllThemes -}}
{{if eq $.SignedUser.Theme $a}}{{call $.ThemeName $a}}{{end}}
{{- end -}}
</div>
<div class="menu">
{{range $i,$a := .AllThemes}}
<div class="item{{if eq $.SignedUser.Theme $a}} active selected{{end}}" data-value="{{$a}}">
{{call $.ThemeName $a}}
</div>
{{end}}
</div>
</div>
{{$theme := index .AllThemes $.SignedUser.Theme}}
{{if $theme.Author}}
<div class="item">
<p>
<strong>{{$theme.Name}}</strong>
by <em><a href="{{$theme.Url}}">{{$theme.Author}}</a></em> {{if $theme.Recommended}}<span data-tooltip-content="{{ctx.Locale.Tr "settings.theme_recommended"}}">{{svg "octicon-star" 16 "dropdown icon"}}</span>{{end}}
</p>
{{if $theme.Description}}
<p><em>"{{$theme.Description}}"</em></p>
{{end}}
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "settings.update_theme"}}</button>
</div>
</form>
<div class="item tw-mb-auto">
{{ctx.Locale.Tr "settings.theme_desc"}}
</div>
{{else}}
<div class="item tw-mb-4">
{{ctx.Locale.Tr "settings.theme_desc"}}
</div>
{{end}}
<form class="ui form" action="{{.Link}}/theme" method="post">
{{.CsrfTokenHtml}}
<div class="field">
<label for="ui">{{ctx.Locale.Tr "settings.ui"}}</label>
<div class="ui selection dropdown" id="ui">
<input name="theme" type="hidden" value="{{.SignedUser.Theme}}">
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="text">
{{- range $i,$a := .AllThemes -}}
{{if eq $.SignedUser.Theme $i}}
{{$a.Name}}
{{end}}
{{- end -}}
</div>
<div class="menu">
{{range $i,$a := .AllThemes}}
<div class="item{{if eq $.SignedUser.Theme $i}} active selected{{end}} tw-flex " data-value="{{$i}}">
{{$a.Name}}
{{if $a.Recommended}}
<span data-tooltip-content="{{ctx.Locale.Tr "settings.theme_recommended"}}">{{svg "octicon-star" 16 "dropdown icon"}}</span>
{{end}}
{{if eq $a.Scheme "auto"}}
<span data-tooltip-content="{{ctx.Locale.Tr "settings.theme_auto"}}">{{svg "octicon-sync" 16 "dropdown icon"}}</span>
{{else if eq $a.Scheme "dark"}}
<span data-tooltip-content="{{ctx.Locale.Tr "settings.theme_dark"}}">{{svg "octicon-moon" 16 "dropdown icon"}}</span>
{{else if eq $a.Scheme "light"}}
<span data-tooltip-content="{{ctx.Locale.Tr "settings.theme_light"}}">{{svg "octicon-sun" 16 "dropdown icon"}}</span>
{{end}}
</div>
{{end}}
</div>
</div>
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "settings.update_theme"}}</button>
</div>
</form>
</div>
</div>

View file

@ -1,8 +1,13 @@
#!/bin/bash
set -euo pipefail
make --no-print-directory watch-frontend &
make --no-print-directory watch-backend &
make --no-print-directory watch-frontend | tee >(
awk -v pattern="webpack" '
$0 ~ pattern {
system("make --no-print-directory watch-backend &")
}
'
) &
trap 'kill $(jobs -p)' EXIT
wait

View file

@ -1,2 +0,0 @@
@import "theme-forgejo-light-deuteranopia-protanopia.css";
@import "theme-forgejo-dark-deuteranopia-protanopia.css" (prefers-color-scheme: dark);

View file

@ -1,2 +0,0 @@
@import "theme-forgejo-light-tritanopia.css";
@import "theme-forgejo-dark-tritanopia.css" (prefers-color-scheme: dark);

View file

@ -1,2 +1,12 @@
/* theme forgejo-auto
Name Forgejo
Family Forgejo
Author Forgejo authors
Url https://forgejo.org/
Description The fresh of freedom.
Scheme Auto
Recommended
*/
@import "theme-forgejo-light.css";
@import "theme-forgejo-dark.css" (prefers-color-scheme: dark);

View file

@ -1,9 +0,0 @@
@import "./theme-forgejo-dark.css";
:root {
/* removed rows/words: use red colors from vanilla forgejo-dark */
--color-diff-added-word-bg: #214d88;
--color-diff-added-row-border: #214d88;
--color-diff-added-row-bg: #18184f;
--color-code-bg: #0d1117;
}

View file

@ -1,9 +0,0 @@
@import "./theme-forgejo-dark.css";
:root {
/* removed rows/words: use red colors from vanilla forgejo-dark */
--color-diff-added-word-bg: #214d88;
--color-diff-added-row-border: #214d88;
--color-diff-added-row-bg: #152846;
--color-code-bg: #0d1117;
}

View file

@ -1,3 +1,13 @@
/* theme forgejo-dark
Name Forgejo
Family Forgejo
Author Forgejo authors
Url https://forgejo.org/
Description The fresh of freedom.
Scheme Dark
Recommended
*/
@import "../chroma/dark.css";
@import "../codemirror/dark.css";
@import "../markup/dark.css";

View file

@ -1,11 +0,0 @@
@import "./theme-forgejo-light.css";
:root {
--color-diff-removed-word-bg: #c8c850;
--color-diff-removed-row-border: #c8c850;
--color-diff-removed-row-bg: #ffecc4;
--color-diff-added-word-bg: #b8c0ff;
--color-diff-added-row-border: #b8c0ff;
--color-diff-added-row-bg: #e0e0ff;
--color-code-bg: #ffffff;
}

View file

@ -1,11 +0,0 @@
@import "./theme-forgejo-light.css";
:root {
--color-diff-removed-word-bg: #ffb8c0;
--color-diff-removed-row-border: #ffb8c0;
--color-diff-removed-row-bg: #ffd8d8;
--color-diff-added-word-bg: #b8c0ff;
--color-diff-added-row-border: #b8c0ff;
--color-diff-added-row-bg: #c0e8ff;
--color-code-bg: #ffffff;
}

View file

@ -1,3 +1,13 @@
/* theme forgejo-light
Name Forgejo
Family Forgejo
Author Forgejo authors
Url https://forgejo.org/
Description The fresh of freedom.
Scheme Light
Recommended
*/
@import "../chroma/light.css";
@import "../codemirror/light.css";
@import "../markup/light.css";

View file

@ -1,2 +1,12 @@
/* theme gitea-auto
Name Gitea
Family Gitea
Author The Gitea Authors
Url https://github.com/go-gitea/gitea
Description The OG look.
Scheme Auto
Recommended
*/
@import "./theme-gitea-light.css" (prefers-color-scheme: light);
@import "./theme-gitea-dark.css" (prefers-color-scheme: dark);

View file

@ -1,3 +1,13 @@
/* theme gitea-dark
Name Gitea
Family Gitea
Author The Gitea Authors
Url https://github.com/go-gitea/gitea
Description The OG look.
Scheme Dark
Recommended
*/
@import "../chroma/dark.css";
@import "../codemirror/dark.css";
@import "../markup/dark.css";

View file

@ -1,3 +1,13 @@
/* theme gitea-light
Name Gitea
Family Gitea
Author The Gitea Authors
Url https://github.com/go-gitea/gitea
Description The OG look.
Scheme Light
Recommended
*/
@import "../chroma/light.css";
@import "../codemirror/light.css";
@import "../markup/light.css";

View file

@ -157,6 +157,7 @@ export default {
minify: true,
css: true,
legalComments: 'none',
exclude: [new RegExp(`css\\/(${Object.keys(themes).join('|')})\\.css$`)],
}),
],
splitChunks: {