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}