package web import ( "bytes" "encoding/json" "net/url" "regexp" "strconv" "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"` LinkHover string `json:"link_hover"` // "" = same as LinkColor LinkUnderline string `json:"link_underline"` // always | hover | never ContentStyle string `json:"content_style"` // card | flat ContentPadding string `json:"content_padding"` // normal | compact | roomy Font string `json:"font"` // sans | serif | mono | fira (Fira Code, bundled in static/) HeadingFont string `json:"heading_font"` // same | sans | serif | mono | fira FontSize string `json:"font_size"` // small | normal | large // Uploaded fonts (file uuids or ""): when set, the blog is typeset in the // file and Font/HeadingFont are the stack readers see until it loads, or // instead of it when their browser cannot use it. CustomFont string `json:"custom_font"` CustomHeadingFont string `json:"custom_heading_font"` HeaderImage string `json:"header_image"` HeaderImageHeight string `json:"header_image_height"` // short | medium | tall HeaderAlign string `json:"header_align"` // left | center HeaderBg string `json:"header_bg"` HeaderText string `json:"header_text"` TitleSize string `json:"title_size"` // normal | small | large (the site title in the header) HeaderRule string `json:"header_rule"` // none | line | dots | stripes, drawn beside the title/logo modules in the header NavAlign string `json:"nav_align"` // left | center | right (header menu) // The link styles are switches that combine; blog.css draws them from // the body classes NavClass makes. NavStyle is the select they replaced // (normal | uppercase | plain): still decoded from themes saved before, // turned into the switches by ParseTheme and never written again. NavStyle string `json:"nav_style,omitempty"` NavBold bool `json:"nav_bold"` NavUpper bool `json:"nav_upper"` // uppercase, letter-spaced NavUnderline string `json:"nav_underline"` // hover | never | always NavBg string `json:"nav_bg"` NavText string `json:"nav_text"` NavHover string `json:"nav_hover"` // "" = same as NavText ShowDates bool `json:"show_dates"` DateFormat string `json:"date_format"` // long | short | iso (rendered by i18n.FormatDate) PostsPerPage int `json:"posts_per_page"` // on listings and tag pages; one of postsPerPageChoices ListStyle string `json:"list_style"` // full | summary (see summary.go) PostImage string `json:"post_image"` // where the post page shows the featured image: top | bottom | list (listings only) TitleRule string `json:"title_rule"` // none | line | dots | dots2 | stripes, drawn under page and post titles in the link colour TagHash bool `json:"tag_hash"` // tags are listed as #name PostNotices bool `json:"post_notices"` // announcements are shown on post pages too, not only on listings FooterBg string `json:"footer_bg"` FooterColor string `json:"footer_color"` FooterAlign string `json:"footer_align"` // center | left Logo string `json:"logo"` // image uuid or "" LogoSize string `json:"logo_size"` // small | medium | large Favicon string `json:"favicon"` // image uuid or "" for the Blogspace default // Layout: which areas are shown and how wide the side columns are. What // each area contains lives in the modules table (store.Module). HeaderOn bool `json:"header_on"` LeftOn bool `json:"left_on"` RightOn bool `json:"right_on"` FooterOn bool `json:"footer_on"` LeftWidth int `json:"left_width"` // percent of the page RightWidth int `json:"right_width"` // percent of the page // KeepColumns lays the side columns out even when empty or hidden, so // the main content keeps the same width on every page. KeepColumns bool `json:"keep_columns"` // AlignToMain insets the header's and footer's content to the main // column's edges (the backgrounds stay full width), so a blog with // KeepColumns and empty columns reads as one centred column. AlignToMain bool `json:"align_to_main"` // PageWidth caps the columns' box at so many pixels (0 = the screen), // centred or at the left per PageAlign; the header's and footer's // content follows it when AlignToMain is on, their backgrounds never. PageWidth int `json:"page_width"` PageAlign string `json:"page_align"` // center | left } // Areas of the blog page, in page order. above/below sit inside the main column. var Areas = []string{"header", "left", "above", "below", "right", "footer"} const ( minColumnWidth = 15 maxColumnWidth = 40 maxColumnsSum = 70 // main keeps at least 30% minPageWidth = 400 maxPageWidth = 3000 ) // postsPerPageChoices are the listing sizes the design form offers; the first is the default. var postsPerPageChoices = []int{10, 5, 15, 20} func DefaultTheme() Theme { return Theme{ BgColor: "#e9e6df", BgMode: "cover", ContentBg: "#ffffff", TextColor: "#222222", LinkColor: "#1a5fb4", LinkUnderline: "always", ContentStyle: "card", ContentPadding: "normal", Font: "sans", HeadingFont: "same", FontSize: "normal", HeaderImageHeight: "medium", HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", TitleSize: "normal", HeaderRule: "none", NavAlign: "left", NavBold: true, NavUnderline: "hover", NavBg: "#ffffff", NavText: "#222222", ShowDates: true, DateFormat: "long", PostsPerPage: postsPerPageChoices[0], ListStyle: "full", PostImage: "top", TitleRule: "none", PostNotices: true, FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", FooterAlign: "center", LogoSize: "medium", HeaderOn: true, LeftOn: true, RightOn: true, FooterOn: true, LeftWidth: 25, RightWidth: 25, PageAlign: "center", } } // 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) } // Themes saved with the old link-style select: it becomes the switches // it stood for, and is dropped from the JSON on the next save. switch t.NavStyle { case "uppercase": t.NavUpper = true case "plain": t.NavBold, t.NavUnderline = false, "always" } t.NavStyle = "" 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 pickInt(v int, allowed ...int) int { 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 } // optColor is for colours that may be unset ("" = inherit). func optColor(v string) string { return color(v, "") } func fileID(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 = fileID(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.LinkHover = optColor(t.LinkHover) t.LinkUnderline = pick(t.LinkUnderline, "always", "hover", "never") t.ContentStyle = pick(t.ContentStyle, "card", "flat") t.ContentPadding = pick(t.ContentPadding, "normal", "compact", "roomy") t.Font = pick(t.Font, "sans", "serif", "mono", "fira") t.HeadingFont = pick(t.HeadingFont, "same", "sans", "serif", "mono", "fira") t.FontSize = pick(t.FontSize, "normal", "small", "large") t.CustomFont = fileID(t.CustomFont) t.CustomHeadingFont = fileID(t.CustomHeadingFont) t.HeaderImage = fileID(t.HeaderImage) t.HeaderImageHeight = pick(t.HeaderImageHeight, "medium", "short", "tall") t.HeaderAlign = pick(t.HeaderAlign, "left", "center") t.HeaderBg = color(t.HeaderBg, d.HeaderBg) t.HeaderText = color(t.HeaderText, d.HeaderText) t.TitleSize = pick(t.TitleSize, "normal", "small", "large") t.HeaderRule = pick(t.HeaderRule, "none", "line", "dots", "stripes") t.NavAlign = pick(t.NavAlign, "left", "center", "right") t.NavUnderline = pick(t.NavUnderline, "hover", "never", "always") t.NavBg = color(t.NavBg, d.NavBg) t.NavText = color(t.NavText, d.NavText) t.NavHover = optColor(t.NavHover) t.DateFormat = pick(t.DateFormat, "long", "short", "iso") t.PostsPerPage = pickInt(t.PostsPerPage, postsPerPageChoices...) t.ListStyle = pick(t.ListStyle, "full", "summary") t.PostImage = pick(t.PostImage, "top", "bottom", "list") t.TitleRule = pick(t.TitleRule, "none", "line", "dots", "dots2", "stripes") t.FooterBg = color(t.FooterBg, d.FooterBg) t.FooterColor = color(t.FooterColor, d.FooterColor) t.FooterAlign = pick(t.FooterAlign, "center", "left") t.Logo = fileID(t.Logo) t.LogoSize = pick(t.LogoSize, "medium", "small", "large") t.Favicon = fileID(t.Favicon) t.LeftWidth = clampInt(t.LeftWidth, minColumnWidth, maxColumnWidth) t.RightWidth = clampInt(t.RightWidth, minColumnWidth, maxColumnWidth) if t.LeftWidth+t.RightWidth > maxColumnsSum { // the right column gives way t.RightWidth = maxColumnsSum - t.LeftWidth } if t.PageWidth != 0 { t.PageWidth = clampInt(t.PageWidth, minPageWidth, maxPageWidth) } t.PageAlign = pick(t.PageAlign, "center", "left") } func clampInt(v, lo, hi int) int { if v < lo { return lo } if v > hi { return hi } return v } // ThemeFromForm reads the design form on top of the current theme: colours, // fonts, images and the layout switches alike. func ThemeFromForm(cur Theme, f url.Values) Theme { t := cur get := func(k string) string { return strings.TrimSpace(f.Get(k)) } on := func(k string) bool { return f.Get(k) == "on" } num := func(k string) int { n, _ := strconv.Atoi(get(k)); return n } // Hover colours only apply when their checkbox is ticked; otherwise "inherit". hover := func(k string) string { if on(k + "_custom") { return get(k) } return "" } 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.LinkHover = hover("link_hover") t.LinkUnderline = get("link_underline") t.ContentStyle = get("content_style") t.ContentPadding = get("content_padding") t.Font = get("font") t.HeadingFont = get("heading_font") t.FontSize = get("font_size") t.HeaderImageHeight = get("header_image_height") t.HeaderAlign = get("header_align") t.HeaderBg = get("header_bg") t.HeaderText = get("header_text") t.TitleSize = get("title_size") t.HeaderRule = get("header_rule") t.NavAlign = get("nav_align") t.NavBold = on("nav_bold") t.NavUpper = on("nav_upper") t.NavUnderline = get("nav_underline") t.NavBg = get("nav_bg") t.NavText = get("nav_text") t.NavHover = hover("nav_hover") t.ShowDates = on("show_dates") t.DateFormat = get("date_format") t.PostsPerPage = num("posts_per_page") t.ListStyle = get("list_style") t.PostImage = get("post_image") t.TitleRule = get("title_rule") t.TagHash = on("tag_hash") t.PostNotices = on("post_notices") t.FooterBg = get("footer_bg") t.FooterColor = get("footer_color") t.FooterAlign = get("footer_align") t.LogoSize = get("logo_size") t.BgImage = pickFile(t.BgImage, get("bg_image")) t.HeaderImage = pickFile(t.HeaderImage, get("header_image")) t.Favicon = pickFile(t.Favicon, get("favicon")) t.Logo = pickFile(t.Logo, get("logo")) t.CustomFont = pickFile(t.CustomFont, get("custom_font")) t.CustomHeadingFont = pickFile(t.CustomHeadingFont, get("custom_heading_font")) t.HeaderOn, t.LeftOn, t.RightOn, t.FooterOn = on("header_on"), on("left_on"), on("right_on"), on("footer_on") t.LeftWidth, t.RightWidth = num("left_width"), num("right_width") t.KeepColumns = on("keep_columns") t.AlignToMain = on("align_to_main") t.PageWidth = num("page_width") t.PageAlign = get("page_align") t.normalize() return t } // AreaOn reports whether an area is switched on; above/below are part of the main column and always on. func (t Theme) AreaOn(area string) bool { switch area { case "header": return t.HeaderOn case "left": return t.LeftOn case "right": return t.RightOn case "footer": return t.FooterOn } return true } // MainWidth is what the main column gets when both side columns are shown. func (t Theme) MainWidth() int { return 100 - t.LeftWidth - t.RightWidth } // PageMargin centres the capped page box or leaves it at the left. func (t Theme) PageMargin() string { if t.PageAlign == "left" { return "0" } return "0 auto" } // Inset is the padding- declarations (AlignToMain) that put the header's // and footer's text at a side column's inner edge: the wrap's own 1em plus // the column's share of the wrap's content box, which is the wrap less its two // 1em paddings — so (1 - 2w/100)em + w% of the wrap. With a page width the // wrap is that width or the screen, whichever is narrower: min() where the // browser has it, the page width alone (the wide-screen case) before it. func (t Theme) Inset(side string, w int) string { em := strconv.FormatFloat(1-float64(2*w)/100, 'f', -1, 64) + "em" if t.PageWidth == 0 { return "padding-" + side + ":calc(" + em + " + " + strconv.Itoa(w) + "%);" } px := strconv.Itoa(w*t.PageWidth/100) + "px" return "padding-" + side + ":calc(" + em + " + " + px + "); padding-" + side + ":calc(" + em + " + min(" + strconv.Itoa(w) + "%, " + px + "));" } // pickFile applies an imagepick or fontpick select: "none" clears, an id // selects, and an empty value (no select in the form) keeps what was there. func pickFile(cur, v string) string { switch v { case "": return cur case "none": return "" } return v } func (t Theme) JSON() json.RawMessage { b, _ := json.Marshal(t) return b } // ---- presets ---------------------------------------------------------------- // Preset is a named colour scheme; applying one touches only colours. type Preset struct { Key, Name string Bg, ContentBg, Text, Link string HeaderBg, HeaderText, NavBg, NavText string FooterBg, FooterText string } var presets = []Preset{ {Key: "paper", Name: "Paper", Bg: "#e9e6df", ContentBg: "#ffffff", Text: "#222222", Link: "#1a5fb4", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", NavBg: "#ffffff", NavText: "#222222", FooterBg: "#2f3a4a", FooterText: "#d0d5dc"}, {Key: "night", Name: "Night", Bg: "#15171c", ContentBg: "#1f2229", Text: "#e6e6e6", Link: "#8ab4f8", HeaderBg: "#0d0f13", HeaderText: "#f2f2f2", NavBg: "#1a1d23", NavText: "#dcdcdc", FooterBg: "#0d0f13", FooterText: "#9a9a9a"}, {Key: "ocean", Name: "Ocean", Bg: "#dfeef4", ContentBg: "#ffffff", Text: "#1b2b34", Link: "#0e7490", HeaderBg: "#0f4c5c", HeaderText: "#e6f7fb", NavBg: "#e6f4f8", NavText: "#0f4c5c", FooterBg: "#0f4c5c", FooterText: "#bfe3ec"}, {Key: "forest", Name: "Forest", Bg: "#e4e9df", ContentBg: "#fbfcf8", Text: "#1f2a1f", Link: "#2f6b3a", HeaderBg: "#243d2a", HeaderText: "#eef5ea", NavBg: "#eef3e8", NavText: "#243d2a", FooterBg: "#243d2a", FooterText: "#bccfb8"}, {Key: "slate", Name: "Slate", Bg: "#f0f0f0", ContentBg: "#ffffff", Text: "#2a2a2a", Link: "#4a4a4a", HeaderBg: "#ffffff", HeaderText: "#111111", NavBg: "#f7f7f7", NavText: "#333333", FooterBg: "#e5e5e5", FooterText: "#555555"}, {Key: "rose", Name: "Rose", Bg: "#f7e9e6", ContentBg: "#fffaf8", Text: "#3a2426", Link: "#b03a48", HeaderBg: "#7a2e3a", HeaderText: "#fdf1f2", NavBg: "#fbe4e4", NavText: "#7a2e3a", FooterBg: "#7a2e3a", FooterText: "#e9c3c8"}, } func Presets() []Preset { return presets } // Colors is the scheme as the design form's colour inputs, name → value, for // the script that fills them in. func (p Preset) Colors() string { b, _ := json.Marshal(map[string]string{"bg_color": p.Bg, "content_bg": p.ContentBg, "text_color": p.Text, "link_color": p.Link, "header_bg": p.HeaderBg, "header_text": p.HeaderText, "nav_bg": p.NavBg, "nav_text": p.NavText, "footer_bg": p.FooterBg, "footer_color": p.FooterText}) return string(b) } func PresetByKey(key string) (Preset, bool) { for _, p := range presets { if p.Key == key { return p, true } } return Preset{}, false } // WithPreset swaps in a scheme's colours and drops custom hover colours; images // and layout choices are untouched. func (t Theme) WithPreset(p Preset) Theme { t.BgColor, t.ContentBg, t.TextColor, t.LinkColor = p.Bg, p.ContentBg, p.Text, p.Link t.HeaderBg, t.HeaderText, t.NavBg, t.NavText = p.HeaderBg, p.HeaderText, p.NavBg, p.NavText t.FooterBg, t.FooterColor = p.FooterBg, p.FooterText t.LinkHover, t.NavHover = "", "" t.normalize() return t } // ---- css -------------------------------------------------------------------- func fontStack(name string) string { switch name { case "serif": return `Georgia, "Times New Roman", Times, serif` case "mono": return `"Courier New", Courier, monospace` case "fira": return `"Fira Code", "Fira Mono", Menlo, Consolas, "Courier New", monospace` } return `"Helvetica Neue", Helvetica, Arial, sans-serif` } // UsesFira says whether the bundled Fira Code face must be declared: the // only built-in choice that is a web font rather than a system stack. func (t Theme) UsesFira() bool { return t.Font == "fira" || t.HeadingFont == "fira" } // Helpers used by the CSS template. The @font-face families are named for // their role, so a stack is the uploaded font first and the built-in choice as // its fallback. func (t Theme) FontFamily() string { if t.CustomFont != "" { return `"BlogFont", ` + fontStack(t.Font) } return fontStack(t.Font) } // HeadingFontFamily builds on the text's stack when the heading font is // "same", so a custom heading font falls back to the custom text font. func (t Theme) HeadingFontFamily() string { stack := t.FontFamily() if t.HeadingFont != "same" { stack = fontStack(t.HeadingFont) } if t.CustomHeadingFont != "" { return `"BlogHeadingFont", ` + stack } return stack } // HeadingFontSet says whether headings need a font-family rule of their own. func (t Theme) HeadingFontSet() bool { return t.HeadingFont != "same" || t.CustomHeadingFont != "" } func (t Theme) FontSizePx() string { switch t.FontSize { case "small": return "14px" case "large": return "18px" } return "16px" } // ContentPaddingCSS returns desktop and phone paddings; "normal" is left to blog.css. func (t Theme) ContentPaddingCSS() (desktop, phone string) { switch t.ContentPadding { case "compact": return "1em 1.2em", "0.7em" case "roomy": return "2.2em 3em", "1.4em" } return "", "" } func (t Theme) PaddingDesktop() string { d, _ := t.ContentPaddingCSS(); return d } func (t Theme) PaddingPhone() string { _, p := t.ContentPaddingCSS(); return p } // LogoMaxHeight sizes the logo image wherever the logo module appears. func (t Theme) LogoMaxHeight() string { switch t.LogoSize { case "small": return "48px" case "large": return "140px" } return "80px" } // TitleSizeCSS is the site title's font size on wide screens; "normal" is // left to blog.css so its phone rule keeps working. func (t Theme) TitleSizeCSS() string { switch t.TitleSize { case "small": return "1.5em" case "large": return "2.6em" } return "" } // RuleClass is what a title or logo block in the header carries when a rule // is drawn beside it; blog.css does the drawing (normalize clamped the value). func (t Theme) RuleClass() string { if t.HeaderRule == "none" { return "" } return "ruled rule-" + t.HeaderRule } // NavClass is the body classes blog.css draws the menu links from (weight, // case, underline); the theme CSS only colours them. func (t Theme) NavClass() string { c := []string{"nav-ul-" + t.NavUnderline} if t.NavBold { c = append(c, "nav-bold") } if t.NavUpper { c = append(c, "nav-upper") } return strings.Join(c, " ") } // TitleRuleClass is the body class blog.css draws the rule under page and // post titles from; the theme CSS only colours it. func (t Theme) TitleRuleClass() string { if t.TitleRule == "none" { return "" } return "titles-" + t.TitleRule } func (t Theme) HeaderImageMaxHeight() string { switch t.HeaderImageHeight { case "short": return "180px" case "tall": return "520px" } return "320px" } var themeCSS = template.Must(template.New("theme").Funcs(template.FuncMap{"sub": func(a, b int) int { return a - b }}).Parse(` {{- if .UsesFira}} @font-face { font-family:"Fira Code"; src:url(/static/FiraCode-VF.woff2) format("woff2"); font-weight:300 700; font-display:swap; } {{- end}} {{- if .CustomFont}} @font-face { font-family:"BlogFont"; src:url(/media/{{.CustomFont}}); font-display:swap; } {{- end}} {{- if .CustomHeadingFont}} @font-face { font-family:"BlogHeadingFont"; src:url(/media/{{.CustomHeadingFont}}); font-display:swap; } {{- end}} body { margin:0; background-color:{{.BgColor}}; color:{{.TextColor}}; font-family:{{.FontFamily}}; font-size:{{.FontSizePx}}; line-height:1.5; {{- 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}} } {{- if .HeadingFontSet}} h1, h2, h3, h4, h5, h6 { font-family:{{.HeadingFontFamily}}; } {{- end}} a { color:{{.LinkColor}};{{if eq .LinkUnderline "always"}} text-decoration:underline;{{else}} text-decoration:none;{{end}} } {{- if or .LinkHover (eq .LinkUnderline "hover")}} a:hover {{"{"}}{{if .LinkHover}} color:{{.LinkHover}};{{end}}{{if eq .LinkUnderline "hover"}} text-decoration:underline;{{end}} } {{- end}} .site-header { background:{{.HeaderBg}}; color:{{.HeaderText}}; text-align:{{.HeaderAlign}}; } .site-header a { color:{{.HeaderText}}; } .header-image img { max-height:{{.HeaderImageMaxHeight}}; } .site-nav { background:{{.NavBg}}; } .nav-inner { text-align:{{.NavAlign}}; } .site-nav a { color:{{.NavText}}; } {{- if .NavHover}} .site-nav a:hover { color:{{.NavHover}}; } {{- end}} .content, .mod { background:{{.ContentBg}};{{if eq .ContentStyle "flat"}} border-radius:0; box-shadow:none;{{end}} } .content {{"{"}}{{with .PaddingDesktop}} padding:{{.}};{{end}} } .notice-gizmo { border-color:{{.LinkColor}}; } {{- if ne .TitleRule "none"}} .page-title, .post-title { border-color:{{.LinkColor}}; } .page-title::after, .post-title::after { color:{{.LinkColor}}; } {{- end}} .logo { max-height:{{.LogoMaxHeight}}; } {{- if .PageWidth}} .wrap.body-wrap { max-width:{{.PageWidth}}px; margin:{{.PageMargin}}; } {{- if .AlignToMain}} .site-header .wrap, .site-footer .wrap { max-width:{{.PageWidth}}px; margin:{{.PageMargin}}; } {{- end}} {{- end}} @media (min-width: 701px) { .col-left { width:{{.LeftWidth}}%; } .col-right { width:{{.RightWidth}}%; } body.has-left.has-right .col-main { width:{{.MainWidth}}%; } body.has-left:not(.has-right) .col-main { width:{{sub 100 .LeftWidth}}%; } body.has-right:not(.has-left) .col-main { width:{{sub 100 .RightWidth}}%; } {{- with .TitleSizeCSS}} .site-title { font-size:{{.}}; } {{- end}} {{- if .AlignToMain}} body.has-left .site-header .wrap, body.has-left .site-footer .wrap { {{.Inset "left" .LeftWidth}} } body.has-right .site-header .wrap, body.has-right .site-footer .wrap { {{.Inset "right" .RightWidth}} } {{- end}} } {{- with .PaddingPhone}} @media (max-width: 700px) { .content { padding:{{.}}; } } {{- end}} .site-footer { background:{{.FooterBg}}; color:{{.FooterColor}};{{if eq .FooterAlign "left"}} text-align:left;{{end}} } .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() }