chore(theme): refresh vendored cms theme copies for font size overrides

Vendors cms backend/internal/theme css.go, defaults.go and the new
fontsize.go so ThemeTypography carries FontSizeOverrides and preset
validation with DisallowUnknownFields keeps accepting current presets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-08-03 21:03:12 +08:00
parent f69e824f78
commit 6979a87d92
3 changed files with 173 additions and 20 deletions

View File

@ -12,6 +12,7 @@ type FontFile struct {
Style string Style string
URL string URL string
Format string // "woff2", "woff", "truetype" Format string // "woff2", "woff", "truetype"
UnicodeRange string // optional; preserves Google's subset splitting for self-hosted fonts
} }
// GenerateCSS generates CSS variable declarations from theme settings // GenerateCSS generates CSS variable declarations from theme settings
@ -31,8 +32,11 @@ func GenerateCSS(theme *Theme, googleFontURLs []string, fontFaces []FontFile) st
font-weight: %s; font-weight: %s;
font-style: %s; font-style: %s;
font-display: swap; font-display: swap;
}
`, ff.Family, ff.URL, ff.Format, ff.Weight, ff.Style) `, ff.Family, ff.URL, ff.Format, ff.Weight, ff.Style)
if ff.UnicodeRange != "" {
fmt.Fprintf(&css, " unicode-range: %s;\n", ff.UnicodeRange)
}
css.WriteString("}\n")
} }
if len(googleFontURLs) > 0 || len(fontFaces) > 0 { if len(googleFontURLs) > 0 || len(fontFaces) > 0 {
@ -63,6 +67,8 @@ func GenerateCSS(theme *Theme, googleFontURLs []string, fontFaces []FontFile) st
// Button class definitions // Button class definitions
WriteButtonClasses(&css, theme.Buttons) WriteButtonClasses(&css, theme.Buttons)
writeFontSizeOverrideRules(&css, theme.Typography)
return css.String() return css.String()
} }
@ -124,15 +130,65 @@ func writeTypographyVars(css *strings.Builder, typography *ThemeTypography) {
writeVar(css, "font-sans", bodyStack) writeVar(css, "font-sans", bodyStack)
writeVar(css, "font-mono", monoStack) writeVar(css, "font-mono", monoStack)
// Font size base as rem if v := NormalizeFontSizeBase(typography.FontSizeBase); v != "" {
if typography.FontSizeBase != "" { writeVar(css, "font-size-base", v)
writeVar(css, "font-size-base", typography.FontSizeBase+"px")
} }
if typography.LineHeightBase != "" { if typography.LineHeightBase != "" {
writeVar(css, "line-height-base", typography.LineHeightBase) writeVar(css, "line-height-base", typography.LineHeightBase)
} }
// Font weight base
writeVar(css, "font-weight-base", getFontWeight(typography.FontWeightBase)) writeVar(css, "font-weight-base", getFontWeight(typography.FontWeightBase))
for _, k := range FontSizeOverrideKeys() {
v := typography.FontSizeOverrides[k]
if v == "" || ValidateFontSize(v) != nil {
continue
}
writeVar(css, "fs-"+k, v)
}
}
var fontSizeOverrideSelectors = map[string]string{
"h1": "h1", "h2": "h2", "h3": "h3", "h4": "h4", "h5": "h5", "h6": "h6",
"nav-link": ".bn-navbar a",
"hero-title": ".bn-hero-title",
"hero-subtitle": ".bn-hero-subtitle",
"index-card-title": ".bn-post-card-title",
}
var headingOverrideKeys = map[string]bool{"h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true}
// writeFontSizeOverrideRules emits element rules for overrides that cannot be
// consumed via var() fallbacks. Headings go in @layer base so Tailwind
// utilities on blocks still win; chrome rules stay unlayered so they beat the
// utility classes baked into builtin templates. Emitted last so they win
// same-specificity cascade inside this sheet.
func writeFontSizeOverrideRules(css *strings.Builder, typography *ThemeTypography) {
if typography == nil || len(typography.FontSizeOverrides) == 0 {
return
}
var headings, chrome []string
for _, k := range FontSizeOverrideKeys() {
v := typography.FontSizeOverrides[k]
sel, ok := fontSizeOverrideSelectors[k]
if !ok || v == "" || ValidateFontSize(v) != nil {
continue
}
rule := fmt.Sprintf("%s { font-size: var(--fs-%s); }", sel, k)
if headingOverrideKeys[k] {
headings = append(headings, " "+rule)
} else {
chrome = append(chrome, rule)
}
}
if len(headings) > 0 {
css.WriteString("\n@layer base {\n")
css.WriteString(strings.Join(headings, "\n"))
css.WriteString("\n}\n")
}
if len(chrome) > 0 {
css.WriteString("\n")
css.WriteString(strings.Join(chrome, "\n"))
css.WriteString("\n")
}
} }
func writeSpacingVars(css *strings.Builder, spacing *ThemeSpacing) { func writeSpacingVars(css *strings.Builder, spacing *ThemeSpacing) {
@ -361,7 +417,7 @@ func WriteButtonClasses(css *strings.Builder, buttons *ThemeButtons) {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
font-size: 1rem; font-size: var(--fs-button, 1rem);
font-weight: 500; font-weight: 500;
border-radius: var(--button-radius, 0.375rem); border-radius: var(--button-radius, 0.375rem);
transition: all 150ms ease; transition: all 150ms ease;
@ -370,9 +426,9 @@ func WriteButtonClasses(css *strings.Builder, buttons *ThemeButtons) {
} }
/* Button sizes */ /* Button sizes */
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.875rem; } .btn-sm { padding: 0.375rem 0.75rem; font-size: calc(var(--fs-button, 1rem) * 0.875); }
.btn-md { padding: 0.5rem 1rem; font-size: 1rem; } .btn-md { padding: 0.5rem 1rem; font-size: var(--fs-button, 1rem); }
.btn-lg { padding: 0.75rem 1.5rem; font-size: 1.125rem; } .btn-lg { padding: 0.75rem 1.5rem; font-size: calc(var(--fs-button, 1rem) * 1.125); }
/* Theme-aware button types */ /* Theme-aware button types */
.btn-primary { .btn-primary {

View File

@ -235,6 +235,7 @@ type ThemeTypography struct {
FontSizeBase string `json:"fontSizeBase"` FontSizeBase string `json:"fontSizeBase"`
LineHeightBase string `json:"lineHeightBase"` LineHeightBase string `json:"lineHeightBase"`
FontWeightBase string `json:"fontWeightBase"` FontWeightBase string `json:"fontWeightBase"`
FontSizeOverrides map[string]string `json:"fontSizeOverrides,omitempty"`
} }
// ThemeSpacing represents spacing settings // ThemeSpacing represents spacing settings

View File

@ -0,0 +1,96 @@
package theme
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
var fontSizeOverrideKeys = map[string]bool{
"h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true,
"post-title": true, "post-lede": true, "post-body": true,
"post-h2": true, "post-h3": true, "post-meta": true,
"page-title": true, "page-lede": true, "index-card-title": true,
"hero-title": true, "hero-subtitle": true,
"button": true, "nav-link": true,
}
var fontSizePattern = regexp.MustCompile(`^(\d+(?:\.\d+)?|\.\d+)(rem|px)$`)
// FontSizeOverrideKeys returns the canonical override keys, sorted.
func FontSizeOverrideKeys() []string {
keys := make([]string, 0, len(fontSizeOverrideKeys))
for k := range fontSizeOverrideKeys {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// ValidateFontSize accepts unit-suffixed CSS lengths within sane bounds.
// Values are emitted into a stylesheet, so this is a security gate.
func ValidateFontSize(v string) error {
m := fontSizePattern.FindStringSubmatch(v)
if m == nil {
return fmt.Errorf("font size %q must be a number with rem or px unit", v)
}
n, err := strconv.ParseFloat(m[1], 64)
if err != nil {
return fmt.Errorf("font size %q is not a number", v)
}
switch m[2] {
case "rem":
if n < 0.25 || n > 10 {
return fmt.Errorf("font size %q out of range (0.25rem to 10rem)", v)
}
case "px":
if n < 4 || n > 160 {
return fmt.Errorf("font size %q out of range (4px to 160px)", v)
}
}
return nil
}
// NormalizeFontSizeBase upgrades legacy bare px numbers ("16") to "16px" and
// validates unit-suffixed values. Returns "" for anything invalid so bad
// stored data degrades to the CSS fallback instead of breaking the sheet.
func NormalizeFontSizeBase(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
if _, err := strconv.ParseFloat(v, 64); err == nil {
v += "px"
}
if err := ValidateFontSize(v); err != nil {
return ""
}
return v
}
// SanitizeFontSizeOverrides drops empty values, rejects unknown keys and
// invalid values, and returns a fresh map safe to persist and emit.
func SanitizeFontSizeOverrides(m map[string]string) (map[string]string, error) {
if len(m) == 0 {
return nil, nil
}
out := make(map[string]string, len(m))
for k, v := range m {
if v == "" {
continue
}
if !fontSizeOverrideKeys[k] {
return nil, fmt.Errorf("unknown font size element %q", k)
}
if err := ValidateFontSize(v); err != nil {
return nil, err
}
out[k] = v
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}