master
  1package main
  2
  3import (
  4	"bytes"
  5	"os"
  6	"path/filepath"
  7	"regexp"
  8	"sort"
  9	"strings"
 10
 11	"github.com/alecthomas/chroma/v2"
 12	"github.com/alecthomas/chroma/v2/formatters/html"
 13	"github.com/alecthomas/chroma/v2/styles"
 14
 15	"github.com/antonmedv/gitmal/pkg/templates"
 16)
 17
 18func generateCSSFiles(params Params) error {
 19	out := params.OutputDir
 20
 21	if err := os.MkdirAll(out, 0o755); err != nil {
 22		return err
 23	}
 24
 25	if err := os.WriteFile(filepath.Join(out, "style.css"), []byte(templates.LayoutCSSAdaptive()), 0o644); err != nil {
 26		return err
 27	}
 28
 29	// markdown.css with both light and dark
 30	var mdLight, mdDark string
 31	mdLight = templates.CSSMarkdownLight
 32	mdDark = templates.CSSMarkdownDark
 33	combinedMD := mdLight + "\n@media (prefers-color-scheme: dark) {\n" + mdDark + "\n}\n"
 34	if err := os.WriteFile(filepath.Join(out, "markdown.css"), []byte(combinedMD), 0o644); err != nil {
 35		return err
 36	}
 37
 38	// chroma.css with both light and dark themes
 39	chromaLight, err := renderChromaCSS(params.StyleLight)
 40	if err != nil {
 41		return err
 42	}
 43	chromaDark, err := renderChromaCSS(params.StyleDark)
 44	if err != nil {
 45		return err
 46	}
 47	combinedChroma := combineThemeCSS(chromaLight, chromaDark)
 48	if combinedChroma != "" {
 49		if err := os.WriteFile(filepath.Join(out, "chroma.css"), []byte(combinedChroma), 0o644); err != nil {
 50			return err
 51		}
 52	}
 53
 54	// diff.css with both light and dark themes
 55	diffLight, err := renderDiffCSS(params.StyleLight)
 56	if err != nil {
 57		return err
 58	}
 59	diffDark, err := renderDiffCSS(params.StyleDark)
 60	if err != nil {
 61		return err
 62	}
 63	combinedDiff := combineThemeCSS(diffLight, diffDark)
 64	if combinedDiff != "" {
 65		if err := os.WriteFile(filepath.Join(out, "diff.css"), []byte(combinedDiff), 0o644); err != nil {
 66			return err
 67		}
 68	}
 69
 70	return nil
 71}
 72
 73func renderChromaCSS(theme string) (string, error) {
 74	style := styles.Get(theme)
 75	if style == nil {
 76		return "", nil
 77	}
 78	formatter := html.New(
 79		html.WithClasses(true),
 80		html.WithCSSComments(false),
 81	)
 82	var buf strings.Builder
 83	if err := formatter.WriteCSS(&buf, style); err != nil {
 84		return "", err
 85	}
 86	return buf.String(), nil
 87}
 88
 89// stripChromaBase removes background-color and color declarations from
 90// top-level .chroma and .bg rules, so the code block background and default
 91// text color are controlled by CSS variables from the layout theme instead.
 92// Chroma outputs single-line rules like: .chroma { background-color: #fff; color: #000; }
 93var reChromaBase = regexp.MustCompile(`(\.chroma|\.bg)\s*\{([^}]*)\}`)
 94var reStripBGColor = regexp.MustCompile(`\s*background-color:\s*[^;]+;`)
 95var reStripColor = regexp.MustCompile(`\s*color:\s*[^;]+;`)
 96
 97func stripChromaBase(css string) string {
 98	return reChromaBase.ReplaceAllStringFunc(css, func(match string) string {
 99		parts := reChromaBase.FindStringSubmatch(match)
100		body := reStripBGColor.ReplaceAllString(parts[2], "")
101		body = reStripColor.ReplaceAllString(body, "")
102		return parts[1] + " {" + body + " }"
103	})
104}
105
106var reChromaSelector = regexp.MustCompile(`\.chroma \.\w+`)
107
108func chromaSelectors(css string) map[string]struct{} {
109	sels := make(map[string]struct{})
110	for _, m := range reChromaSelector.FindAllString(css, -1) {
111		sels[m] = struct{}{}
112	}
113	return sels
114}
115
116// lightOnlySelectors returns selectors present in light but missing in dark.
117// These need to be reset in dark mode to prevent light colors from leaking.
118func lightOnlySelectors(light, dark string) []string {
119	lightSels := chromaSelectors(light)
120	darkSels := chromaSelectors(dark)
121	var only []string
122	for sel := range lightSels {
123		if _, ok := darkSels[sel]; !ok {
124			only = append(only, sel)
125		}
126	}
127	sort.Strings(only)
128	return only
129}
130
131func combineThemeCSS(light, dark string) string {
132	if light == "" && dark == "" {
133		return ""
134	}
135	if dark == "" || light == dark {
136		return light
137	}
138
139	var darkReset strings.Builder
140	for _, sel := range lightOnlySelectors(light, dark) {
141		darkReset.WriteString(sel + " { color: inherit; }\n")
142	}
143
144	return light +
145		"\n@media (prefers-color-scheme: dark) {\n" + dark + darkReset.String() + "\n}\n" +
146		"\n[data-theme=\"dark\"] {\n" + dark + darkReset.String() + "\n}\n"
147}
148
149func renderDiffCSS(theme string) (string, error) {
150	style := styles.Get(theme)
151	if style == nil {
152		return "", nil
153	}
154	formatter := html.New(
155		html.WithClasses(true),
156		html.WithCSSComments(false),
157		html.WithCustomCSS(map[chroma.TokenType]string{
158			chroma.GenericInserted: "display: block;",
159			chroma.GenericDeleted:  "display: block;",
160		}),
161	)
162	var buf bytes.Buffer
163	if err := formatter.WriteCSS(&buf, style); err != nil {
164		return "", err
165	}
166	return buf.String(), nil
167}