Commit ea019bd
Changed files (8)
pkg
templates
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))