// Package highlight is quarry's thin chroma wrapper (0002 §2 lists the
// Forgejo one as [COPY]; theirs is coupled to their settings and caches,
// so this is a smaller fresh write). Output uses CSS classes, never
// inline styles, so the two-theme story stays in one stylesheet.
package highlight
import (
"bytes"
"fmt"
"regexp"
"strings"
"github.com/alecthomas/chroma/v2"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
)
var formatter = chromahtml.New(
chromahtml.WithClasses(true),
chromahtml.PreventSurroundingPre(false),
)
// Code highlights source for the given language hint and/or filename;
// either may be empty. ok is false when no lexer matched — callers fall
// back to an escaped <pre>.
func Code(code []byte, language, filename string) (string, bool) {
var lexer chroma.Lexer
if language != "" {
lexer = lexers.Get(language)
}
if lexer == nil && filename != "" {
lexer = lexers.Match(filename)
}
// Content analysis ONLY for a shebang. Chroma's Analyse is eager
// enough to read an English README as SQL (is/and/or/in/where are
// all keywords) and paint the prose red; a #! line is the one
// content signal that names a language on purpose.
if lexer == nil && bytes.HasPrefix(code, []byte("#!")) {
lexer = lexers.Analyse(string(code))
}
if lexer == nil {
return "", false
}
lexer = chroma.Coalesce(lexer)
it, err := lexer.Tokenise(nil, string(code))
if err != nil {
return "", false
}
var buf bytes.Buffer
if err := formatter.Format(&buf, styles.Get("github"), it); err != nil {
return "", false
}
return buf.String(), true
}
// CSS returns the chroma class rules for both themes: light rules bare,
// dark rules inside a prefers-color-scheme block — matching the page
// layout's theming (0002 §4 item 5: both themes).
//
// A chroma style only emits rules for token classes it colors
// explicitly, so a class present in the light output but absent from
// the dark output would fall through to its light (near-black) color on
// the dark background — invisible text (ticket 030). Every such class
// gets an explicit dark override at the base text color.
func CSS() string {
var light, dark strings.Builder
formatter.WriteCSS(&light, styles.Get("github"))
formatter.WriteCSS(&dark, styles.Get("github-dark"))
darkCSS := dark.String()
darkClasses := map[string]bool{}
for _, m := range classRule.FindAllStringSubmatch(darkCSS, -1) {
darkClasses[m[1]] = true
}
base := "#e6edf3"
if m := baseColor.FindStringSubmatch(darkCSS); m != nil {
base = m[1]
}
var missing strings.Builder
for _, m := range classRule.FindAllStringSubmatch(light.String(), -1) {
if !darkClasses[m[1]] {
fmt.Fprintf(&missing, ".chroma .%s { color: %s }\n", m[1], base)
darkClasses[m[1]] = true
}
}
// Three theme states, matching the page: the media query is guarded
// against an explicit light choice, and the same rules repeat under
// an explicit dark one. Without both, a reader who picks light on a
// dark-mode device gets dark code colours on a light page, and
// picking dark on a light-mode device does nothing at all.
darkRules := darkCSS + missing.String()
return light.String() +
"\n@media (prefers-color-scheme: dark) {\n" +
prefixRules(darkRules, `:root:not([data-theme="light"]) `) + "\n}\n" +
"\n" + prefixRules(darkRules, `:root[data-theme="dark"] `) + "\n"
}
var (
classRule = regexp.MustCompile(`\.chroma \.([a-zA-Z0-9]+) `)
baseColor = regexp.MustCompile(`\.chroma \{[^}]*?color: (#[0-9a-fA-F]{3,8})`)
)
// prefixRules qualifies every ".chroma" selector with a theme scope, so
// one set of dark declarations can be emitted twice — once under the
// media query and once under the explicit choice — without writing the
// theme out by hand. A plain prefix rather than CSS nesting: same
// result, and it asks nothing of the browser.
func prefixRules(css, scope string) string {
return strings.ReplaceAll(css, ".chroma ", scope+".chroma ")
}