Files

348 lines
10 KiB
Go

package site
import (
"bytes"
"fmt"
"math"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// The look of a site: what its theme lets people change without touching CSS
// (colors, fonts, corners, the logo), and inside what limits. The theme
// declares that in look.yaml; a site's choices go in site.yaml under look:.
// The build writes the choices into a small stylesheet of CSS custom
// properties, loaded after the theme's own, so no page needs an inline style.
//
// # look.yaml
// tokens:
// - { name: accent, label: Accent, type: color, default: "#c2541b", dark: "#f2a65a" }
// - name: font
// type: font
// default: system
// options: { system: "system-ui, sans-serif", serif: "Georgia, serif" }
// - { name: logo, type: image }
// contrast:
// - [text, bg] # pairs that have to stay readable: 4.5:1, light and dark
//
// # site.yaml
// look:
// accent: "#0a7d55" # light only; dark keeps the theme's
// text: { light: "#1b1b1b", dark: "#f0f0f0" }
// font: serif
// logo: /media/2026/10/logo.png
// LookFile is a theme's look.yaml.
type LookFile struct {
Tokens []LookToken `yaml:"tokens" json:"tokens"`
Contrast [][]string `yaml:"contrast" json:"contrast"`
}
// LookToken is one thing the look can change.
type LookToken struct {
Name string `yaml:"name" json:"name"`
Label string `yaml:"label" json:"label,omitempty"`
Type string `yaml:"type" json:"type"` // color, font, select, image
Help string `yaml:"help" json:"help,omitempty"`
Default string `yaml:"default" json:"default,omitempty"`
Dark string `yaml:"dark" json:"dark,omitempty"` // a color's default in dark mode
Options LookOptions `yaml:"options" json:"options,omitempty"`
}
// LookOption is one choice of a font or select token: what the editor shows,
// and the CSS value it stands for.
type LookOption struct {
Name string `json:"name"`
Value string `json:"value"`
}
// LookOptions keeps the order the theme wrote its options in.
type LookOptions []LookOption
func (o *LookOptions) UnmarshalYAML(n *yaml.Node) error {
if n.Kind != yaml.MappingNode {
return fmt.Errorf("options must be a map of name: CSS value")
}
for i := 0; i+1 < len(n.Content); i += 2 {
*o = append(*o, LookOption{Name: n.Content[i].Value, Value: n.Content[i+1].Value})
}
return nil
}
func (o LookOptions) value(name string) (string, bool) {
for _, x := range o {
if x.Name == name {
return x.Value, true
}
}
return "", false
}
func (o LookOptions) names() string {
var n []string
for _, x := range o {
n = append(n, x.Name)
}
return strings.Join(n, ", ")
}
// LookValue is a site's choice for one token. Dark is empty when the site
// doesn't set a separate dark-mode value.
type LookValue struct {
Light string `json:"light"`
Dark string `json:"dark,omitempty"`
}
var (
tokenName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
hexColor = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
)
// LoadLook reads a site's look.yaml. A site without one has no look to
// change, and gets nil.
func LoadLook(dir string) (*LookFile, error) {
raw, err := os.ReadFile(filepath.Join(dir, "look.yaml"))
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var lf LookFile
dec := yaml.NewDecoder(bytes.NewReader(raw))
dec.KnownFields(true)
if err := dec.Decode(&lf); err != nil {
return nil, fmt.Errorf("look.yaml: %w", err)
}
seen := map[string]*LookToken{}
for i := range lf.Tokens {
t := &lf.Tokens[i]
if !tokenName.MatchString(t.Name) || seen[t.Name] != nil {
return nil, fmt.Errorf("look.yaml: tokens[%d]: needs a name of lowercase letters, digits and dashes, used once", i)
}
seen[t.Name] = t
switch t.Type {
case "color":
if !hexColor.MatchString(t.Default) || (t.Dark != "" && !hexColor.MatchString(t.Dark)) {
return nil, fmt.Errorf("look.yaml: %s: a color needs a default (and any dark) like #c2541b", t.Name)
}
case "font", "select":
if len(t.Options) == 0 {
return nil, fmt.Errorf("look.yaml: %s: needs options", t.Name)
}
if _, ok := t.Options.value(t.Default); t.Default != "" && !ok {
return nil, fmt.Errorf("look.yaml: %s: default %q isn't one of %s", t.Name, t.Default, t.Options.names())
}
case "image":
default:
return nil, fmt.Errorf("look.yaml: %s: type %q isn't color, font, select or image", t.Name, t.Type)
}
if t.Label == "" {
t.Label = strings.ToUpper(t.Name[:1]) + strings.ReplaceAll(t.Name[1:], "-", " ")
}
}
for i, pair := range lf.Contrast {
if len(pair) != 2 || seen[pair[0]] == nil || seen[pair[1]] == nil || seen[pair[0]].Type != "color" || seen[pair[1]].Type != "color" {
return nil, fmt.Errorf("look.yaml: contrast[%d]: a pair of two color tokens", i)
}
}
return &lf, nil
}
// Values reads a site's look: from site.yaml against the theme's tokens.
// Anything that doesn't fit is reported, in words.
func (lf *LookFile) Values(look map[string]any) (map[string]LookValue, error) {
out := map[string]LookValue{}
var problems []string
keys := make([]string, 0, len(look))
for k := range look {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
t := lf.token(k)
if t == nil {
problems = append(problems, fmt.Sprintf("look.%s: the theme has no %s (look.yaml)", k, k))
continue
}
var v LookValue
switch x := look[k].(type) {
case string:
v.Light = strings.TrimSpace(x)
case map[string]any:
if t.Type != "color" {
problems = append(problems, fmt.Sprintf("look.%s: only a color has light and dark values", k))
continue
}
v.Light, _ = x["light"].(string)
v.Dark, _ = x["dark"].(string)
default:
problems = append(problems, fmt.Sprintf("look.%s: expected text", k))
continue
}
switch t.Type {
case "color":
for _, c := range []string{v.Light, v.Dark} {
if c != "" && !hexColor.MatchString(c) {
problems = append(problems, fmt.Sprintf("look.%s: %q isn't a color like #c2541b", k, c))
}
}
case "font", "select":
if _, ok := t.Options.value(v.Light); !ok {
problems = append(problems, fmt.Sprintf("look.%s: %q isn't one of %s", k, v.Light, t.Options.names()))
}
case "image":
if v.Light != "" && !strings.HasPrefix(v.Light, "/") && !strings.HasPrefix(v.Light, "https://") {
problems = append(problems, fmt.Sprintf("look.%s: %q should be an address on the site (/media/…) or https://", k, v.Light))
}
}
out[k] = v
}
if len(problems) > 0 {
return out, fmt.Errorf("site.yaml: %s", strings.Join(problems, "; "))
}
return out, nil
}
func (lf *LookFile) token(name string) *LookToken {
for i := range lf.Tokens {
if lf.Tokens[i].Name == name {
return &lf.Tokens[i]
}
}
return nil
}
// Get is a token's value as templates see it: the site's choice, or the
// theme's default. A font or select gives its option's name.
func (lf *LookFile) Get(vals map[string]LookValue, name string) string {
if v, ok := vals[name]; ok && v.Light != "" {
return v.Light
}
if t := lf.token(name); t != nil {
return t.Default
}
return ""
}
// CSS is the stylesheet for a site's choices: only what it changes, so the
// theme's own values stand for the rest. Empty when nothing is changed. Dark
// values use the selectors themes follow for dark mode: the system setting
// unless the reader picked light, or the reader picking dark.
func (lf *LookFile) CSS(vals map[string]LookValue) []byte {
var light, dark []string
for _, t := range lf.Tokens {
v, ok := vals[t.Name]
if !ok {
continue
}
css := func(s string) string {
if t.Type == "font" || t.Type == "select" {
s, _ = t.Options.value(s)
}
return s
}
switch t.Type {
case "image":
continue
case "color":
if v.Light != "" {
light = append(light, fmt.Sprintf("--%s: %s;", t.Name, v.Light))
}
if v.Dark != "" {
dark = append(dark, fmt.Sprintf("--%s: %s;", t.Name, v.Dark))
}
default:
if v.Light != "" {
light = append(light, fmt.Sprintf("--%s: %s;", t.Name, css(v.Light)))
}
}
}
if len(light)+len(dark) == 0 {
return nil
}
var b strings.Builder
b.WriteString("/* The site's look, from site.yaml (look:). Written by HotDog CMS. */\n")
if len(light) > 0 {
fmt.Fprintf(&b, ":root { %s }\n", strings.Join(light, " "))
}
if len(dark) > 0 {
d := strings.Join(dark, " ")
fmt.Fprintf(&b, "@media (prefers-color-scheme: dark) { :root:not([data-theme=\"light\"]) { %s } }\n:root[data-theme=\"dark\"] { %s }\n", d, d)
}
return []byte(b.String())
}
// ContrastResult is how readable one pair is, in each mode.
type ContrastResult struct {
Fore string `json:"fore"`
Back string `json:"back"`
Light float64 `json:"light"`
Dark float64 `json:"dark"`
}
// MinContrast is WCAG AA for body text.
const MinContrast = 4.5
// Contrasts works out every declared pair with the site's choices applied.
func (lf *LookFile) Contrasts(vals map[string]LookValue) []ContrastResult {
color := func(name string, dark bool) string {
t := lf.token(name)
v := vals[name]
if dark {
for _, c := range []string{v.Dark, t.Dark, v.Light, t.Default} {
if c != "" {
return c
}
}
}
if v.Light != "" {
return v.Light
}
return t.Default
}
var out []ContrastResult
for _, p := range lf.Contrast {
out = append(out, ContrastResult{Fore: p[0], Back: p[1],
Light: Contrast(color(p[0], false), color(p[1], false)),
Dark: Contrast(color(p[0], true), color(p[1], true))})
}
return out
}
// Contrast is the WCAG contrast ratio of two #rgb or #rrggbb colors.
func Contrast(a, b string) float64 {
la, lb := luminance(a), luminance(b)
if la < lb {
la, lb = lb, la
}
return math.Round((la+0.05)/(lb+0.05)*100) / 100
}
func luminance(hex string) float64 {
h := strings.TrimPrefix(hex, "#")
if len(h) == 3 {
h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})
}
if len(h) != 6 {
return 0
}
var c [3]float64
for i := 0; i < 3; i++ {
v, _ := strconv.ParseUint(h[i*2:i*2+2], 16, 8)
x := float64(v) / 255
if x <= 0.03928 {
c[i] = x / 12.92
} else {
c[i] = math.Pow((x+0.055)/1.055, 2.4)
}
}
return 0.2126*c[0] + 0.7152*c[1] + 0.0722*c[2]
}