package web import ( "bytes" "encoding/json" "net/url" "regexp" "strings" "text/template" "github.com/google/uuid" ) // Theme is the structured, form-editable look of a blog. Stored as jsonb. type Theme struct { BgColor string `json:"bg_color"` BgImage string `json:"bg_image"` // image uuid or "" BgMode string `json:"bg_mode"` // cover | tile | fixed ContentBg string `json:"content_bg"` TextColor string `json:"text_color"` LinkColor string `json:"link_color"` ContentWidth string `json:"content_width"` // narrow | medium | wide Font string `json:"font"` // sans | serif | mono FontSize string `json:"font_size"` // small | normal | large HeaderShowTitle bool `json:"header_show_title"` HeaderImage string `json:"header_image"` HeaderAlign string `json:"header_align"` // left | center HeaderBg string `json:"header_bg"` HeaderText string `json:"header_text"` NavPosition string `json:"nav_position"` // below-header | top-bar | left-sidebar NavBg string `json:"nav_bg"` NavText string `json:"nav_text"` NavShowHome bool `json:"nav_show_home"` FooterText string `json:"footer_text"` FooterBg string `json:"footer_bg"` FooterColor string `json:"footer_color"` } func DefaultTheme() Theme { return Theme{ BgColor: "#e9e6df", BgMode: "cover", ContentBg: "#ffffff", TextColor: "#222222", LinkColor: "#1a5fb4", ContentWidth: "medium", Font: "sans", FontSize: "normal", HeaderShowTitle: true, HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", NavPosition: "below-header", NavBg: "#ffffff", NavText: "#222222", NavShowHome: true, FooterText: "", FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", } } // ParseTheme decodes stored JSON on top of the defaults so new fields get sane values. func ParseTheme(raw json.RawMessage) Theme { t := DefaultTheme() if len(raw) > 0 { _ = json.Unmarshal(raw, &t) } t.normalize() return t } var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) func pick(v string, allowed ...string) string { for _, a := range allowed { if v == a { return v } } return allowed[0] } func color(v, def string) string { v = strings.TrimSpace(v) if hexColor.MatchString(v) { return strings.ToLower(v) } return def } func imageID(v string) string { if _, err := uuid.Parse(v); err != nil { return "" } return v } // normalize clamps every field to an allowed value so the CSS template can trust them. func (t *Theme) normalize() { d := DefaultTheme() t.BgColor = color(t.BgColor, d.BgColor) t.BgImage = imageID(t.BgImage) t.BgMode = pick(t.BgMode, "cover", "tile", "fixed") t.ContentBg = color(t.ContentBg, d.ContentBg) t.TextColor = color(t.TextColor, d.TextColor) t.LinkColor = color(t.LinkColor, d.LinkColor) t.ContentWidth = pick(t.ContentWidth, "medium", "narrow", "wide") t.Font = pick(t.Font, "sans", "serif", "mono") t.FontSize = pick(t.FontSize, "normal", "small", "large") t.HeaderImage = imageID(t.HeaderImage) t.HeaderAlign = pick(t.HeaderAlign, "left", "center") t.HeaderBg = color(t.HeaderBg, d.HeaderBg) t.HeaderText = color(t.HeaderText, d.HeaderText) t.NavPosition = pick(t.NavPosition, "below-header", "top-bar", "left-sidebar") t.NavBg = color(t.NavBg, d.NavBg) t.NavText = color(t.NavText, d.NavText) t.FooterBg = color(t.FooterBg, d.FooterBg) t.FooterColor = color(t.FooterColor, d.FooterColor) if len(t.FooterText) > 2000 { t.FooterText = t.FooterText[:2000] } } // ThemeFromForm reads the design form on top of the current theme. func ThemeFromForm(cur Theme, f url.Values) Theme { t := cur get := func(k string) string { return strings.TrimSpace(f.Get(k)) } t.BgColor = get("bg_color") t.BgMode = get("bg_mode") t.ContentBg = get("content_bg") t.TextColor = get("text_color") t.LinkColor = get("link_color") t.ContentWidth = get("content_width") t.Font = get("font") t.FontSize = get("font_size") t.HeaderShowTitle = f.Get("header_show_title") == "on" t.HeaderAlign = get("header_align") t.HeaderBg = get("header_bg") t.HeaderText = get("header_text") t.NavPosition = get("nav_position") t.NavBg = get("nav_bg") t.NavText = get("nav_text") t.NavShowHome = f.Get("nav_show_home") == "on" t.FooterText = get("footer_text") t.FooterBg = get("footer_bg") t.FooterColor = get("footer_color") if f.Get("bg_image_remove") == "on" { t.BgImage = "" } else if v := get("bg_image"); v != "" { t.BgImage = v } if f.Get("header_image_remove") == "on" { t.HeaderImage = "" } else if v := get("header_image"); v != "" { t.HeaderImage = v } t.normalize() return t } func (t Theme) JSON() json.RawMessage { b, _ := json.Marshal(t) return b } // Helpers used by the CSS template. func (t Theme) FontFamily() string { switch t.Font { case "serif": return `Georgia, "Times New Roman", Times, serif` case "mono": return `"Courier New", Courier, monospace` } return `"Helvetica Neue", Helvetica, Arial, sans-serif` } func (t Theme) FontSizePx() string { switch t.FontSize { case "small": return "15px" case "large": return "19px" } return "17px" } func (t Theme) MaxWidth() string { switch t.ContentWidth { case "narrow": return "640px" case "wide": return "1100px" } return "840px" } var themeCSS = template.Must(template.New("theme").Parse(` body { margin:0; background-color:{{.BgColor}}; color:{{.TextColor}}; font-family:{{.FontFamily}}; font-size:{{.FontSizePx}}; line-height:1.55; {{- if .BgImage}} background-image:url(/media/{{.BgImage}}); {{- if eq .BgMode "cover"}} background-size:cover; background-position:center top; background-repeat:no-repeat; {{- else if eq .BgMode "fixed"}} background-size:cover; background-position:center; background-attachment:fixed; background-repeat:no-repeat; {{- else}} background-repeat:repeat;{{end}} {{- end}} } a { color:{{.LinkColor}}; } .wrap { max-width:{{.MaxWidth}}; margin:0 auto; } .site-header { background:{{.HeaderBg}}; color:{{.HeaderText}}; text-align:{{.HeaderAlign}}; } .site-header a { color:{{.HeaderText}}; } .site-nav { background:{{.NavBg}}; } .site-nav a { color:{{.NavText}}; } .content { background:{{.ContentBg}}; } .site-footer { background:{{.FooterBg}}; color:{{.FooterColor}}; } .site-footer a { color:{{.FooterColor}}; } `)) // CSS renders the per-blog stylesheet; all values were normalized so it is safe to inline. func (t Theme) CSS() string { var b bytes.Buffer _ = themeCSS.Execute(&b, t) return b.String() }