1package html
2
3import (
4 "fmt"
5 "html"
6 "io"
7 "sort"
8 "strconv"
9 "strings"
10 "sync"
11
12 "github.com/alecthomas/chroma/v2"
13)
14
15// Option sets an option of the HTML formatter.
16type Option func(f *Formatter)
17
18// Standalone configures the HTML formatter for generating a standalone HTML document.
19func Standalone(b bool) Option { return func(f *Formatter) { f.standalone = b } }
20
21// ClassPrefix sets the CSS class prefix.
22func ClassPrefix(prefix string) Option { return func(f *Formatter) { f.prefix = prefix } }
23
24// WithClasses emits HTML using CSS classes, rather than inline styles.
25func WithClasses(b bool) Option { return func(f *Formatter) { f.Classes = b } }
26
27// WithAllClasses disables an optimisation that omits redundant CSS classes.
28func WithAllClasses(b bool) Option { return func(f *Formatter) { f.allClasses = b } }
29
30// WithCustomCSS sets user's custom CSS styles.
31func WithCustomCSS(css map[chroma.TokenType]string) Option {
32 return func(f *Formatter) {
33 f.customCSS = css
34 }
35}
36
37// TabWidth sets the number of characters for a tab. Defaults to 8.
38func TabWidth(width int) Option { return func(f *Formatter) { f.tabWidth = width } }
39
40// PreventSurroundingPre prevents the surrounding pre tags around the generated code.
41func PreventSurroundingPre(b bool) Option {
42 return func(f *Formatter) {
43 f.preventSurroundingPre = b
44
45 if b {
46 f.preWrapper = nopPreWrapper
47 } else {
48 f.preWrapper = defaultPreWrapper
49 }
50 }
51}
52
53// InlineCode creates inline code wrapped in a code tag.
54func InlineCode(b bool) Option {
55 return func(f *Formatter) {
56 f.inlineCode = b
57 f.preWrapper = preWrapper{
58 start: func(code bool, styleAttr string) string {
59 if code {
60 return fmt.Sprintf(`<code%s>`, styleAttr)
61 }
62
63 return ``
64 },
65 end: func(code bool) string {
66 if code {
67 return `</code>`
68 }
69
70 return ``
71 },
72 }
73 }
74}
75
76// WithPreWrapper allows control of the surrounding pre tags.
77func WithPreWrapper(wrapper PreWrapper) Option {
78 return func(f *Formatter) {
79 f.preWrapper = wrapper
80 }
81}
82
83// WrapLongLines wraps long lines.
84func WrapLongLines(b bool) Option {
85 return func(f *Formatter) {
86 f.wrapLongLines = b
87 }
88}
89
90// WithLineNumbers formats output with line numbers.
91func WithLineNumbers(b bool) Option {
92 return func(f *Formatter) {
93 f.lineNumbers = b
94 }
95}
96
97// LineNumbersInTable will, when combined with WithLineNumbers, separate the line numbers
98// and code in table td's, which make them copy-and-paste friendly.
99func LineNumbersInTable(b bool) Option {
100 return func(f *Formatter) {
101 f.lineNumbersInTable = b
102 }
103}
104
105// WithLinkableLineNumbers decorates the line numbers HTML elements with an "id"
106// attribute so they can be linked.
107func WithLinkableLineNumbers(b bool, prefix string) Option {
108 return func(f *Formatter) {
109 f.linkableLineNumbers = b
110 f.lineNumbersIDPrefix = prefix
111 }
112}
113
114// HighlightLines higlights the given line ranges with the Highlight style.
115//
116// A range is the beginning and ending of a range as 1-based line numbers, inclusive.
117func HighlightLines(ranges [][2]int) Option {
118 return func(f *Formatter) {
119 f.highlightRanges = ranges
120 sort.Sort(f.highlightRanges)
121 }
122}
123
124// BaseLineNumber sets the initial number to start line numbering at. Defaults to 1.
125func BaseLineNumber(n int) Option {
126 return func(f *Formatter) {
127 f.baseLineNumber = n
128 }
129}
130
131// New HTML formatter.
132func New(options ...Option) *Formatter {
133 f := &Formatter{
134 baseLineNumber: 1,
135 preWrapper: defaultPreWrapper,
136 }
137 f.styleCache = newStyleCache(f)
138 for _, option := range options {
139 option(f)
140 }
141 return f
142}
143
144// PreWrapper defines the operations supported in WithPreWrapper.
145type PreWrapper interface {
146 // Start is called to write a start <pre> element.
147 // The code flag tells whether this block surrounds
148 // highlighted code. This will be false when surrounding
149 // line numbers.
150 Start(code bool, styleAttr string) string
151
152 // End is called to write the end </pre> element.
153 End(code bool) string
154}
155
156type preWrapper struct {
157 start func(code bool, styleAttr string) string
158 end func(code bool) string
159}
160
161func (p preWrapper) Start(code bool, styleAttr string) string {
162 return p.start(code, styleAttr)
163}
164
165func (p preWrapper) End(code bool) string {
166 return p.end(code)
167}
168
169var (
170 nopPreWrapper = preWrapper{
171 start: func(code bool, styleAttr string) string { return "" },
172 end: func(code bool) string { return "" },
173 }
174 defaultPreWrapper = preWrapper{
175 start: func(code bool, styleAttr string) string {
176 if code {
177 return fmt.Sprintf(`<pre%s><code>`, styleAttr)
178 }
179
180 return fmt.Sprintf(`<pre%s>`, styleAttr)
181 },
182 end: func(code bool) string {
183 if code {
184 return `</code></pre>`
185 }
186
187 return `</pre>`
188 },
189 }
190)
191
192// Formatter that generates HTML.
193type Formatter struct {
194 styleCache *styleCache
195 standalone bool
196 prefix string
197 Classes bool // Exported field to detect when classes are being used
198 allClasses bool
199 customCSS map[chroma.TokenType]string
200 preWrapper PreWrapper
201 inlineCode bool
202 preventSurroundingPre bool
203 tabWidth int
204 wrapLongLines bool
205 lineNumbers bool
206 lineNumbersInTable bool
207 linkableLineNumbers bool
208 lineNumbersIDPrefix string
209 highlightRanges highlightRanges
210 baseLineNumber int
211}
212
213type highlightRanges [][2]int
214
215func (h highlightRanges) Len() int { return len(h) }
216func (h highlightRanges) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
217func (h highlightRanges) Less(i, j int) bool { return h[i][0] < h[j][0] }
218
219func (f *Formatter) Format(w io.Writer, style *chroma.Style, iterator chroma.Iterator) (err error) {
220 return f.writeHTML(w, style, iterator.Tokens())
221}
222
223// We deliberately don't use html/template here because it is two orders of magnitude slower (benchmarked).
224//
225// OTOH we need to be super careful about correct escaping...
226func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.Token) (err error) { // nolint: gocyclo
227 css := f.styleCache.get(style, true)
228 if f.standalone {
229 fmt.Fprint(w, "<html>\n")
230 if f.Classes {
231 fmt.Fprint(w, "<style type=\"text/css\">\n")
232 err = f.WriteCSS(w, style)
233 if err != nil {
234 return err
235 }
236 fmt.Fprintf(w, "body { %s; }\n", css[chroma.Background])
237 fmt.Fprint(w, "</style>")
238 }
239 fmt.Fprintf(w, "<body%s>\n", f.styleAttr(css, chroma.Background))
240 }
241
242 wrapInTable := f.lineNumbers && f.lineNumbersInTable
243
244 lines := chroma.SplitTokensIntoLines(tokens)
245 lineDigits := len(strconv.Itoa(f.baseLineNumber + len(lines) - 1))
246 highlightIndex := 0
247
248 if wrapInTable {
249 // List line numbers in its own <td>
250 fmt.Fprintf(w, "<div%s>\n", f.styleAttr(css, chroma.PreWrapper))
251 fmt.Fprintf(w, "<table%s><tr>", f.styleAttr(css, chroma.LineTable))
252 fmt.Fprintf(w, "<td%s>\n", f.styleAttr(css, chroma.LineTableTD))
253 fmt.Fprintf(w, "%s", f.preWrapper.Start(false, f.styleAttr(css, chroma.PreWrapper)))
254 for index := range lines {
255 line := f.baseLineNumber + index
256 highlight, next := f.shouldHighlight(highlightIndex, line)
257 if next {
258 highlightIndex++
259 }
260 if highlight {
261 fmt.Fprintf(w, "<span%s>", f.styleAttr(css, chroma.LineHighlight))
262 }
263
264 fmt.Fprintf(w, "<span%s%s>%s\n</span>", f.styleAttr(css, chroma.LineNumbersTable), f.lineIDAttribute(line), f.lineTitleWithLinkIfNeeded(css, lineDigits, line))
265
266 if highlight {
267 fmt.Fprintf(w, "</span>")
268 }
269 }
270 fmt.Fprint(w, f.preWrapper.End(false))
271 fmt.Fprint(w, "</td>\n")
272 fmt.Fprintf(w, "<td%s>\n", f.styleAttr(css, chroma.LineTableTD, "width:100%"))
273 }
274
275 fmt.Fprintf(w, "%s", f.preWrapper.Start(true, f.styleAttr(css, chroma.PreWrapper)))
276
277 highlightIndex = 0
278 for index, tokens := range lines {
279 // 1-based line number.
280 line := f.baseLineNumber + index
281 highlight, next := f.shouldHighlight(highlightIndex, line)
282 if next {
283 highlightIndex++
284 }
285
286 if !(f.preventSurroundingPre || f.inlineCode) {
287 // Start of Line
288 fmt.Fprint(w, `<span`)
289
290 if highlight {
291 // Line + LineHighlight
292 if f.Classes {
293 fmt.Fprintf(w, ` class="%s %s"`, f.class(chroma.Line), f.class(chroma.LineHighlight))
294 } else {
295 fmt.Fprintf(w, ` style="%s %s"`, css[chroma.Line], css[chroma.LineHighlight])
296 }
297 fmt.Fprint(w, `>`)
298 } else {
299 fmt.Fprintf(w, "%s>", f.styleAttr(css, chroma.Line))
300 }
301
302 // Line number
303 if f.lineNumbers && !wrapInTable {
304 fmt.Fprintf(w, "<span%s%s>%s</span>", f.styleAttr(css, chroma.LineNumbers), f.lineIDAttribute(line), f.lineTitleWithLinkIfNeeded(css, lineDigits, line))
305 }
306
307 fmt.Fprintf(w, `<span%s>`, f.styleAttr(css, chroma.CodeLine))
308 }
309
310 for _, token := range tokens {
311 html := html.EscapeString(token.String())
312 attr := f.styleAttr(css, token.Type)
313 if attr != "" {
314 html = fmt.Sprintf("<span%s>%s</span>", attr, html)
315 }
316 fmt.Fprint(w, html)
317 }
318
319 if !(f.preventSurroundingPre || f.inlineCode) {
320 fmt.Fprint(w, `</span>`) // End of CodeLine
321
322 fmt.Fprint(w, `</span>`) // End of Line
323 }
324 }
325 fmt.Fprintf(w, "%s", f.preWrapper.End(true))
326
327 if wrapInTable {
328 fmt.Fprint(w, "</td></tr></table>\n")
329 fmt.Fprint(w, "</div>\n")
330 }
331
332 if f.standalone {
333 fmt.Fprint(w, "\n</body>\n")
334 fmt.Fprint(w, "</html>\n")
335 }
336
337 return nil
338}
339
340func (f *Formatter) lineIDAttribute(line int) string {
341 if !f.linkableLineNumbers {
342 return ""
343 }
344 return fmt.Sprintf(" id=\"%s\"", f.lineID(line))
345}
346
347func (f *Formatter) lineTitleWithLinkIfNeeded(css map[chroma.TokenType]string, lineDigits, line int) string {
348 title := fmt.Sprintf("%*d", lineDigits, line)
349 if !f.linkableLineNumbers {
350 return title
351 }
352 return fmt.Sprintf("<a%s href=\"#%s\">%s</a>", f.styleAttr(css, chroma.LineLink), f.lineID(line), title)
353}
354
355func (f *Formatter) lineID(line int) string {
356 return fmt.Sprintf("%s%d", f.lineNumbersIDPrefix, line)
357}
358
359func (f *Formatter) shouldHighlight(highlightIndex, line int) (bool, bool) {
360 next := false
361 for highlightIndex < len(f.highlightRanges) && line > f.highlightRanges[highlightIndex][1] {
362 highlightIndex++
363 next = true
364 }
365 if highlightIndex < len(f.highlightRanges) {
366 hrange := f.highlightRanges[highlightIndex]
367 if line >= hrange[0] && line <= hrange[1] {
368 return true, next
369 }
370 }
371 return false, next
372}
373
374func (f *Formatter) class(t chroma.TokenType) string {
375 for t != 0 {
376 if cls, ok := chroma.StandardTypes[t]; ok {
377 if cls != "" {
378 return f.prefix + cls
379 }
380 return ""
381 }
382 t = t.Parent()
383 }
384 if cls := chroma.StandardTypes[t]; cls != "" {
385 return f.prefix + cls
386 }
387 return ""
388}
389
390func (f *Formatter) styleAttr(styles map[chroma.TokenType]string, tt chroma.TokenType, extraCSS ...string) string {
391 if f.Classes {
392 cls := f.class(tt)
393 if cls == "" {
394 return ""
395 }
396 return fmt.Sprintf(` class="%s"`, cls)
397 }
398 if _, ok := styles[tt]; !ok {
399 tt = tt.SubCategory()
400 if _, ok := styles[tt]; !ok {
401 tt = tt.Category()
402 if _, ok := styles[tt]; !ok {
403 return ""
404 }
405 }
406 }
407 css := []string{styles[tt]}
408 css = append(css, extraCSS...)
409 return fmt.Sprintf(` style="%s"`, strings.Join(css, ";"))
410}
411
412func (f *Formatter) tabWidthStyle() string {
413 if f.tabWidth != 0 && f.tabWidth != 8 {
414 return fmt.Sprintf("-moz-tab-size: %[1]d; -o-tab-size: %[1]d; tab-size: %[1]d;", f.tabWidth)
415 }
416 return ""
417}
418
419// WriteCSS writes CSS style definitions (without any surrounding HTML).
420func (f *Formatter) WriteCSS(w io.Writer, style *chroma.Style) error {
421 css := f.styleCache.get(style, false)
422 // Special-case background as it is mapped to the outer ".chroma" class.
423 if _, err := fmt.Fprintf(w, "/* %s */ .%sbg { %s }\n", chroma.Background, f.prefix, css[chroma.Background]); err != nil {
424 return err
425 }
426 // Special-case PreWrapper as it is the ".chroma" class.
427 if _, err := fmt.Fprintf(w, "/* %s */ .%schroma { %s }\n", chroma.PreWrapper, f.prefix, css[chroma.PreWrapper]); err != nil {
428 return err
429 }
430 // Special-case code column of table to expand width.
431 if f.lineNumbers && f.lineNumbersInTable {
432 if _, err := fmt.Fprintf(w, "/* %s */ .%schroma .%s:last-child { width: 100%%; }",
433 chroma.LineTableTD, f.prefix, f.class(chroma.LineTableTD)); err != nil {
434 return err
435 }
436 }
437 // Special-case line number highlighting when targeted.
438 if f.lineNumbers || f.lineNumbersInTable {
439 targetedLineCSS := StyleEntryToCSS(style.Get(chroma.LineHighlight))
440 for _, tt := range []chroma.TokenType{chroma.LineNumbers, chroma.LineNumbersTable} {
441 fmt.Fprintf(w, "/* %s targeted by URL anchor */ .%schroma .%s:target { %s }\n", tt, f.prefix, f.class(tt), targetedLineCSS)
442 }
443 }
444 tts := []int{}
445 for tt := range css {
446 tts = append(tts, int(tt))
447 }
448 sort.Ints(tts)
449 for _, ti := range tts {
450 tt := chroma.TokenType(ti)
451 switch tt {
452 case chroma.Background, chroma.PreWrapper:
453 continue
454 }
455 class := f.class(tt)
456 if class == "" {
457 continue
458 }
459 styles := css[tt]
460 if _, err := fmt.Fprintf(w, "/* %s */ .%schroma .%s { %s }\n", tt, f.prefix, class, styles); err != nil {
461 return err
462 }
463 }
464 return nil
465}
466
467func (f *Formatter) styleToCSS(style *chroma.Style) map[chroma.TokenType]string {
468 classes := map[chroma.TokenType]string{}
469 bg := style.Get(chroma.Background)
470 // Convert the style.
471 for t := range chroma.StandardTypes {
472 entry := style.Get(t)
473 if t != chroma.Background {
474 entry = entry.Sub(bg)
475 }
476
477 // Inherit from custom CSS provided by user
478 tokenCategory := t.Category()
479 tokenSubCategory := t.SubCategory()
480 if t != tokenCategory {
481 if css, ok := f.customCSS[tokenCategory]; ok {
482 classes[t] = css
483 }
484 }
485 if tokenCategory != tokenSubCategory {
486 if css, ok := f.customCSS[tokenSubCategory]; ok {
487 classes[t] += css
488 }
489 }
490 // Add custom CSS provided by user
491 if css, ok := f.customCSS[t]; ok {
492 classes[t] += css
493 }
494
495 if !f.allClasses && entry.IsZero() && classes[t] == `` {
496 continue
497 }
498
499 styleEntryCSS := StyleEntryToCSS(entry)
500 if styleEntryCSS != `` && classes[t] != `` {
501 styleEntryCSS += `;`
502 }
503 classes[t] = styleEntryCSS + classes[t]
504 }
505 classes[chroma.Background] += `;` + f.tabWidthStyle()
506 classes[chroma.PreWrapper] += classes[chroma.Background]
507 // Make PreWrapper a grid to show highlight style with full width.
508 if len(f.highlightRanges) > 0 && f.customCSS[chroma.PreWrapper] == `` {
509 classes[chroma.PreWrapper] += `display: grid;`
510 }
511 // Make PreWrapper wrap long lines.
512 if f.wrapLongLines {
513 classes[chroma.PreWrapper] += `white-space: pre-wrap; word-break: break-word;`
514 }
515 lineNumbersStyle := `white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;`
516 // All rules begin with default rules followed by user provided rules
517 classes[chroma.Line] = `display: flex;` + classes[chroma.Line]
518 classes[chroma.LineNumbers] = lineNumbersStyle + classes[chroma.LineNumbers]
519 classes[chroma.LineNumbersTable] = lineNumbersStyle + classes[chroma.LineNumbersTable]
520 classes[chroma.LineTable] = "border-spacing: 0; padding: 0; margin: 0; border: 0;" + classes[chroma.LineTable]
521 classes[chroma.LineTableTD] = "vertical-align: top; padding: 0; margin: 0; border: 0;" + classes[chroma.LineTableTD]
522 classes[chroma.LineLink] = "outline: none; text-decoration: none; color: inherit" + classes[chroma.LineLink]
523 return classes
524}
525
526// StyleEntryToCSS converts a chroma.StyleEntry to CSS attributes.
527func StyleEntryToCSS(e chroma.StyleEntry) string {
528 styles := []string{}
529 if e.Colour.IsSet() {
530 styles = append(styles, "color: "+e.Colour.String())
531 }
532 if e.Background.IsSet() {
533 styles = append(styles, "background-color: "+e.Background.String())
534 }
535 if e.Bold == chroma.Yes {
536 styles = append(styles, "font-weight: bold")
537 }
538 if e.Italic == chroma.Yes {
539 styles = append(styles, "font-style: italic")
540 }
541 if e.Underline == chroma.Yes {
542 styles = append(styles, "text-decoration: underline")
543 }
544 return strings.Join(styles, "; ")
545}
546
547// Compress CSS attributes - remove spaces, transform 6-digit colours to 3.
548func compressStyle(s string) string {
549 parts := strings.Split(s, ";")
550 out := []string{}
551 for _, p := range parts {
552 p = strings.Join(strings.Fields(p), " ")
553 p = strings.Replace(p, ": ", ":", 1)
554 if strings.Contains(p, "#") {
555 c := p[len(p)-6:]
556 if c[0] == c[1] && c[2] == c[3] && c[4] == c[5] {
557 p = p[:len(p)-6] + c[0:1] + c[2:3] + c[4:5]
558 }
559 }
560 out = append(out, p)
561 }
562 return strings.Join(out, ";")
563}
564
565const styleCacheLimit = 32
566
567type styleCacheEntry struct {
568 style *chroma.Style
569 compressed bool
570 cache map[chroma.TokenType]string
571}
572
573type styleCache struct {
574 mu sync.Mutex
575 // LRU cache of compiled (and possibly compressed) styles. This is a slice
576 // because the cache size is small, and a slice is sufficiently fast for
577 // small N.
578 cache []styleCacheEntry
579 f *Formatter
580}
581
582func newStyleCache(f *Formatter) *styleCache {
583 return &styleCache{f: f}
584}
585
586func (l *styleCache) get(style *chroma.Style, compress bool) map[chroma.TokenType]string {
587 l.mu.Lock()
588 defer l.mu.Unlock()
589
590 // Look for an existing entry.
591 for i := len(l.cache) - 1; i >= 0; i-- {
592 entry := l.cache[i]
593 if entry.style == style && entry.compressed == compress {
594 // Top of the cache, no need to adjust the order.
595 if i == len(l.cache)-1 {
596 return entry.cache
597 }
598 // Move this entry to the end of the LRU
599 copy(l.cache[i:], l.cache[i+1:])
600 l.cache[len(l.cache)-1] = entry
601 return entry.cache
602 }
603 }
604
605 // No entry, create one.
606 cached := l.f.styleToCSS(style)
607 if !l.f.Classes {
608 for t, style := range cached {
609 cached[t] = compressStyle(style)
610 }
611 }
612 if compress {
613 for t, style := range cached {
614 cached[t] = compressStyle(style)
615 }
616 }
617 // Evict the oldest entry.
618 if len(l.cache) >= styleCacheLimit {
619 l.cache = l.cache[0:copy(l.cache, l.cache[1:])]
620 }
621 l.cache = append(l.cache, styleCacheEntry{style: style, cache: cached, compressed: compress})
622 return cached
623}