package theme import ( "io/fs" "regexp" "strings" "testing" "github.com/tamakyi/TamaBox/static" ) var ruleRe = regexp.MustCompile(`(?s)([^{}]+)\{([^{}]*)\}`) // TestNarrowMediaOverridesScopedDeco 守一个只会「静默失效」的坑: // 窄屏媒体查询里写的装饰层覆盖,若特异性不如页面作用域块,就完全不生效。 // // 作用域块是 html[data-theme-style][data-page-scope="box"](两个属性), // 而 @media 里常见的写法是 html[data-theme-style](一个属性)—— // 媒体查询不会提升特异性,于是桌面的装饰层在手机上照旧铺开, // 表现就是「加了素材,窄屏糊在卡片上」。深色作用域块有三个属性,更难压。 // // 判定:外层每一处「不全是 none」的 --tb-deco 声明,窄屏媒体查询里必须有一处属性名集合 // 相同、属性个数不少于它、值相同或更宽泛的选择器再声明一次 // (媒体查询在文件更后,同特异性必胜)。本来就声明成 none 的(如深色直接关掉装饰层)不算。 func TestNarrowMediaOverridesScopedDeco(t *testing.T) { checked := 0 for _, slug := range discover() { name := themeCSSDir + slug + ".css" b, err := fs.ReadFile(static.FS, name) if err != nil { t.Fatalf("读取 %s: %v", name, err) } css := commentRe.ReplaceAllString(string(b), "") inner, outer := splitNarrow(css) outerSels := decoSels(outer, true) if len(outerSels) == 0 { continue } narrowSels := decoSels(inner, false) for _, s := range outerSels { checked++ if !coveredBy(s, narrowSels) { t.Errorf("%s: 窄屏 @media (max-width: 640px) 里没有能压住 `%s` 的装饰层声明"+ "(同属性名、属性数不少于它)—— 该作用域在手机上会沿用桌面装饰层", name, s.raw) } } } t.Logf("共校验 %d 处作用域装饰层声明", checked) } // splitNarrow 把 css 拆成「窄屏媒体查询内部」与「其余」两部分。 // 任何 max-width 断点都算(shiroko 用的是 900px,按内容宽度自己定更合理)。 func splitNarrow(css string) (inner, outer string) { var rest strings.Builder for { i := strings.Index(css, "@media (max-width:") if i < 0 { rest.WriteString(css) return inner, rest.String() } rest.WriteString(css[:i]) open := strings.IndexByte(css[i:], '{') if open < 0 { return inner, rest.String() } open += i depth, end := 0, -1 for j := open; j < len(css); j++ { switch css[j] { case '{': depth++ case '}': depth-- if depth == 0 { end = j } } if end >= 0 { break } } if end < 0 { return inner, rest.String() } inner += css[open+1 : end] css = css[end+1:] } } type decoSel struct { raw string attrs map[string]string // 属性名 → 值(空值表示只检查属性存在) } // decoSels 取出所有声明了 --tb-deco 的规则里的选择器。 // skipNone 用于外层:值全是 none 的本来就没有装饰层可压,不必要求窄屏再声明一次。 // 窄屏侧不能跳(那里往往正是靠 none 来收掉桌面装饰层)。 func decoSels(css string, skipNone bool) []decoSel { var out []decoSel for _, m := range ruleRe.FindAllStringSubmatch(css, -1) { value := declarationValue(m[2], "--tb-deco:") if value == "" || (skipNone && allNone(value)) { continue } for _, one := range strings.Split(m[1], ",") { one = strings.TrimSpace(one) if strings.HasPrefix(one, "@") { continue } out = append(out, decoSel{raw: one, attrs: attrMap(one)}) } } return out } func declarationValue(body, key string) string { i := strings.Index(body, key) if i < 0 { return "" } rest := body[i+len(key):] if j := strings.IndexByte(rest, ';'); j >= 0 { rest = rest[:j] } return strings.TrimSpace(rest) } func allNone(v string) bool { layers := splitTopLevel(v) if len(layers) == 0 { return false } for _, l := range layers { if l != "none" { return false } } return true } // attrMap 解析选择器里的 [name="value"],只保留属性名与值。 func attrMap(sel string) map[string]string { out := map[string]string{} for _, m := range regexp.MustCompile(`\[([a-zA-Z-]+)(?:="?([^"\]]*)"?)?\]`).FindAllStringSubmatch(sel, -1) { out[m[1]] = m[2] } return out } // coveredBy 判断媒体查询里的某个选择器是否能在同特异性下压过 outer。 func coveredBy(outer decoSel, narrow []decoSel) bool { for _, n := range narrow { if len(n.attrs) != len(outer.attrs) { continue } ok := true for k, v := range outer.attrs { nv, has := n.attrs[k] if !has || (nv != "" && nv != v) { ok = false break } } if ok { return true } } return false }