diff --git a/internal/theme/css.go b/internal/theme/css.go index 7f3c843..c57819a 100644 --- a/internal/theme/css.go +++ b/internal/theme/css.go @@ -7,11 +7,12 @@ import ( // FontFile represents a font file for CSS generation type FontFile struct { - Family string - Weight string - Style string - URL string - Format string // "woff2", "woff", "truetype" + Family string + Weight string + Style string + URL string + 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 @@ -31,8 +32,11 @@ func GenerateCSS(theme *Theme, googleFontURLs []string, fontFaces []FontFile) st font-weight: %s; font-style: %s; font-display: swap; -} `, 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 { @@ -63,6 +67,8 @@ func GenerateCSS(theme *Theme, googleFontURLs []string, fontFaces []FontFile) st // Button class definitions WriteButtonClasses(&css, theme.Buttons) + writeFontSizeOverrideRules(&css, theme.Typography) + return css.String() } @@ -124,15 +130,65 @@ func writeTypographyVars(css *strings.Builder, typography *ThemeTypography) { writeVar(css, "font-sans", bodyStack) writeVar(css, "font-mono", monoStack) - // Font size base as rem - if typography.FontSizeBase != "" { - writeVar(css, "font-size-base", typography.FontSizeBase+"px") + if v := NormalizeFontSizeBase(typography.FontSizeBase); v != "" { + writeVar(css, "font-size-base", v) } if typography.LineHeightBase != "" { writeVar(css, "line-height-base", typography.LineHeightBase) } - // Font weight base 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) { @@ -361,7 +417,7 @@ func WriteButtonClasses(css *strings.Builder, buttons *ThemeButtons) { align-items: center; justify-content: center; padding: 0.5rem 1rem; - font-size: 1rem; + font-size: var(--fs-button, 1rem); font-weight: 500; border-radius: var(--button-radius, 0.375rem); transition: all 150ms ease; @@ -370,9 +426,9 @@ func WriteButtonClasses(css *strings.Builder, buttons *ThemeButtons) { } /* Button sizes */ -.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.875rem; } -.btn-md { padding: 0.5rem 1rem; font-size: 1rem; } -.btn-lg { padding: 0.75rem 1.5rem; font-size: 1.125rem; } +.btn-sm { padding: 0.375rem 0.75rem; font-size: calc(var(--fs-button, 1rem) * 0.875); } +.btn-md { padding: 0.5rem 1rem; font-size: var(--fs-button, 1rem); } +.btn-lg { padding: 0.75rem 1.5rem; font-size: calc(var(--fs-button, 1rem) * 1.125); } /* Theme-aware button types */ .btn-primary { diff --git a/internal/theme/defaults.go b/internal/theme/defaults.go index cac7598..2371718 100644 --- a/internal/theme/defaults.go +++ b/internal/theme/defaults.go @@ -229,12 +229,13 @@ type ColorScheme struct { // ThemeTypography represents typography settings type ThemeTypography struct { - FontHeading string `json:"fontHeading"` - FontBody string `json:"fontBody"` - FontMono string `json:"fontMono"` - FontSizeBase string `json:"fontSizeBase"` - LineHeightBase string `json:"lineHeightBase"` - FontWeightBase string `json:"fontWeightBase"` + FontHeading string `json:"fontHeading"` + FontBody string `json:"fontBody"` + FontMono string `json:"fontMono"` + FontSizeBase string `json:"fontSizeBase"` + LineHeightBase string `json:"lineHeightBase"` + FontWeightBase string `json:"fontWeightBase"` + FontSizeOverrides map[string]string `json:"fontSizeOverrides,omitempty"` } // ThemeSpacing represents spacing settings diff --git a/internal/theme/fontsize.go b/internal/theme/fontsize.go new file mode 100644 index 0000000..f9e71bb --- /dev/null +++ b/internal/theme/fontsize.go @@ -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 +}