package branding
import (
"encoding/json"
"html"
"strings"
)
// Snapshot is an immutable view of the current branding configuration. It is
// the input to HTML templating, manifest generation, and the public API
// response.
type Snapshot struct {
ServerName string
LoginSubtitle string
AccentColor string // "" when unset
DefaultTheme string // "" when unset
assets map[AssetKind]string // kind -> content ref ("")
}
// AssetRef returns the stored content ref for a kind, or "" when no custom
// asset is set.
func (s Snapshot) AssetRef(kind AssetKind) string { return s.assets[kind] }
// HasAsset reports whether a custom asset of the given kind is configured.
func (s Snapshot) HasAsset(kind AssetKind) bool { return s.assets[kind] != "" }
// AssetURL returns the stable public URL for a custom asset, or "" when unset.
// The content ref is carried as ?v= so the asset can be cached immutably.
func (s Snapshot) AssetURL(kind AssetKind) string {
return AssetURLFor(kind, s.assets[kind])
}
// AssetURLFor builds the stable public URL for a kind given its content ref.
// Returns "" when ref is empty.
func AssetURLFor(kind AssetKind, ref string) string {
if ref == "" {
return ""
}
return assetURLBase + string(kind) + "?v=" + ref
}
// ThemeColor returns the color used for the PWA manifest and the theme-color
// meta tag: the configured accent color, or the default when unset.
func (s Snapshot) ThemeColor() string {
if s.AccentColor != "" {
return s.AccentColor
}
return DefaultThemeColor
}
// RenderKey returns a stable identity covering every snapshot field the
// Render* functions read, so callers can cache rendered output and re-render
// only when the branding configuration actually changes. Keep in sync with
// RenderIndexHTML and RenderManifest.
func (s Snapshot) RenderKey() string {
return strings.Join([]string{
s.ServerName,
s.LoginSubtitle,
s.AccentColor,
s.DefaultTheme,
s.assets[KindWordmark],
s.assets[KindMark],
s.assets[KindFavicon],
s.assets[KindLoginBg],
}, "\x00")
}
// Favicon link literal as it appears in web/index.html. Kept in sync with that
// file; RenderIndexHTML rewrites it to a custom favicon when one is set.
const indexFaviconLink = ``
// RenderIndexHTML injects branding into the SPA shell: the browser tab title
// and, when configured, the custom favicon link and a theme-color meta tag.
// Favicon/manifest paths themselves are served dynamically by the frontend
// handler, so only the title and the cache-bustable favicon href are rewritten
// here. Replacements that don't match are no-ops, so a build that changes the
// shell degrades gracefully to the bundled defaults.
func RenderIndexHTML(index []byte, snap Snapshot) []byte {
out := string(index)
out = strings.Replace(out,
"Silo",
""+html.EscapeString(snap.ServerName)+"",
1,
)
if u := snap.AssetURL(KindFavicon); u != "" {
out = strings.Replace(out,
indexFaviconLink,
``,
1,
)
}
if snap.AccentColor != "" {
meta := ``
out = strings.Replace(out, "", " "+meta+"\n ", 1)
}
return []byte(out)
}
type manifestIcon struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
Purpose string `json:"purpose,omitempty"`
}
type webManifest struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
Icons []manifestIcon `json:"icons"`
ThemeColor string `json:"theme_color"`
BackgroundColor string `json:"background_color"`
Display string `json:"display"`
StartURL string `json:"start_url"`
}
// RenderManifest builds the dynamic web app manifest from the snapshot. When a
// custom mark is set its WebP is advertised at both common sizes; otherwise the
// bundled PNG icon set is used.
func RenderManifest(snap Snapshot) []byte {
var icons []manifestIcon
if u := snap.AssetURL(KindMark); u != "" {
icons = []manifestIcon{
{Src: u, Sizes: "192x192", Type: "image/webp"},
{Src: u, Sizes: "512x512", Type: "image/webp"},
{Src: u, Sizes: "512x512", Type: "image/webp", Purpose: "maskable"},
}
} else {
icons = []manifestIcon{
{Src: "/web-app-icon-192.png", Sizes: "192x192", Type: "image/png"},
{Src: "/web-app-icon-512.png", Sizes: "512x512", Type: "image/png"},
{Src: "/maskable-icon-512.png", Sizes: "512x512", Type: "image/png", Purpose: "maskable"},
}
}
m := webManifest{
Name: snap.ServerName,
ShortName: snap.ServerName,
Icons: icons,
ThemeColor: snap.ThemeColor(),
BackgroundColor: snap.ThemeColor(),
Display: "standalone",
StartURL: "/",
}
// Marshaling a fixed struct cannot fail.
b, _ := json.Marshal(m)
return b
}