Commit ea019bd

HPCesia <me@hpcesia.com>
2026-07-01 17:54:30
Add adaptive light/dark theme support
Generated pages now automatically switch between light and dark themes based on the user's system preference via @media (prefers-color-scheme). - All CSS files (style.css, markdown.css, chroma.css, diff.css) include both light and dark variants, with dark wrapped in a prefers-color-scheme media query - --theme-light and --theme-dark flags allow explicit theme specification - When only --theme is provided, auto-map to a known light/dark counterpart (e.g. github -> github-dark, nord -> nordic, etc.) using knownPairs map and suffix heuristics - Warning printed when no counterpart is found, falling back to using the same theme for both - Inline mode (--inline-styles) preserves single-theme behavior
1 parent f52df1c
pkg/templates/blob.gohtml
@@ -27,6 +27,8 @@
           word-wrap: normal;
           tab-size: 4;
           font-family: var(--font-family-mono), monospace;
+          background: var(--code-block-bg);
+          color: var(--code-block-color);
         }
 
         pre > code {
@@ -38,6 +40,11 @@
           font-size: var(--code-font-size);
         }
 
+        .chroma {
+          background: var(--code-block-bg);
+          color: var(--code-block-color);
+        }
+
         .border {
           border: 1px solid var(--c-border);
           border-top: none;
pkg/templates/layout.gohtml
@@ -1,6 +1,6 @@
 {{- /*gotype: github.com/antonmedv/gitmal/pkg/templates.LayoutParams*/ -}}
 <!DOCTYPE html>
-<html lang="en"{{ if not .InlineStyles }}{{ if .Dark }} data-theme="dark"{{ end }}{{ end }}>
+<html lang="en">
 <head>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
pkg/templates/styles.go
@@ -309,6 +309,22 @@ func LayoutCSSCombo() string {
 	return LayoutCSSVarLight + LayoutCSSVarDarkCombo + LayoutCSSShared
 }
 
+// LayoutCSSAdaptive returns layout CSS that adapts to the user's system
+// preference using @media (prefers-color-scheme: dark), while also
+// supporting [data-theme="dark"] for manual overrides (e.g. via JS).
+//
+// Unlike the previous non-adaptive behavior where --theme controlled
+// both code highlighting AND page appearance, adaptive mode decouples
+// these: the page follows the OS preference regardless of --theme.
+// Users who want to force a specific appearance can set [data-theme]
+// on <html> via a theme switcher.
+func LayoutCSSAdaptive() string {
+	return LayoutCSSVarLight +
+		"\n@media (prefers-color-scheme: dark) {\n" + LayoutCSSVarDarkInline + "\n}\n" +
+		LayoutCSSVarDarkCombo +
+		LayoutCSSShared
+}
+
 func LayoutCSSInline(dark bool) CSS {
 	if dark {
 		return CSS(LayoutCSSVarDarkInline + LayoutCSSShared)
blob.go
@@ -37,9 +37,11 @@ func generateBlobs(files []git.Blob, params Params) error {
 	}
 
 	// Use a temporary formatter to render CSS once
-	if err := html.New(formatterOptions...).WriteCSS(&css, style); err != nil {
+	var rawCSS strings.Builder
+	if err := html.New(formatterOptions...).WriteCSS(&rawCSS, style); err != nil {
 		return err
 	}
+	css.WriteString(stripChromaBase(rawCSS.String()))
 
 	dirsSet := links.BuildDirSet(files)
 	filesSet := links.BuildFileSet(files)
main.go
@@ -20,6 +20,8 @@ var (
 	flagBranches      string
 	flagDefaultBranch string
 	flagTheme         string
+	flagThemeLight    string
+	flagThemeDark     string
 	flagPreviewThemes bool
 	flagMinify        bool
 	flagGzip          bool
@@ -34,6 +36,8 @@ type Params struct {
 	Ref          git.Ref
 	OutputDir    string
 	Style        string
+	StyleLight   string
+	StyleDark    string
 	Dark         bool
 	DefaultRef   git.Ref
 	InlineStyles bool
@@ -68,6 +72,8 @@ func main() {
 	flag.StringVar(&flagBranches, "branches", "", "Regex for branches to include")
 	flag.StringVar(&flagDefaultBranch, "default-branch", "", "Default branch to use (autodetect master or main)")
 	flag.StringVar(&flagTheme, "theme", "github", "Style theme")
+	flag.StringVar(&flagThemeLight, "theme-light", "", "Light theme for code highlighting (overrides --theme)")
+	flag.StringVar(&flagThemeDark, "theme-dark", "", "Dark theme for code highlighting (overrides --theme)")
 	flag.BoolVar(&flagPreviewThemes, "preview-themes", false, "Preview available themes")
 	flag.BoolVar(&flagMinify, "minify", false, "Minify all generated HTML files")
 	flag.BoolVar(&flagGzip, "gzip", false, "Compress all generated HTML files")
@@ -106,9 +112,9 @@ func main() {
 		flagName = strings.TrimSuffix(flagName, ".git")
 	}
 
-	themeColor, ok := themeStyles[flagTheme]
-	if !ok {
-		panic("Invalid theme: " + flagTheme)
+	styleLight, styleDark, err := resolveTheme(flagTheme, flagThemeLight, flagThemeDark)
+	if err != nil {
+		panic(err)
 	}
 
 	branchesFilter, err := regexp.Compile(flagBranches)
@@ -156,7 +162,9 @@ func main() {
 		RepoDir:      input,
 		OutputDir:    outputDir,
 		Style:        flagTheme,
-		Dark:         themeColor == "dark",
+		StyleLight:   styleLight,
+		StyleDark:    styleDark,
+		Dark:         themeStyles[flagTheme] == "dark",
 		DefaultRef:   git.NewRef(flagDefaultBranch),
 		InlineStyles: flagInlineStyles,
 	}
markdown.go
@@ -3,6 +3,7 @@ package main
 import (
 	"html/template"
 
+	"github.com/alecthomas/chroma/v2/formatters/html"
 	"github.com/yuin/goldmark"
 	highlighting "github.com/yuin/goldmark-highlighting/v2"
 	"github.com/yuin/goldmark/extension"
@@ -19,6 +20,9 @@ func createMarkdown(style string) goldmark.Markdown {
 			extension.Typographer,
 			highlighting.NewHighlighting(
 				highlighting.WithStyle(style),
+				highlighting.WithFormatOptions(
+					html.WithClasses(true),
+				),
 			),
 		),
 		goldmark.WithParserOptions(
@@ -31,8 +35,5 @@ func createMarkdown(style string) goldmark.Markdown {
 }
 
 func cssMarkdown(dark bool) template.CSS {
-	if dark {
-		return template.CSS(templates.CSSMarkdownDark)
-	}
-	return template.CSS(templates.CSSMarkdownLight)
+	return template.CSS(templates.CSSMarkdownLight + "\n@media (prefers-color-scheme: dark) {\n" + templates.CSSMarkdownDark + "\n}\n")
 }
styles_gen.go
@@ -1,8 +1,10 @@
 package main
 
 import (
+	"bytes"
 	"os"
 	"path/filepath"
+	"regexp"
 	"strings"
 
 	"github.com/alecthomas/chroma/v2"
@@ -19,52 +21,115 @@ func generateCSSFiles(params Params) error {
 		return err
 	}
 
-	if err := os.WriteFile(filepath.Join(out, "style.css"), []byte(templates.LayoutCSSCombo()), 0o644); err != nil {
+	if err := os.WriteFile(filepath.Join(out, "style.css"), []byte(templates.LayoutCSSAdaptive()), 0o644); err != nil {
 		return err
 	}
 
-	// markdown.css for the active theme
-	var mdCSS string
-	if params.Dark {
-		mdCSS = templates.CSSMarkdownDark
-	} else {
-		mdCSS = templates.CSSMarkdownLight
-	}
-	if err := os.WriteFile(filepath.Join(out, "markdown.css"), []byte(mdCSS), 0o644); err != nil {
+	// markdown.css with both light and dark
+	var mdLight, mdDark string
+	mdLight = templates.CSSMarkdownLight
+	mdDark = templates.CSSMarkdownDark
+	combinedMD := mdLight + "\n@media (prefers-color-scheme: dark) {\n" + mdDark + "\n}\n"
+	if err := os.WriteFile(filepath.Join(out, "markdown.css"), []byte(combinedMD), 0o644); err != nil {
 		return err
 	}
 
-	// chroma.css for syntax highlighting and diff.css for diff highlighting
-	style := styles.Get(params.Style)
-	if style != nil {
-		formatter := html.New(
-			html.WithClasses(true),
-			html.WithCSSComments(false),
-		)
-		var cssBuf strings.Builder
-		if err := formatter.WriteCSS(&cssBuf, style); err != nil {
-			return err
-		}
-		if err := os.WriteFile(filepath.Join(out, "chroma.css"), []byte(cssBuf.String()), 0o644); err != nil {
+	// chroma.css with both light and dark themes
+	chromaLight, err := renderChromaCSS(params.StyleLight)
+	if err != nil {
+		return err
+	}
+	chromaDark, err := renderChromaCSS(params.StyleDark)
+	if err != nil {
+		return err
+	}
+	combinedChroma := combineThemeCSS(chromaLight, chromaDark)
+	if combinedChroma != "" {
+		if err := os.WriteFile(filepath.Join(out, "chroma.css"), []byte(combinedChroma), 0o644); err != nil {
 			return err
 		}
+	}
 
-		diffFormatter := html.New(
-			html.WithClasses(true),
-			html.WithCSSComments(false),
-			html.WithCustomCSS(map[chroma.TokenType]string{
-				chroma.GenericInserted: "display: block;",
-				chroma.GenericDeleted:  "display: block;",
-			}),
-		)
-		var diffBuf strings.Builder
-		if err := diffFormatter.WriteCSS(&diffBuf, style); err != nil {
-			return err
-		}
-		if err := os.WriteFile(filepath.Join(out, "diff.css"), []byte(diffBuf.String()), 0o644); err != nil {
+	// diff.css with both light and dark themes
+	diffLight, err := renderDiffCSS(params.StyleLight)
+	if err != nil {
+		return err
+	}
+	diffDark, err := renderDiffCSS(params.StyleDark)
+	if err != nil {
+		return err
+	}
+	combinedDiff := combineThemeCSS(diffLight, diffDark)
+	if combinedDiff != "" {
+		if err := os.WriteFile(filepath.Join(out, "diff.css"), []byte(combinedDiff), 0o644); err != nil {
 			return err
 		}
 	}
 
 	return nil
 }
+
+func renderChromaCSS(theme string) (string, error) {
+	style := styles.Get(theme)
+	if style == nil {
+		return "", nil
+	}
+	formatter := html.New(
+		html.WithClasses(true),
+		html.WithCSSComments(false),
+	)
+	var buf strings.Builder
+	if err := formatter.WriteCSS(&buf, style); err != nil {
+		return "", err
+	}
+	return stripChromaBase(buf.String()), nil
+}
+
+// stripChromaBase removes background-color and color declarations from
+// top-level .chroma and .bg rules, so the code block background and default
+// text color are controlled by CSS variables from the layout theme instead.
+// Chroma outputs single-line rules like: .chroma { background-color: #fff; color: #000; }
+var reChromaBase = regexp.MustCompile(`(\.chroma|\.bg)\s*\{([^}]*)\}`)
+var reStripBGColor = regexp.MustCompile(`\s*background-color:\s*[^;]+;`)
+var reStripColor = regexp.MustCompile(`\s*color:\s*[^;]+;`)
+
+func stripChromaBase(css string) string {
+	return reChromaBase.ReplaceAllStringFunc(css, func(match string) string {
+		parts := reChromaBase.FindStringSubmatch(match)
+		body := reStripBGColor.ReplaceAllString(parts[2], "")
+		body = reStripColor.ReplaceAllString(body, "")
+		return parts[1] + " {" + body + " }"
+	})
+}
+
+func combineThemeCSS(light, dark string) string {
+	if light == "" && dark == "" {
+		return ""
+	}
+	if dark == "" || light == dark {
+		return light
+	}
+	return light +
+		"\n@media (prefers-color-scheme: dark) {\n" + dark + "\n}\n" +
+		"\n[data-theme=\"dark\"] {\n" + dark + "\n}\n"
+}
+
+func renderDiffCSS(theme string) (string, error) {
+	style := styles.Get(theme)
+	if style == nil {
+		return "", nil
+	}
+	formatter := html.New(
+		html.WithClasses(true),
+		html.WithCSSComments(false),
+		html.WithCustomCSS(map[chroma.TokenType]string{
+			chroma.GenericInserted: "display: block;",
+			chroma.GenericDeleted:  "display: block;",
+		}),
+	)
+	var buf bytes.Buffer
+	if err := formatter.WriteCSS(&buf, style); err != nil {
+		return "", err
+	}
+	return buf.String(), nil
+}
themes.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"fmt"
 	"html/template"
 	"net"
 	"net/http"
@@ -14,6 +15,56 @@ import (
 	"github.com/antonmedv/gitmal/pkg/templates"
 )
 
+func resolveTheme(theme, themeLight, themeDark string) (string, string, error) {
+	switch {
+	case themeLight != "" && themeDark != "":
+		if _, ok := themeStyles[themeLight]; !ok {
+			return "", "", fmt.Errorf("invalid light theme: %s", themeLight)
+		}
+		if _, ok := themeStyles[themeDark]; !ok {
+			return "", "", fmt.Errorf("invalid dark theme: %s", themeDark)
+		}
+		return themeLight, themeDark, nil
+
+	case themeLight != "":
+		if _, ok := themeStyles[themeLight]; !ok {
+			return "", "", fmt.Errorf("invalid light theme: %s", themeLight)
+		}
+		if _, dark, ok := themePair(themeLight); ok {
+			return themeLight, dark, nil
+		}
+		echo(fmt.Sprintf("Warning: no dark counterpart for %q, using same theme", themeLight))
+		return themeLight, themeLight, nil
+
+	case themeDark != "":
+		if _, ok := themeStyles[themeDark]; !ok {
+			return "", "", fmt.Errorf("invalid dark theme: %s", themeDark)
+		}
+		if light, _, ok := themePair(themeDark); ok {
+			return light, themeDark, nil
+		}
+		echo(fmt.Sprintf("Warning: no light counterpart for %q, using same theme", themeDark))
+		return themeDark, themeDark, nil
+
+	default:
+		if _, ok := themeStyles[theme]; !ok {
+			return "", "", fmt.Errorf("invalid theme: %s", theme)
+		}
+		if light, dark, ok := themePair(theme); ok {
+			return light, dark, nil
+		}
+		echo(fmt.Sprintf("Warning: no %s counterpart for %q, using same theme for both", oppositeTone(theme), theme))
+		return theme, theme, nil
+	}
+}
+
+func oppositeTone(theme string) string {
+	if themeStyles[theme] == "dark" {
+		return "light"
+	}
+	return "dark"
+}
+
 var themeStyles = map[string]string{
 	"abap":                 "light",
 	"algol":                "light",
@@ -82,6 +133,60 @@ var themeStyles = map[string]string{
 	"xcode":                "light",
 }
 
+// knownPairs maps a theme name to its light/dark counterpart pair.
+// The key can be either the light or the dark theme name.
+var knownPairs = map[string]struct{ light, dark string }{
+	"github":           {"github", "github-dark"},
+	"github-dark":      {"github", "github-dark"},
+	"xcode":            {"xcode", "xcode-dark"},
+	"xcode-dark":       {"xcode", "xcode-dark"},
+	"catppuccin-latte": {"catppuccin-latte", "catppuccin-mocha"},
+	"catppuccin-mocha": {"catppuccin-latte", "catppuccin-mocha"},
+	"gruvbox-light":    {"gruvbox-light", "gruvbox"},
+	"gruvbox":          {"gruvbox-light", "gruvbox"},
+	"solarized-light":  {"solarized-light", "solarized-dark"},
+	"solarized-dark":   {"solarized-light", "solarized-dark"},
+	"paraiso-light":    {"paraiso-light", "paraiso-dark"},
+	"paraiso-dark":     {"paraiso-light", "paraiso-dark"},
+	"rose-pine-dawn":   {"rose-pine-dawn", "rose-pine"},
+	"rose-pine":        {"rose-pine-dawn", "rose-pine"},
+	"tokyonight-day":   {"tokyonight-day", "tokyonight-night"},
+	"tokyonight-night": {"tokyonight-day", "tokyonight-night"},
+	"modus-operandi":   {"modus-operandi", "modus-vivendi"},
+	"modus-vivendi":    {"modus-operandi", "modus-vivendi"},
+	"monokailight":     {"monokailight", "monokai"},
+	"monokai":          {"monokailight", "monokai"},
+}
+
+// themePair returns the light and dark theme names for a given theme.
+// If no counterpart is found, it returns the same name for both and ok=false.
+func themePair(theme string) (light, dark string, ok bool) {
+	if p, found := knownPairs[theme]; found {
+		return p.light, p.dark, true
+	}
+	// Heuristic: swap -dark/-light or add/remove suffix
+	if _, exists := themeStyles[theme+"-dark"]; exists && themeStyles[theme] == "light" {
+		return theme, theme + "-dark", true
+	}
+	if _, exists := themeStyles[theme+"-light"]; exists && themeStyles[theme] == "dark" {
+		return theme + "-light", theme, true
+	}
+	// Try removing -dark/-light suffix
+	if strings.HasSuffix(theme, "-dark") {
+		base := strings.TrimSuffix(theme, "-dark")
+		if _, exists := themeStyles[base]; exists {
+			return base, theme, true
+		}
+	}
+	if strings.HasSuffix(theme, "-light") {
+		base := strings.TrimSuffix(theme, "-light")
+		if _, exists := themeStyles[base]; exists {
+			return theme, base, true
+		}
+	}
+	return theme, theme, false
+}
+
 func previewThemes() {
 	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		names := make([]string, 0, len(themeStyles))