1 package html
2
3 import (
4 "fmt"
5 "html"
6 "io"
7 "sort"
8 "strings"
9
10 "github.com/alecthomas/chroma/v2"
11 )
12
13
14 type Option func(f *Formatter)
15
16
17 func Standalone(b bool) Option { return func(f *Formatter) { f.standalone = b } }
18
19
20 func ClassPrefix(prefix string) Option { return func(f *Formatter) { f.prefix = prefix } }
21
22
23 func WithClasses(b bool) Option { return func(f *Formatter) { f.Classes = b } }
24
25
26 func WithAllClasses(b bool) Option { return func(f *Formatter) { f.allClasses = b } }
27
28
29 func WithCustomCSS(css map[chroma.TokenType]string) Option {
30 return func(f *Formatter) {
31 f.customCSS = css
32 }
33 }
34
35
36 func TabWidth(width int) Option { return func(f *Formatter) { f.tabWidth = width } }
37
38
39 func 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
52 func 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
75 func WithPreWrapper(wrapper PreWrapper) Option {
76 return func(f *Formatter) {
77 f.preWrapper = wrapper
78 }
79 }
80
81
82 func WrapLongLines(b bool) Option {
83 return func(f *Formatter) {
84 f.wrapLongLines = b
85 }
86 }
87
88
89 func WithLineNumbers(b bool) Option {
90 return func(f *Formatter) {
91 f.lineNumbers = b
92 }
93 }
94
95
96
97 func LineNumbersInTable(b bool) Option {
98 return func(f *Formatter) {
99 f.lineNumbersInTable = b
100 }
101 }
102
103
104
105 func WithLinkableLineNumbers(b bool, prefix string) Option {
106 return func(f *Formatter) {
107 f.linkableLineNumbers = b
108 f.lineNumbersIDPrefix = prefix
109 }
110 }
111
112
113
114
115 func HighlightLines(ranges [][2]int) Option {
116 return func(f *Formatter) {
117 f.highlightRanges = ranges
118 sort.Sort(f.highlightRanges)
119 }
120 }
121
122
123 func BaseLineNumber(n int) Option {
124 return func(f *Formatter) {
125 f.baseLineNumber = n
126 }
127 }
128
129
130 func 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
142 type PreWrapper interface {
143
144
145
146
147 Start(code bool, styleAttr string) string
148
149
150 End(code bool) string
151 }
152
153 type preWrapper struct {
154 start func(code bool, styleAttr string) string
155 end func(code bool) string
156 }
157
158 func (p preWrapper) Start(code bool, styleAttr string) string {
159 return p.start(code, styleAttr)
160 }
161
162 func (p preWrapper) End(code bool) string {
163 return p.end(code)
164 }
165
166 var (
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%s><code>`, styleAttr)
175 }
176
177 return fmt.Sprintf(`<pre%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
190 type Formatter struct {
191 standalone bool
192 prefix string
193 Classes bool
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
209 type highlightRanges [][2]int
210
211 func (h highlightRanges) Len() int { return len(h) }
212 func (h highlightRanges) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
213 func (h highlightRanges) Less(i, j int) bool { return h[i][0] < h[j][0] }
214
215 func (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
220
221
222 func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.Token) (err error) {
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
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(css, 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
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
289 fmt.Fprint(w, `<span`)
290
291 if highlight {
292
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
304 if f.lineNumbers && !wrapInTable {
305 fmt.Fprintf(w, "<span%s%s>%s</span>", f.styleAttr(css, chroma.LineNumbers), f.lineIDAttribute(line), f.lineTitleWithLinkIfNeeded(css, 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>`)
322
323 fmt.Fprint(w, `</span>`)
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
341 func (f *Formatter) lineIDAttribute(line int) string {
342 if !f.linkableLineNumbers {
343 return ""
344 }
345 return fmt.Sprintf(" id=\"%s\"", f.lineID(line))
346 }
347
348 func (f *Formatter) lineTitleWithLinkIfNeeded(css map[chroma.TokenType]string, lineDigits, line int) string {
349 title := fmt.Sprintf("%*d", lineDigits, line)
350 if !f.linkableLineNumbers {
351 return title
352 }
353 return fmt.Sprintf("<a%s href=\"#%s\">%s</a>", f.styleAttr(css, chroma.LineLink), f.lineID(line), title)
354 }
355
356 func (f *Formatter) lineID(line int) string {
357 return fmt.Sprintf("%s%d", f.lineNumbersIDPrefix, line)
358 }
359
360 func (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
375 func (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
391 func (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
413 func (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
421 func (f *Formatter) WriteCSS(w io.Writer, style *chroma.Style) error {
422 css := f.styleToCSS(style)
423
424 if _, err := fmt.Fprintf(w, "/* %s */ .%sbg { %s }\n", chroma.Background, f.prefix, css[chroma.Background]); err != nil {
425 return err
426 }
427
428 if _, err := fmt.Fprintf(w, "/* %s */ .%schroma { %s }\n", chroma.PreWrapper, f.prefix, css[chroma.PreWrapper]); err != nil {
429 return err
430 }
431
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
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
468 func (f *Formatter) styleToCSS(style *chroma.Style) map[chroma.TokenType]string {
469 classes := map[chroma.TokenType]string{}
470 bg := style.Get(chroma.Background)
471
472 for t := range chroma.StandardTypes {
473 entry := style.Get(t)
474 if t != chroma.Background {
475 entry = entry.Sub(bg)
476 }
477
478
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
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
509 if len(f.highlightRanges) > 0 && f.customCSS[chroma.PreWrapper] == `` {
510 classes[chroma.PreWrapper] += `display: grid;`
511 }
512
513 if f.wrapLongLines {
514 classes[chroma.PreWrapper] += `white-space: pre-wrap; word-break: break-word;`
515 }
516 lineNumbersStyle := `white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;`
517
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 classes[chroma.LineLink] = "outline: none; text-decoration: none; color: inherit" + classes[chroma.LineLink]
524 return classes
525 }
526
527
528 func StyleEntryToCSS(e chroma.StyleEntry) string {
529 styles := []string{}
530 if e.Colour.IsSet() {
531 styles = append(styles, "color: "+e.Colour.String())
532 }
533 if e.Background.IsSet() {
534 styles = append(styles, "background-color: "+e.Background.String())
535 }
536 if e.Bold == chroma.Yes {
537 styles = append(styles, "font-weight: bold")
538 }
539 if e.Italic == chroma.Yes {
540 styles = append(styles, "font-style: italic")
541 }
542 if e.Underline == chroma.Yes {
543 styles = append(styles, "text-decoration: underline")
544 }
545 return strings.Join(styles, "; ")
546 }
547
548
549 func compressStyle(s string) string {
550 parts := strings.Split(s, ";")
551 out := []string{}
552 for _, p := range parts {
553 p = strings.Join(strings.Fields(p), " ")
554 p = strings.Replace(p, ": ", ":", 1)
555 if strings.Contains(p, "#") {
556 c := p[len(p)-6:]
557 if c[0] == c[1] && c[2] == c[3] && c[4] == c[5] {
558 p = p[:len(p)-6] + c[0:1] + c[2:3] + c[4:5]
559 }
560 }
561 out = append(out, p)
562 }
563 return strings.Join(out, ";")
564 }
565
View as plain text