Files
tamakyi 6c014b3ad2 修 test01:窄屏装饰全藏导致素材消失、主题名显示成 i18n key
- 生成器:勾「窄屏隐藏全部装饰层」时给橙色警告(几层会消失)、图层行标「窄屏隐藏」、加层与导出结果再提醒;「窄屏」下拉在被全藏覆盖时标注不生效
- 新增 ?narrow=hide 复现该状态(这是「素材在手机上看不到」的头号来源:@media 把 10 槽全写 none,桌面完全正常)
- 主题名:新增 theme.DisplayName + 模板函数 themeName,i18n 未登记 theme.name.<slug> 时回退 slug;原来模板直接调用 i18n.T,缺词条就显示 "theme.name.test01"
- test01.css 删掉 @media 全 none 段:装饰是 fit 单位,浏览器会自己按视口缩,不需要这段
- StaticVersion → 20261020g(主题 css 内容变了,必须换缓存键,否则浏览器还会用旧文件)
2026-10-20 18:23:21 +08:00

243 lines
7.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package theme 维护箱主主题的注册表与取值回退。
//
// 一个主题就是一个 CSS 文件(static/css/themes/<slug>.css),只覆盖设计令牌:
// 配色、页面背景图/图案,不涉及布局。新增主题不需要改任何模板或 Go 代码,
// 只要放文件并在 [theme] available 里登记。
package theme
import (
"io/fs"
"regexp"
"strings"
"sync"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/static"
)
const (
// DefaultSlug 内置默认主题:不加载任何 css,等价于站点原本的外观。
DefaultSlug = "default"
themeCSSDir = "css/themes/"
)
var (
slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
// 仅接受纯色值,避免把主题文件里的任意内容拼进设置页的 style 属性。
colorRe = regexp.MustCompile(`^(#[0-9a-fA-F]{3,8}|rgba?\([0-9.,\s%/]+\))$`)
// 主题 css 里的块注释;取令牌前先剥掉,避免注释与令牌粘连
commentRe = regexp.MustCompile(`(?s)/\*.*?\*/`)
)
// Theme 是设置页需要的一项。名称由 i18n 键 theme.name.<slug> 提供,
// Swatch 是可直接放进 style 的预览背景(取不到颜色时为空)。
type Theme struct {
Slug string
Swatch string
}
var (
once sync.Once
themes []Theme
bySlug map[string]Theme
siteDef string // 站点默认主题;空串表示「基础外观,不加载主题 css」
)
// DisplayName 返回主题在设置页里的显示名。
//
// 优先取 i18n 的 theme.name.<slug>;**没登记就原样返回 slug**。
// 这条回退是必需的:自建主题(生成器导出的)只放一个 css 就到手,
// 不该为了显示名去改四份词典 —— 否则设置页会把 key 直接印出来(theme.name.test01),
// 而 i18n.T 缺词条时返回 key 本身,调用方无从判断「是缺失还是文案就叫这个」。
func DisplayName(lang, slug string) string {
key := "theme.name." + slug
if name := i18n.T(lang, key); name != key {
return name
}
return slug
}
func load() {
once.Do(func() {
themes, siteDef, bySlug = build(conf.Theme.Default, conf.Theme.Available)
})
}
// build 是注册表的纯函数形态,便于单测直接喂配置。
func build(defaultSlug, available string) ([]Theme, string, map[string]Theme) {
bySlug := make(map[string]Theme)
var list []Theme
add := func(slug string) {
if _, ok := bySlug[slug]; ok {
return
}
css, err := static.FS.ReadFile(themeCSSDir + slug + ".css")
if err != nil {
logrus.WithField("theme", slug).Warn("主题文件不存在,已从可用列表忽略")
return
}
t := Theme{Slug: slug, Swatch: swatchOf(string(css), slug)}
bySlug[slug] = t
list = append(list, t)
}
for _, raw := range strings.Split(available, ",") {
slug := normalize(raw)
if slug == "" {
continue
}
add(slug)
}
def := normalize(defaultSlug)
if def == DefaultSlug || def == "" {
def = ""
} else if _, ok := bySlug[def]; !ok {
logrus.WithField("theme", def).Warn("站点默认主题不可用,回退基础外观")
def = ""
}
reportRegistry(list, bySlug)
return list, def, bySlug
}
// reportRegistry 把注册表状态写进启动日志。
// 没有这段时,主题文件已随二进制发布、却忘了在 [theme] available 登记,
// 表现只是"设置页里找不到这个主题",从日志里完全看不出原因。
func reportRegistry(list []Theme, bySlug map[string]Theme) {
enabled := make([]string, 0, len(list))
for _, t := range list {
enabled = append(enabled, t.Slug)
}
logrus.WithField("enabled", strings.Join(enabled, ",")).Info("主题注册表已加载")
var pending []string
for _, slug := range discover() {
if slug == DefaultSlug {
continue
}
if _, ok := bySlug[slug]; ok {
continue
}
pending = append(pending, slug)
}
if len(pending) > 0 {
logrus.WithFields(logrus.Fields{
"themes": strings.Join(pending, ","),
"available": strings.Join(append(enabled, pending...), ","),
}).Warn("[theme] available 未登记以下主题,它们不会出现在设置页")
}
}
// discover 列出内嵌的主题 css(不符合 slug 规则的模板文件如 _scaffold.css 会被跳过)。
func discover() []string {
matches, err := fs.Glob(static.FS, themeCSSDir+"*.css")
if err != nil {
return nil
}
slugs := make([]string, 0, len(matches))
for _, m := range matches {
slug := strings.TrimSuffix(strings.TrimPrefix(m, themeCSSDir), ".css")
if slugRe.MatchString(slug) {
slugs = append(slugs, slug)
}
}
return slugs
}
func normalize(slug string) string {
slug = strings.ToLower(strings.TrimSpace(slug))
if !slugRe.MatchString(slug) {
if slug != "" {
logrus.WithField("theme", slug).Warn("主题 slug 非法(只允许小写字母/数字/连字符),已忽略")
}
return ""
}
return slug
}
// swatchOf 从主题 css 的浅色块里取底色与主色,拼成设置页的小预览。
// 取不到(或颜色格式可疑)就返回空串,模板退化为纯色占位。
func swatchOf(css, slug string) string {
block := ruleBody(css, `html[data-theme-style="`+slug+`"]`)
if block == "" {
return ""
}
bg := tokenValue(block, "--tb-bg")
primary := tokenValue(block, "--tb-primary")
if !colorRe.MatchString(bg) || !colorRe.MatchString(primary) {
return ""
}
return "linear-gradient(135deg, " + bg + " 0 45%, " + primary + " 45% 100%)"
}
// ruleBody 返回 selector 对应的第一个声明块内容(主题令牌块内不含嵌套规则)。
func ruleBody(css, selector string) string {
i := strings.Index(css, selector)
if i < 0 {
return ""
}
open := strings.IndexByte(css[i:], '{')
if open < 0 {
return ""
}
start := i + open + 1
end := strings.IndexByte(css[start:], '}')
if end < 0 {
return ""
}
// 注释必须剥掉:tokenValue 按 ";" 切分再按 ":" 取值,行内注释会和后面的令牌粘成一段,
// 取到的键变成 "/* ... */ --tb-surface",色块预览就静默失效。
return commentRe.ReplaceAllString(css[start:start+end], "")
}
func tokenValue(block, name string) string {
for _, line := range strings.Split(block, ";") {
kv := strings.SplitN(line, ":", 2)
if len(kv) != 2 || strings.TrimSpace(kv[0]) != name {
continue
}
return strings.TrimSpace(kv[1])
}
return ""
}
// List 返回设置页可选项(顺序与 [theme] available 一致)。
func List() []Theme {
load()
return themes
}
// IsAvailable 判断 slug 是否在白名单内且文件存在。
func IsAvailable(slug string) bool {
load()
_, ok := bySlug[strings.ToLower(strings.TrimSpace(slug))]
return ok
}
// Resolve 返回该页面要加载的主题 slug;空串表示不加载主题 css(基础外观)。
//
// 回退顺序:箱主选择 > 站点默认 > 基础外观。
// 注意 "" 与 "default" 语义不同:"" 是「没选,跟随站点默认」,
// "default" 是箱主明确选择基础外观(即使站点默认是别的主题也不覆盖)。
func Resolve(userTheme string) string {
load()
return resolve(userTheme, siteDef, bySlug)
}
func resolve(userTheme, siteDef string, available map[string]Theme) string {
slug := strings.ToLower(strings.TrimSpace(userTheme))
if slug == DefaultSlug {
return ""
}
if _, ok := available[slug]; ok {
return slug
}
return siteDef
}