Skip to content

Commit 6fb9d92

Browse files
committed
feat(html): tag output with style mode
The HTML formatter now emits a "light" or "dark" class alongside "chroma" on the wrapper (and on standalone <body>) when classes are enabled. WriteCSS scopes its rules by the same mode class so two themes can share a stylesheet and be toggled at the wrapper level. Fixes #1162
1 parent a71fea3 commit 6fb9d92

3 files changed

Lines changed: 178 additions & 87 deletions

File tree

formatters/html/html.go

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.
242242
fmt.Fprintf(w, "body { %s; }\n", css[chroma.Background])
243243
fmt.Fprint(w, "</style>")
244244
}
245-
fmt.Fprintf(w, "<body%s>\n", f.styleAttr(css, chroma.Background))
245+
fmt.Fprintf(w, "<body%s>\n", f.styleAttrWithMode(css, chroma.Background, style))
246246
}
247247

248248
wrapInTable := f.lineNumbers && f.lineNumbersInTable
@@ -253,10 +253,10 @@ func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.
253253

254254
if wrapInTable {
255255
// List line numbers in its own <td>
256-
fmt.Fprintf(w, "<div%s>\n", f.styleAttr(css, chroma.PreWrapper))
256+
fmt.Fprintf(w, "<div%s>\n", f.styleAttrWithMode(css, chroma.PreWrapper, style))
257257
fmt.Fprintf(w, "<table%s><tr>", f.styleAttr(css, chroma.LineTable))
258258
fmt.Fprintf(w, "<td%s>\n", f.styleAttr(css, chroma.LineTableTD))
259-
fmt.Fprintf(w, "%s", f.preWrapper.Start(false, f.styleAttr(css, chroma.PreWrapper)))
259+
fmt.Fprintf(w, "%s", f.preWrapper.Start(false, f.styleAttrWithMode(css, chroma.PreWrapper, style)))
260260
for index := range lines {
261261
line := f.baseLineNumber + index
262262
highlight, next := f.shouldHighlight(highlightIndex, line)
@@ -278,7 +278,7 @@ func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.
278278
fmt.Fprintf(w, "<td%s>\n", f.styleAttr(css, chroma.LineTableTD, "width:100%"))
279279
}
280280

281-
fmt.Fprintf(w, "%s", f.preWrapper.Start(true, f.styleAttr(css, chroma.PreWrapper)))
281+
fmt.Fprintf(w, "%s", f.preWrapper.Start(true, f.styleAttrWithMode(css, chroma.PreWrapper, style)))
282282

283283
highlightIndex = 0
284284
for index, tokens := range lines {
@@ -415,6 +415,26 @@ func (f *Formatter) styleAttr(styles map[chroma.TokenType]string, tt chroma.Toke
415415
return fmt.Sprintf(` style="%s"`, strings.Join(css, ";"))
416416
}
417417

418+
// modeClass returns the CSS class corresponding to the style's mode (eg.
419+
// "light" or "dark"), with the formatter's class prefix applied.
420+
func (f *Formatter) modeClass(style *chroma.Style) string {
421+
return f.prefix + style.Mode().String()
422+
}
423+
424+
// styleAttrWithMode is like styleAttr but, in classes mode, appends the
425+
// style's mode class alongside the existing class. Used for the outer
426+
// wrapper and standalone <body> so external CSS can target the mode.
427+
func (f *Formatter) styleAttrWithMode(styles map[chroma.TokenType]string, tt chroma.TokenType, style *chroma.Style) string {
428+
if !f.Classes {
429+
return f.styleAttr(styles, tt)
430+
}
431+
cls := f.class(tt)
432+
if cls == "" {
433+
return ""
434+
}
435+
return fmt.Sprintf(` class="%s %s"`, cls, f.modeClass(style))
436+
}
437+
418438
func (f *Formatter) tabWidthStyle() string {
419439
if f.tabWidth != 0 && f.tabWidth != 8 {
420440
return fmt.Sprintf("-moz-tab-size: %[1]d; -o-tab-size: %[1]d; tab-size: %[1]d;", f.tabWidth)
@@ -438,20 +458,26 @@ func (f *Formatter) writeCSSRule(w io.Writer, comment string, selector string, s
438458
}
439459

440460
// WriteCSS writes CSS style definitions (without any surrounding HTML).
461+
//
462+
// Rules are scoped by the style's mode (eg. ".chroma.dark") so that CSS
463+
// generated from a light and dark style can be combined without conflict.
441464
func (f *Formatter) WriteCSS(w io.Writer, style *chroma.Style) error {
442465
css := f.styleCache.get(style, false)
466+
modeCls := f.modeClass(style)
467+
chromaSel := fmt.Sprintf(".%schroma.%s", f.prefix, modeCls)
468+
bgSel := fmt.Sprintf(".%sbg.%s", f.prefix, modeCls)
443469

444470
// Special-case background as it is mapped to the outer ".chroma" class.
445-
if err := f.writeCSSRule(w, chroma.Background.String(), fmt.Sprintf(".%sbg", f.prefix), css[chroma.Background]); err != nil {
471+
if err := f.writeCSSRule(w, chroma.Background.String(), bgSel, css[chroma.Background]); err != nil {
446472
return err
447473
}
448474
// Special-case PreWrapper as it is the ".chroma" class.
449-
if err := f.writeCSSRule(w, chroma.PreWrapper.String(), fmt.Sprintf(".%schroma", f.prefix), css[chroma.PreWrapper]); err != nil {
475+
if err := f.writeCSSRule(w, chroma.PreWrapper.String(), chromaSel, css[chroma.PreWrapper]); err != nil {
450476
return err
451477
}
452478
// Special-case code column of table to expand width.
453479
if f.lineNumbers && f.lineNumbersInTable {
454-
selector := fmt.Sprintf(".%schroma .%s:last-child", f.prefix, f.class(chroma.LineTableTD))
480+
selector := fmt.Sprintf("%s .%s:last-child", chromaSel, f.class(chroma.LineTableTD))
455481
if err := f.writeCSSRule(w, chroma.LineTableTD.String(), selector, "width: 100%;"); err != nil {
456482
return err
457483
}
@@ -461,7 +487,7 @@ func (f *Formatter) WriteCSS(w io.Writer, style *chroma.Style) error {
461487
targetedLineCSS := StyleEntryToCSS(style.Get(chroma.LineHighlight))
462488
for _, tt := range []chroma.TokenType{chroma.LineNumbers, chroma.LineNumbersTable} {
463489
comment := fmt.Sprintf("%s targeted by URL anchor", tt)
464-
selector := fmt.Sprintf(".%schroma .%s:target", f.prefix, f.class(tt))
490+
selector := fmt.Sprintf("%s .%s:target", chromaSel, f.class(tt))
465491
if err := f.writeCSSRule(w, comment, selector, targetedLineCSS); err != nil {
466492
return err
467493
}
@@ -482,7 +508,7 @@ func (f *Formatter) WriteCSS(w io.Writer, style *chroma.Style) error {
482508
if class == "" {
483509
continue
484510
}
485-
if err := f.writeCSSRule(w, tt.String(), fmt.Sprintf(".%schroma .%s", f.prefix, class), css[tt]); err != nil {
511+
if err := f.writeCSSRule(w, tt.String(), fmt.Sprintf("%s .%s", chromaSel, class), css[tt]); err != nil {
486512
return err
487513
}
488514
}

formatters/html/html_test.go

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ func TestClassPrefix(t *testing.T) {
127127
var styleBuf bytes.Buffer
128128
err := withPrefix.WriteCSS(&styleBuf, styles.Fallback)
129129
assert.NoError(t, err)
130-
if !strings.Contains(styleBuf.String(), ".some-prefix-chroma ") {
130+
if !strings.Contains(styleBuf.String(), ".some-prefix-chroma.some-prefix-") {
131131
t.Error("Stylesheets should have a class prefix")
132132
}
133133
}
@@ -235,9 +235,9 @@ func TestPreWrapper(t *testing.T) {
235235
err = f.Format(&buf, styles.Fallback, it)
236236
assert.NoError(t, err)
237237

238-
assert.True(t, regexp.MustCompile("<body class=\"bg\">\n<pre.*class=\"chroma\"><code><span class=\"line\"><span class=\"cl\"><span class=\"nb\">echo</span> FOO</span></span></code></pre>\n</body>\n</html>").MatchString(buf.String()))
239-
assert.True(t, regexp.MustCompile(`\.bg { .+ }`).MatchString(buf.String()))
240-
assert.True(t, regexp.MustCompile(`\.chroma { .+ }`).MatchString(buf.String()))
238+
assert.True(t, regexp.MustCompile("<body class=\"bg dark\">\n<pre.*class=\"chroma dark\"><code><span class=\"line\"><span class=\"cl\"><span class=\"nb\">echo</span> FOO</span></span></code></pre>\n</body>\n</html>").MatchString(buf.String()))
239+
assert.True(t, regexp.MustCompile(`\.bg\.dark { .+ }`).MatchString(buf.String()))
240+
assert.True(t, regexp.MustCompile(`\.chroma\.dark { .+ }`).MatchString(buf.String()))
241241
}
242242

243243
func TestLinkeableLineNumbers(t *testing.T) {
@@ -264,7 +264,7 @@ func TestTableLinkeableLineNumbers(t *testing.T) {
264264

265265
assert.Contains(t, buf.String(), `id="line1"><a class="lnlinks" href="#line1">1</a>`)
266266
assert.Contains(t, buf.String(), `id="line5"><a class="lnlinks" href="#line5">5</a>`)
267-
assert.Contains(t, buf.String(), `/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit }`, buf.String())
267+
assert.Contains(t, buf.String(), `/* LineLink */ .chroma.dark .lnlinks { outline: none; text-decoration: none; color: inherit }`, buf.String())
268268
}
269269

270270
func TestTableLineNumberSpacing(t *testing.T) {
@@ -335,7 +335,7 @@ func TestWithPreWrapper(t *testing.T) {
335335

336336
t.Run("Regular", func(t *testing.T) {
337337
s := format(New(WithClasses(true)))
338-
assert.Equal(t, s, `<pre class="chroma"><code><span class="line"><span class="cl"><span class="nb">echo</span> FOO</span></span></code></pre>`)
338+
assert.Equal(t, s, `<pre class="chroma dark"><code><span class="line"><span class="cl"><span class="nb">echo</span> FOO</span></span></code></pre>`)
339339
})
340340

341341
t.Run("PreventSurroundingPre", func(t *testing.T) {
@@ -345,7 +345,7 @@ func TestWithPreWrapper(t *testing.T) {
345345

346346
t.Run("InlineCode", func(t *testing.T) {
347347
s := format(New(InlineCode(true), WithClasses(true)))
348-
assert.Equal(t, s, `<code class="chroma"><span class="nb">echo</span> FOO</code>`)
348+
assert.Equal(t, s, `<code class="chroma dark"><span class="nb">echo</span> FOO</code>`)
349349
})
350350

351351
t.Run("InlineCode, inline styles", func(t *testing.T) {
@@ -355,18 +355,18 @@ func TestWithPreWrapper(t *testing.T) {
355355

356356
t.Run("Wrapper", func(t *testing.T) {
357357
s := format(New(WithPreWrapper(wrapper), WithClasses(true)))
358-
assert.Equal(t, s, `<foo class="chroma" id="code-true"><span class="line"><span class="cl"><span class="nb">echo</span> FOO</span></span></foo>`)
358+
assert.Equal(t, s, `<foo class="chroma dark" id="code-true"><span class="line"><span class="cl"><span class="nb">echo</span> FOO</span></span></foo>`)
359359
})
360360

361361
t.Run("Wrapper, LineNumbersInTable", func(t *testing.T) {
362362
s := format(New(WithPreWrapper(wrapper), WithClasses(true), WithLineNumbers(true), LineNumbersInTable(true)))
363363

364-
assert.Equal(t, s, `<div class="chroma">
364+
assert.Equal(t, s, `<div class="chroma dark">
365365
<table class="lntable"><tr><td class="lntd">
366-
<foo class="chroma" id="code-false"><span class="lnt">1
366+
<foo class="chroma dark" id="code-false"><span class="lnt">1
367367
</span></foo></td>
368368
<td class="lntd">
369-
<foo class="chroma" id="code-true"><span class="line"><span class="cl"><span class="nb">echo</span> FOO</span></span></foo></td></tr></table>
369+
<foo class="chroma dark" id="code-true"><span class="line"><span class="cl"><span class="nb">echo</span> FOO</span></span></foo></td></tr></table>
370370
</div>
371371
`)
372372
})
@@ -389,7 +389,7 @@ func TestReconfigureOptions(t *testing.T) {
389389
err = f.Format(&buf, styles.Fallback, it)
390390

391391
assert.NoError(t, err)
392-
assert.Equal(t, `<pre class="chroma"><code><span class="line"><span class="cl"><span class="nb">echo</span> FOO</span></span></code></pre>`, buf.String())
392+
assert.Equal(t, `<pre class="chroma dark"><code><span class="line"><span class="cl"><span class="nb">echo</span> FOO</span></span></code></pre>`, buf.String())
393393
}
394394

395395
func TestWriteCssWithAllClasses(t *testing.T) {
@@ -402,6 +402,71 @@ func TestWriteCssWithAllClasses(t *testing.T) {
402402
assert.NotContains(t, buf.String(), ".chroma . {", "Generated css doesn't contain invalid css")
403403
}
404404

405+
func TestModeClassOnWrapper(t *testing.T) {
406+
tests := []struct {
407+
name string
408+
styleName string
409+
wantClass string
410+
}{
411+
{"LightStyle", "github", `class="chroma light"`},
412+
{"DarkStyle", "github-dark", `class="chroma dark"`},
413+
}
414+
for _, tt := range tests {
415+
t.Run(tt.name, func(t *testing.T) {
416+
f := New(WithClasses(true))
417+
it, err := lexers.Get("bash").Tokenise(nil, "echo FOO")
418+
assert.NoError(t, err)
419+
var buf bytes.Buffer
420+
err = f.Format(&buf, styles.Get(tt.styleName), it)
421+
assert.NoError(t, err)
422+
assert.Contains(t, buf.String(), tt.wantClass)
423+
})
424+
}
425+
}
426+
427+
func TestModeClassWithPrefix(t *testing.T) {
428+
f := New(WithClasses(true), ClassPrefix("c-"))
429+
it, err := lexers.Get("bash").Tokenise(nil, "echo FOO")
430+
assert.NoError(t, err)
431+
var buf bytes.Buffer
432+
err = f.Format(&buf, styles.Get("github"), it)
433+
assert.NoError(t, err)
434+
assert.Contains(t, buf.String(), `class="c-chroma c-light"`)
435+
}
436+
437+
func TestModeClassAbsentInInlineMode(t *testing.T) {
438+
f := New()
439+
it, err := lexers.Get("bash").Tokenise(nil, "echo FOO")
440+
assert.NoError(t, err)
441+
var buf bytes.Buffer
442+
err = f.Format(&buf, styles.Get("github"), it)
443+
assert.NoError(t, err)
444+
assert.NotContains(t, buf.String(), `light`)
445+
assert.NotContains(t, buf.String(), `dark`)
446+
}
447+
448+
func TestWriteCSSModeScoping(t *testing.T) {
449+
tests := []struct {
450+
name string
451+
styleName string
452+
selector string
453+
}{
454+
{"Light", "github", ".chroma.light"},
455+
{"Dark", "github-dark", ".chroma.dark"},
456+
}
457+
for _, tt := range tests {
458+
t.Run(tt.name, func(t *testing.T) {
459+
f := New(WithClasses(true))
460+
var buf bytes.Buffer
461+
err := f.WriteCSS(&buf, styles.Get(tt.styleName))
462+
assert.NoError(t, err)
463+
out := buf.String()
464+
assert.Contains(t, out, tt.selector)
465+
assert.Contains(t, out, tt.selector+" .k ")
466+
})
467+
}
468+
}
469+
405470
func TestStyleCache(t *testing.T) {
406471
f := New()
407472

0 commit comments

Comments
 (0)