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