theme v1
This commit is contained in:
@@ -89,6 +89,12 @@ func Init() error {
|
||||
// ini 键留空会被 int 解析为 0;<1 的值一律回退内置默认,防止上限被静默置零。
|
||||
sanitizeLimits(&Limits)
|
||||
|
||||
// 主题:默认 slug 与启用清单。段缺失时 MapTo 得到空值,运行期回退内置 default,
|
||||
// 因此老配置文件不写 [theme] 也能正常启动。
|
||||
if err := File.Section("theme").MapTo(&Theme); err != nil {
|
||||
return errors.Wrap(err, "map 'theme'")
|
||||
}
|
||||
|
||||
if err := File.Section("mail").MapTo(&Mail); err != nil {
|
||||
return errors.Wrap(err, "map 'mail'")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ package conf
|
||||
var (
|
||||
BuildTime string
|
||||
BuildCommit = "dev"
|
||||
|
||||
// StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。
|
||||
StaticVersion = "20261019e"
|
||||
)
|
||||
|
||||
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
|
||||
@@ -35,6 +38,15 @@ var (
|
||||
FontPath string `ini:"font_path"`
|
||||
}
|
||||
|
||||
// Theme 主题系统([theme] 段)。
|
||||
// default :站点默认主题 slug,留空/非法回退内置 default(= 现在的外观)。
|
||||
// available :启用哪些主题(逗号分隔),同时是箱主设置页的白名单;
|
||||
// 列了但缺少 css 文件的会被忽略并告警(见 internal/theme)。
|
||||
Theme struct {
|
||||
Default string `ini:"default"`
|
||||
Available string `ini:"available"`
|
||||
}
|
||||
|
||||
Security struct {
|
||||
EnableTextCensor bool `ini:"enable_text_censor"`
|
||||
IPBlacklist string `ini:"ip_blacklist"`
|
||||
|
||||
@@ -284,6 +284,8 @@ func Contexter() flamego.Handler {
|
||||
|
||||
c.Data["CurrentURI"] = ctx.Request().Request.RequestURI
|
||||
c.Data["ExternalURL"] = conf.App.ExternalURL
|
||||
// 静态资源版本号:base.css 与主题 css 共用同一个值,改样式只需改 conf.StaticVersion。
|
||||
c.Data["StaticVersion"] = conf.StaticVersion
|
||||
|
||||
// 默认头像/背景图兜底:未设置自定义头像的箱主,其二维码 logo 应与页面头像一致回退到 default_avatar。
|
||||
// 模板按原始 URL 直连(easy.qrcode 以 crossOrigin:'anonymous' 加载,配合对象存储桶 CORS
|
||||
|
||||
+38
-24
@@ -40,6 +40,7 @@ type UsersStore interface {
|
||||
// UpdateModerationStrictness 更新内容审核严格度(lax / standard / strict,空值表示跟随站点默认)。
|
||||
// 只写 moderation_strictness 列,不影响防骚扰与屏蔽词。
|
||||
UpdateModerationStrictness(ctx context.Context, id uint, strictness string) error
|
||||
UpdateTheme(ctx context.Context, id uint, theme string) error
|
||||
Authenticate(ctx context.Context, email, password string) (*User, error)
|
||||
ChangePassword(ctx context.Context, id uint, oldPassword, newPassword string) error
|
||||
UpdatePassword(ctx context.Context, id uint, newPassword string) error
|
||||
@@ -85,30 +86,33 @@ type users struct {
|
||||
|
||||
type User struct {
|
||||
gorm.Model `json:"-"`
|
||||
UID string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Password string `json:"-"`
|
||||
Email string `json:"-" gorm:"uniqueIndex:idx_users_email_unique"`
|
||||
Avatar string `json:"avatar"`
|
||||
Domain string `json:"domain" gorm:"uniqueIndex:idx_users_domain_unique"`
|
||||
Background string `json:"background"`
|
||||
Intro string `json:"intro"`
|
||||
Introcolor string `json:"introcolor"`
|
||||
Usernamecolor string `json:"usernamecolor"`
|
||||
Qrcodebackcolor string `json:"qrcodebackcolor"`
|
||||
Qrcodecolor string `json:"qrcodecolor"`
|
||||
BackgroundImage string `json:"backgroundimage"`
|
||||
Dotscale string `json:"dotscale"`
|
||||
Acfunlink string `json:"acfunlink"`
|
||||
Bililink string `json:"bililink"`
|
||||
Weibolink string `json:"weibolink"`
|
||||
Bloglink string `json:"bloglink"`
|
||||
Xiaohongshulink string `json:"xiaohongshulink"`
|
||||
Qrcodepdpcolor string `json:"qrcodepdpcolor"`
|
||||
Backgroundimagealpha string `json:"Backgroundimagealpha"`
|
||||
Notify NotifyType `json:"notify"`
|
||||
HarassmentSetting HarassmentSettingType `json:"harassment_setting" gorm:"default:'none'"`
|
||||
BlockWords string `json:"-"`
|
||||
UID string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Password string `json:"-"`
|
||||
Email string `json:"-" gorm:"uniqueIndex:idx_users_email_unique"`
|
||||
Avatar string `json:"avatar"`
|
||||
Domain string `json:"domain" gorm:"uniqueIndex:idx_users_domain_unique"`
|
||||
Background string `json:"background"`
|
||||
Intro string `json:"intro"`
|
||||
Introcolor string `json:"introcolor"`
|
||||
Usernamecolor string `json:"usernamecolor"`
|
||||
Qrcodebackcolor string `json:"qrcodebackcolor"`
|
||||
Qrcodecolor string `json:"qrcodecolor"`
|
||||
BackgroundImage string `json:"backgroundimage"`
|
||||
Dotscale string `json:"dotscale"`
|
||||
Acfunlink string `json:"acfunlink"`
|
||||
Bililink string `json:"bililink"`
|
||||
Weibolink string `json:"weibolink"`
|
||||
Bloglink string `json:"bloglink"`
|
||||
Xiaohongshulink string `json:"xiaohongshulink"`
|
||||
Qrcodepdpcolor string `json:"qrcodepdpcolor"`
|
||||
Backgroundimagealpha string `json:"Backgroundimagealpha"`
|
||||
// Theme 箱主选择的主题 slug(空串=跟随站点默认,default=基础外观)。
|
||||
// 取值合法性由 route/user 在写入前校验,读取侧由 internal/theme 回退。
|
||||
Theme string `json:"theme" gorm:"column:theme;type:varchar(32);default:''"`
|
||||
Notify NotifyType `json:"notify"`
|
||||
HarassmentSetting HarassmentSettingType `json:"harassment_setting" gorm:"default:'none'"`
|
||||
BlockWords string `json:"-"`
|
||||
// 防骚扰设置取值 none / register_only(见下方常量)。历史账号此列为空串(建表时无默认值),
|
||||
// 与 none 语义等价但取值不同,会导致「按 == none 判断」的隐性漏判 → 已补 schema 默认值、
|
||||
// Create 显式写入、启动期归一历史空值(见 db.Init 的 normalizeHarassmentSetting)。
|
||||
@@ -445,6 +449,16 @@ func (db *users) UpdateModerationStrictness(ctx context.Context, id uint, strict
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateTheme 仅更新主题 slug,不动其它个性化字段(背景/颜色各自独立保存)。
|
||||
func (db *users) UpdateTheme(ctx context.Context, id uint, theme string) error {
|
||||
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"Theme": theme,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(err, "update user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *users) Authenticate(ctx context.Context, email, password string) (*User, error) {
|
||||
u, err := db.GetByEmail(ctx, email)
|
||||
if err != nil {
|
||||
|
||||
@@ -26,6 +26,13 @@ type UpdateProfile struct {
|
||||
Language string `form:"language"`
|
||||
}
|
||||
|
||||
// UpdateProfileTheme 主题外观。空串表示跟随站点默认主题;
|
||||
// 取值在白名单之外时保存被拒绝(见 route/user.UpdateTheme),
|
||||
// 这样箱主不会「选了个不存在的主题,然后看起来什么都没变」。
|
||||
type UpdateProfileTheme struct {
|
||||
Theme string `valid:"maxlen:32" label:"主题" msg:"profile.theme.invalid"`
|
||||
}
|
||||
|
||||
// UpdateHarassment 仅「允许注册用户向我提问」开关(防骚扰类型)。
|
||||
// 全局屏蔽词为独立入口 UpdateBlockWords:二者分处不同设置面板保存,
|
||||
// 若仍共用一个结构体会互相覆盖对方字段(见 route/user/harassment.go 注释)。
|
||||
|
||||
@@ -930,4 +930,14 @@ var enDict = map[string]string{
|
||||
"form.register.recaptcha": "Unable to load the CAPTCHA. Please refresh the page or switch your network and try again",
|
||||
"form.register.agreeRules": "Please read and accept the community rules first",
|
||||
"form.register.repeatPassword": "The two passwords do not match",
|
||||
|
||||
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
|
||||
"profile.theme.title": "Theme",
|
||||
"profile.theme.hint": "Applies to your question box pages only; the rest of the site keeps the default look.",
|
||||
"profile.theme.followDefault": "Follow site default",
|
||||
"profile.theme.save": "Save theme",
|
||||
"profile.theme.saved": "Theme saved",
|
||||
"profile.theme.invalid": "That theme is unavailable — pick one from the list",
|
||||
"theme.name.default": "Default look",
|
||||
"theme.name.sakura": "Sakura",
|
||||
}
|
||||
|
||||
@@ -930,4 +930,14 @@ var jaDict = map[string]string{
|
||||
"form.register.recaptcha": "CAPTCHAを読み込めません。ページを再読み込みするか、ネットワークを変更してお試しください",
|
||||
"form.register.agreeRules": "コミュニティルールをお読みのうえ同意してください",
|
||||
"form.register.repeatPassword": "入力した2つのパスワードが一致しません",
|
||||
|
||||
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
|
||||
"profile.theme.title": "テーマ",
|
||||
"profile.theme.hint": "質問箱のページにのみ適用されます。他のページは既定の外観のままです。",
|
||||
"profile.theme.followDefault": "サイト既定に従う",
|
||||
"profile.theme.save": "テーマを保存",
|
||||
"profile.theme.saved": "テーマを保存しました",
|
||||
"profile.theme.invalid": "このテーマは利用できません。一覧から選択してください",
|
||||
"theme.name.default": "標準の外観",
|
||||
"theme.name.sakura": "さくら",
|
||||
}
|
||||
|
||||
@@ -872,6 +872,16 @@ var zhCNDict = map[string]string{
|
||||
"profile.saveSocial": "保存社交链接",
|
||||
"profile.err.cropLibUnavailable": "图片裁剪组件加载失败,请刷新页面后重试",
|
||||
|
||||
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
|
||||
"profile.theme.title": "主题外观",
|
||||
"profile.theme.hint": "仅对提问箱页面生效,站内其它页面保持默认外观。",
|
||||
"profile.theme.followDefault": "跟随站点默认",
|
||||
"profile.theme.save": "保存主题",
|
||||
"profile.theme.saved": "主题已保存",
|
||||
"profile.theme.invalid": "该主题不可用,请从列表中选择",
|
||||
"theme.name.default": "基础外观",
|
||||
"theme.name.sakura": "樱花",
|
||||
|
||||
// 验证码 JSON 接口:前端消费(P2)
|
||||
"captcha.json.badRequest": "请求格式错误",
|
||||
"captcha.json.idRequired": "验证码 ID 不能为空",
|
||||
|
||||
@@ -930,4 +930,14 @@ var zhTWDict = map[string]string{
|
||||
"form.register.recaptcha": "無法人機驗證,請重新整理頁面或更換網路後重試",
|
||||
"form.register.agreeRules": "請先閱讀並同意社群規則",
|
||||
"form.register.repeatPassword": "兩次輸入的密碼不一致",
|
||||
|
||||
// 主题:设置页选择器与主题显示名(theme.name.<slug> 由 internal/theme 约定)
|
||||
"profile.theme.title": "主題外觀",
|
||||
"profile.theme.hint": "僅對提問箱頁面生效,站內其他頁面維持預設外觀。",
|
||||
"profile.theme.followDefault": "跟隨站點預設",
|
||||
"profile.theme.save": "儲存主題",
|
||||
"profile.theme.saved": "主題已儲存",
|
||||
"profile.theme.invalid": "該主題無法使用,請從清單中選擇",
|
||||
"theme.name.default": "基礎外觀",
|
||||
"theme.name.sakura": "櫻花",
|
||||
}
|
||||
|
||||
@@ -346,6 +346,7 @@ func New() *flamego.Flame {
|
||||
f.Post("/update", form.Bind(form.UpdateProfile{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdateProfile)
|
||||
f.Post("/export", user.ExportProfile)
|
||||
f.Post("/export-json", user.ExportProfileJSON)
|
||||
f.Post("/theme", form.Bind(form.UpdateProfileTheme{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdateTheme)
|
||||
f.Combo("/deactivate").Get(user.DeactivateProfile).Post(user.DeactivateProfileAction)
|
||||
|
||||
f.Post("/update-page-password", form.Bind(form.UpdatePagePassword{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdatePagePassword)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// Package theme 维护箱主主题的注册表与取值回退。
|
||||
//
|
||||
// 一个主题就是一个 CSS 文件(static/css/themes/<slug>.css),只覆盖设计令牌:
|
||||
// 配色、页面背景图/图案,不涉及布局。新增主题不需要改任何模板或 Go 代码,
|
||||
// 只要放文件并在 [theme] available 里登记。
|
||||
package theme
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"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%/]+\))$`)
|
||||
)
|
||||
|
||||
// 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」
|
||||
)
|
||||
|
||||
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 = ""
|
||||
}
|
||||
return list, def, bySlug
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
return 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
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/tamakyi/TamaBox/static"
|
||||
)
|
||||
|
||||
// 主题必须自带的一套颜色令牌(形状/字体/阴影允许继承基础层)。
|
||||
var requiredTokens = []string{
|
||||
"--tb-bg", "--tb-surface", "--tb-surface-2", "--tb-surface-hover", "--tb-border",
|
||||
"--tb-text", "--tb-text-secondary", "--tb-text-muted",
|
||||
"--tb-primary", "--tb-primary-weak",
|
||||
}
|
||||
|
||||
func TestBuildAvailable(t *testing.T) {
|
||||
list, def, _ := build("sakura", "default, sakura ,bad_Slug,missing")
|
||||
|
||||
assert.Equal(t, "sakura", def, "站点默认应取配置值")
|
||||
var slugs []string
|
||||
for _, item := range list {
|
||||
slugs = append(slugs, item.Slug)
|
||||
}
|
||||
// bad_Slug 非法、missing 没有 css 文件,都应被忽略
|
||||
assert.Equal(t, []string{"default", "sakura"}, slugs)
|
||||
}
|
||||
|
||||
func TestBuildInvalidDefault(t *testing.T) {
|
||||
_, def, _ := build("does-not-exist", "sakura")
|
||||
assert.Equal(t, "", def, "默认主题不可用时回退基础外观")
|
||||
|
||||
_, def, _ = build("default", "default,sakura")
|
||||
assert.Equal(t, "", def, "default 语义即基础外观,不应产生 css 请求")
|
||||
}
|
||||
|
||||
func TestResolve(t *testing.T) {
|
||||
// 站点默认是 sakura
|
||||
themes, siteDef, index := build("sakura", "default,sakura")
|
||||
require.Equal(t, "sakura", siteDef)
|
||||
require.Contains(t, index, "sakura")
|
||||
require.NotEmpty(t, themes)
|
||||
|
||||
assert.Equal(t, "sakura", resolve("", siteDef, index), "未选择时跟随站点默认")
|
||||
assert.Equal(t, "sakura", resolve("SAKURA", siteDef, index), "大小写不敏感")
|
||||
assert.Equal(t, "", resolve("default", siteDef, index), "显式选 default = 基础外观,不回退站点默认")
|
||||
assert.Equal(t, "sakura", resolve("nope", siteDef, index), "非法值回退站点默认")
|
||||
|
||||
// 站点默认是基础外观
|
||||
_, def2, index2 := build("default", "default,sakura")
|
||||
assert.Equal(t, "", resolve("", def2, index2))
|
||||
assert.Equal(t, "sakura", resolve("sakura", def2, index2))
|
||||
}
|
||||
|
||||
// TestThemeCSSComplete 防止「复制底稿只写了浅色块」这类问题:
|
||||
// 半套主题不会报错,只会让深色模式变成浅色配色。
|
||||
func TestThemeCSSComplete(t *testing.T) {
|
||||
entries, err := fs.ReadDir(static.FS, strings.TrimSuffix(themeCSSDir, "/"))
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, entries)
|
||||
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".css") {
|
||||
continue
|
||||
}
|
||||
slug := strings.TrimSuffix(name, ".css")
|
||||
// _scaffold.css 是给人复制的模板,default.css 永不加载(仅提供色块预览)
|
||||
if strings.HasPrefix(slug, "_") || slug == DefaultSlug {
|
||||
continue
|
||||
}
|
||||
|
||||
raw, err := static.FS.ReadFile(themeCSSDir + name)
|
||||
require.NoError(t, err)
|
||||
css := string(raw)
|
||||
|
||||
light := ruleBody(css, `html[data-theme-style="`+slug+`"]`)
|
||||
require.NotEmpty(t, light, "%s:缺少浅色块 html[data-theme-style=\"%s\"]", name, slug)
|
||||
|
||||
dark := ruleBody(css, `html[data-theme="dark"][data-theme-style="`+slug+`"]`)
|
||||
require.NotEmpty(t, dark, "%s:缺少深色块 html[data-theme=\"dark\"][data-theme-style=\"%s\"]", name, slug)
|
||||
|
||||
for _, tok := range requiredTokens {
|
||||
assert.NotEmpty(t, tokenValue(light, tok), "%s 浅色块缺少 %s", name, tok)
|
||||
assert.NotEmpty(t, tokenValue(dark, tok), "%s 深色块缺少 %s", name, tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwatch(t *testing.T) {
|
||||
raw, err := static.FS.ReadFile(themeCSSDir + "sakura.css")
|
||||
require.NoError(t, err)
|
||||
sw := swatchOf(string(raw), "sakura")
|
||||
assert.True(t, strings.HasPrefix(sw, "linear-gradient("), "swatch 应为渐变串,实际 %q", sw)
|
||||
assert.Contains(t, sw, "#e5678f")
|
||||
|
||||
assert.Equal(t, "", swatchOf("/* 空文件 */", "sakura"), "取不到颜色时返回空串")
|
||||
assert.Equal(t, "", swatchOf(`html[data-theme-style="x"] { --tb-bg: url(evil); --tb-primary: #fff; }`, "x"),
|
||||
"非色值内容不得进入 swatch")
|
||||
}
|
||||
Reference in New Issue
Block a user