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}