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