git.sr.ht/~pingoo/stdx@v0.0.0-20240218134121-094174641f6e/goldmark-highlighting/highlighting.go (about) 1 // package highlighting is a extension for the goldmark(http://github.com/yuin/goldmark). 2 // 3 // This extension adds syntax-highlighting to the fenced code blocks using 4 // chroma(https://github.com/alecthomas/chroma). 5 package highlighting 6 7 import ( 8 "bytes" 9 "io" 10 "strconv" 11 "strings" 12 13 "github.com/yuin/goldmark" 14 "github.com/yuin/goldmark/ast" 15 "github.com/yuin/goldmark/parser" 16 "github.com/yuin/goldmark/renderer" 17 "github.com/yuin/goldmark/renderer/html" 18 "github.com/yuin/goldmark/text" 19 "github.com/yuin/goldmark/util" 20 21 "github.com/alecthomas/chroma/v2" 22 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 23 "github.com/alecthomas/chroma/v2/lexers" 24 "github.com/alecthomas/chroma/v2/styles" 25 ) 26 27 // ImmutableAttributes is a read-only interface for ast.Attributes. 28 type ImmutableAttributes interface { 29 // Get returns (value, true) if an attribute associated with given 30 // name exists, otherwise (nil, false) 31 Get(name []byte) (interface{}, bool) 32 33 // GetString returns (value, true) if an attribute associated with given 34 // name exists, otherwise (nil, false) 35 GetString(name string) (interface{}, bool) 36 37 // All returns all attributes. 38 All() []ast.Attribute 39 } 40 41 type immutableAttributes struct { 42 n ast.Node 43 } 44 45 func (a *immutableAttributes) Get(name []byte) (interface{}, bool) { 46 return a.n.Attribute(name) 47 } 48 49 func (a *immutableAttributes) GetString(name string) (interface{}, bool) { 50 return a.n.AttributeString(name) 51 } 52 53 func (a *immutableAttributes) All() []ast.Attribute { 54 if a.n.Attributes() == nil { 55 return []ast.Attribute{} 56 } 57 return a.n.Attributes() 58 } 59 60 // CodeBlockContext holds contextual information of code highlighting. 61 type CodeBlockContext interface { 62 // Language returns (language, true) if specified, otherwise (nil, false). 63 Language() ([]byte, bool) 64 65 // Highlighted returns true if this code block can be highlighted, otherwise false. 66 Highlighted() bool 67 68 // Attributes return attributes of the code block. 69 Attributes() ImmutableAttributes 70 } 71 72 type codeBlockContext struct { 73 language []byte 74 highlighted bool 75 attributes ImmutableAttributes 76 } 77 78 func newCodeBlockContext(language []byte, highlighted bool, attrs ImmutableAttributes) CodeBlockContext { 79 return &codeBlockContext{ 80 language: language, 81 highlighted: highlighted, 82 attributes: attrs, 83 } 84 } 85 86 func (c *codeBlockContext) Language() ([]byte, bool) { 87 if c.language != nil { 88 return c.language, true 89 } 90 return nil, false 91 } 92 93 func (c *codeBlockContext) Highlighted() bool { 94 return c.highlighted 95 } 96 97 func (c *codeBlockContext) Attributes() ImmutableAttributes { 98 return c.attributes 99 } 100 101 // WrapperRenderer renders wrapper elements like div, pre, etc. 102 type WrapperRenderer func(w util.BufWriter, context CodeBlockContext, entering bool) 103 104 // CodeBlockOptions creates Chroma options per code block. 105 type CodeBlockOptions func(ctx CodeBlockContext) []chromahtml.Option 106 107 // Config struct holds options for the extension. 108 type Config struct { 109 html.Config 110 111 // Style is a highlighting style. 112 // Supported styles are defined under https://github.com/alecthomas/chroma/tree/master/formatters. 113 Style string 114 115 // Pass in a custom Chroma style. If this is not nil, the Style string will be ignored 116 CustomStyle *chroma.Style 117 118 // If set, will try to guess language if none provided. 119 // If the guessing fails, we will fall back to a text lexer. 120 // Note that while Chroma's API supports language guessing, the implementation 121 // is not there yet, so you will currently always get the basic text lexer. 122 GuessLanguage bool 123 124 // FormatOptions is a option related to output formats. 125 // See https://github.com/alecthomas/chroma#the-html-formatter for details. 126 FormatOptions []chromahtml.Option 127 128 // CSSWriter is an io.Writer that will be used as CSS data output buffer. 129 // If WithClasses() is enabled, you can get CSS data corresponds to the style. 130 CSSWriter io.Writer 131 132 // CodeBlockOptions allows set Chroma options per code block. 133 CodeBlockOptions CodeBlockOptions 134 135 // WrapperRenderer allows you to change wrapper elements. 136 WrapperRenderer WrapperRenderer 137 } 138 139 // NewConfig returns a new Config with defaults. 140 func NewConfig() Config { 141 return Config{ 142 Config: html.NewConfig(), 143 Style: "github", 144 FormatOptions: []chromahtml.Option{}, 145 CSSWriter: nil, 146 WrapperRenderer: nil, 147 CodeBlockOptions: nil, 148 } 149 } 150 151 // SetOption implements renderer.SetOptioner. 152 func (c *Config) SetOption(name renderer.OptionName, value interface{}) { 153 switch name { 154 case optStyle: 155 c.Style = value.(string) 156 case optCustomStyle: 157 c.CustomStyle = value.(*chroma.Style) 158 case optFormatOptions: 159 if value != nil { 160 c.FormatOptions = value.([]chromahtml.Option) 161 } 162 case optCSSWriter: 163 c.CSSWriter = value.(io.Writer) 164 case optWrapperRenderer: 165 c.WrapperRenderer = value.(WrapperRenderer) 166 case optCodeBlockOptions: 167 c.CodeBlockOptions = value.(CodeBlockOptions) 168 case optGuessLanguage: 169 c.GuessLanguage = value.(bool) 170 default: 171 c.Config.SetOption(name, value) 172 } 173 } 174 175 // Option interface is a functional option interface for the extension. 176 type Option interface { 177 renderer.Option 178 // SetHighlightingOption sets given option to the extension. 179 SetHighlightingOption(*Config) 180 } 181 182 type withHTMLOptions struct { 183 value []html.Option 184 } 185 186 func (o *withHTMLOptions) SetConfig(c *renderer.Config) { 187 if o.value != nil { 188 for _, v := range o.value { 189 v.(renderer.Option).SetConfig(c) 190 } 191 } 192 } 193 194 func (o *withHTMLOptions) SetHighlightingOption(c *Config) { 195 if o.value != nil { 196 for _, v := range o.value { 197 v.SetHTMLOption(&c.Config) 198 } 199 } 200 } 201 202 // WithHTMLOptions is functional option that wraps goldmark HTMLRenderer options. 203 func WithHTMLOptions(opts ...html.Option) Option { 204 return &withHTMLOptions{opts} 205 } 206 207 const optStyle renderer.OptionName = "HighlightingStyle" 208 const optCustomStyle renderer.OptionName = "HighlightingCustomStyle" 209 210 var highlightLinesAttrName = []byte("hl_lines") 211 212 var styleAttrName = []byte("hl_style") 213 var nohlAttrName = []byte("nohl") 214 var linenosAttrName = []byte("linenos") 215 var linenosTableAttrValue = []byte("table") 216 var linenosInlineAttrValue = []byte("inline") 217 var linenostartAttrName = []byte("linenostart") 218 219 type withStyle struct { 220 value string 221 } 222 223 func (o *withStyle) SetConfig(c *renderer.Config) { 224 c.Options[optStyle] = o.value 225 } 226 227 func (o *withStyle) SetHighlightingOption(c *Config) { 228 c.Style = o.value 229 } 230 231 // WithStyle is a functional option that changes highlighting style. 232 func WithStyle(style string) Option { 233 return &withStyle{style} 234 } 235 236 type withCustomStyle struct { 237 value *chroma.Style 238 } 239 240 func (o *withCustomStyle) SetConfig(c *renderer.Config) { 241 c.Options[optCustomStyle] = o.value 242 } 243 244 func (o *withCustomStyle) SetHighlightingOption(c *Config) { 245 c.CustomStyle = o.value 246 } 247 248 // WithStyle is a functional option that changes highlighting style. 249 func WithCustomStyle(style *chroma.Style) Option { 250 return &withCustomStyle{style} 251 } 252 253 const optCSSWriter renderer.OptionName = "HighlightingCSSWriter" 254 255 type withCSSWriter struct { 256 value io.Writer 257 } 258 259 func (o *withCSSWriter) SetConfig(c *renderer.Config) { 260 c.Options[optCSSWriter] = o.value 261 } 262 263 func (o *withCSSWriter) SetHighlightingOption(c *Config) { 264 c.CSSWriter = o.value 265 } 266 267 // WithCSSWriter is a functional option that sets io.Writer for CSS data. 268 func WithCSSWriter(w io.Writer) Option { 269 return &withCSSWriter{w} 270 } 271 272 const optGuessLanguage renderer.OptionName = "HighlightingGuessLanguage" 273 274 type withGuessLanguage struct { 275 value bool 276 } 277 278 func (o *withGuessLanguage) SetConfig(c *renderer.Config) { 279 c.Options[optGuessLanguage] = o.value 280 } 281 282 func (o *withGuessLanguage) SetHighlightingOption(c *Config) { 283 c.GuessLanguage = o.value 284 } 285 286 // WithGuessLanguage is a functional option that toggles language guessing 287 // if none provided. 288 func WithGuessLanguage(b bool) Option { 289 return &withGuessLanguage{value: b} 290 } 291 292 const optWrapperRenderer renderer.OptionName = "HighlightingWrapperRenderer" 293 294 type withWrapperRenderer struct { 295 value WrapperRenderer 296 } 297 298 func (o *withWrapperRenderer) SetConfig(c *renderer.Config) { 299 c.Options[optWrapperRenderer] = o.value 300 } 301 302 func (o *withWrapperRenderer) SetHighlightingOption(c *Config) { 303 c.WrapperRenderer = o.value 304 } 305 306 // WithWrapperRenderer is a functional option that sets WrapperRenderer that 307 // renders wrapper elements like div, pre, etc. 308 func WithWrapperRenderer(w WrapperRenderer) Option { 309 return &withWrapperRenderer{w} 310 } 311 312 const optCodeBlockOptions renderer.OptionName = "HighlightingCodeBlockOptions" 313 314 type withCodeBlockOptions struct { 315 value CodeBlockOptions 316 } 317 318 func (o *withCodeBlockOptions) SetConfig(c *renderer.Config) { 319 c.Options[optWrapperRenderer] = o.value 320 } 321 322 func (o *withCodeBlockOptions) SetHighlightingOption(c *Config) { 323 c.CodeBlockOptions = o.value 324 } 325 326 // WithCodeBlockOptions is a functional option that sets CodeBlockOptions that 327 // allows setting Chroma options per code block. 328 func WithCodeBlockOptions(c CodeBlockOptions) Option { 329 return &withCodeBlockOptions{value: c} 330 } 331 332 const optFormatOptions renderer.OptionName = "HighlightingFormatOptions" 333 334 type withFormatOptions struct { 335 value []chromahtml.Option 336 } 337 338 func (o *withFormatOptions) SetConfig(c *renderer.Config) { 339 if _, ok := c.Options[optFormatOptions]; !ok { 340 c.Options[optFormatOptions] = []chromahtml.Option{} 341 } 342 c.Options[optFormatOptions] = append(c.Options[optFormatOptions].([]chromahtml.Option), o.value...) 343 } 344 345 func (o *withFormatOptions) SetHighlightingOption(c *Config) { 346 c.FormatOptions = append(c.FormatOptions, o.value...) 347 } 348 349 // WithFormatOptions is a functional option that wraps chroma HTML formatter options. 350 func WithFormatOptions(opts ...chromahtml.Option) Option { 351 return &withFormatOptions{opts} 352 } 353 354 // HTMLRenderer struct is a renderer.NodeRenderer implementation for the extension. 355 type HTMLRenderer struct { 356 Config 357 } 358 359 // NewHTMLRenderer builds a new HTMLRenderer with given options and returns it. 360 func NewHTMLRenderer(opts ...Option) renderer.NodeRenderer { 361 r := &HTMLRenderer{ 362 Config: NewConfig(), 363 } 364 for _, opt := range opts { 365 opt.SetHighlightingOption(&r.Config) 366 } 367 return r 368 } 369 370 // RegisterFuncs implements NodeRenderer.RegisterFuncs. 371 func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 372 reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock) 373 } 374 375 func getAttributes(node *ast.FencedCodeBlock, infostr []byte) ImmutableAttributes { 376 if node.Attributes() != nil { 377 return &immutableAttributes{node} 378 } 379 if infostr != nil { 380 attrStartIdx := -1 381 382 for idx, char := range infostr { 383 if char == '{' { 384 attrStartIdx = idx 385 break 386 } 387 } 388 if attrStartIdx > 0 { 389 n := ast.NewTextBlock() // dummy node for storing attributes 390 attrStr := infostr[attrStartIdx:] 391 if attrs, hasAttr := parser.ParseAttributes(text.NewReader(attrStr)); hasAttr { 392 for _, attr := range attrs { 393 n.SetAttribute(attr.Name, attr.Value) 394 } 395 return &immutableAttributes{n} 396 } 397 } 398 } 399 return nil 400 } 401 402 func (r *HTMLRenderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { 403 n := node.(*ast.FencedCodeBlock) 404 if !entering { 405 return ast.WalkContinue, nil 406 } 407 language := n.Language(source) 408 409 chromaFormatterOptions := make([]chromahtml.Option, len(r.FormatOptions)) 410 copy(chromaFormatterOptions, r.FormatOptions) 411 412 style := r.CustomStyle 413 if style == nil { 414 style = styles.Get(r.Style) 415 } 416 nohl := false 417 418 var info []byte 419 if n.Info != nil { 420 info = n.Info.Segment.Value(source) 421 } 422 attrs := getAttributes(n, info) 423 if attrs != nil { 424 baseLineNumber := 1 425 if linenostartAttr, ok := attrs.Get(linenostartAttrName); ok { 426 if linenostart, ok := linenostartAttr.(float64); ok { 427 baseLineNumber = int(linenostart) 428 chromaFormatterOptions = append(chromaFormatterOptions, chromahtml.BaseLineNumber(baseLineNumber)) 429 } 430 } 431 if linesAttr, hasLinesAttr := attrs.Get(highlightLinesAttrName); hasLinesAttr { 432 if lines, ok := linesAttr.([]interface{}); ok { 433 var hlRanges [][2]int 434 for _, l := range lines { 435 if ln, ok := l.(float64); ok { 436 hlRanges = append(hlRanges, [2]int{int(ln) + baseLineNumber - 1, int(ln) + baseLineNumber - 1}) 437 } 438 if rng, ok := l.([]uint8); ok { 439 slices := strings.Split(string([]byte(rng)), "-") 440 lhs, err := strconv.Atoi(slices[0]) 441 if err != nil { 442 continue 443 } 444 rhs := lhs 445 if len(slices) > 1 { 446 rhs, err = strconv.Atoi(slices[1]) 447 if err != nil { 448 continue 449 } 450 } 451 hlRanges = append(hlRanges, [2]int{lhs + baseLineNumber - 1, rhs + baseLineNumber - 1}) 452 } 453 } 454 chromaFormatterOptions = append(chromaFormatterOptions, chromahtml.HighlightLines(hlRanges)) 455 } 456 } 457 if styleAttr, hasStyleAttr := attrs.Get(styleAttrName); hasStyleAttr { 458 if st, ok := styleAttr.([]uint8); ok { 459 styleStr := string([]byte(st)) 460 style = styles.Get(styleStr) 461 } 462 } 463 if _, hasNohlAttr := attrs.Get(nohlAttrName); hasNohlAttr { 464 nohl = true 465 } 466 467 if linenosAttr, ok := attrs.Get(linenosAttrName); ok { 468 switch v := linenosAttr.(type) { 469 case bool: 470 chromaFormatterOptions = append(chromaFormatterOptions, chromahtml.WithLineNumbers(v)) 471 case []uint8: 472 if v != nil { 473 chromaFormatterOptions = append(chromaFormatterOptions, chromahtml.WithLineNumbers(true)) 474 } 475 if bytes.Equal(v, linenosTableAttrValue) { 476 chromaFormatterOptions = append(chromaFormatterOptions, chromahtml.LineNumbersInTable(true)) 477 } else if bytes.Equal(v, linenosInlineAttrValue) { 478 chromaFormatterOptions = append(chromaFormatterOptions, chromahtml.LineNumbersInTable(false)) 479 } 480 } 481 } 482 } 483 484 var lexer chroma.Lexer 485 if language != nil { 486 lexer = lexers.Get(string(language)) 487 } 488 if !nohl && (lexer != nil || r.GuessLanguage) { 489 if style == nil { 490 style = styles.Fallback 491 } 492 var buffer bytes.Buffer 493 l := n.Lines().Len() 494 for i := 0; i < l; i++ { 495 line := n.Lines().At(i) 496 buffer.Write(line.Value(source)) 497 } 498 499 if lexer == nil { 500 lexer = lexers.Analyse(buffer.String()) 501 if lexer == nil { 502 lexer = lexers.Fallback 503 } 504 language = []byte(strings.ToLower(lexer.Config().Name)) 505 } 506 lexer = chroma.Coalesce(lexer) 507 508 iterator, err := lexer.Tokenise(nil, buffer.String()) 509 if err == nil { 510 c := newCodeBlockContext(language, true, attrs) 511 512 if r.CodeBlockOptions != nil { 513 chromaFormatterOptions = append(chromaFormatterOptions, r.CodeBlockOptions(c)...) 514 } 515 formatter := chromahtml.New(chromaFormatterOptions...) 516 if r.WrapperRenderer != nil { 517 r.WrapperRenderer(w, c, true) 518 } 519 _ = formatter.Format(w, style, iterator) == nil 520 if r.WrapperRenderer != nil { 521 r.WrapperRenderer(w, c, false) 522 } 523 if r.CSSWriter != nil { 524 _ = formatter.WriteCSS(r.CSSWriter, style) 525 } 526 return ast.WalkContinue, nil 527 } 528 } 529 530 var c CodeBlockContext 531 if r.WrapperRenderer != nil { 532 c = newCodeBlockContext(language, false, attrs) 533 r.WrapperRenderer(w, c, true) 534 } else { 535 _, _ = w.WriteString("<pre><code") 536 language := n.Language(source) 537 if language != nil { 538 _, _ = w.WriteString(" class=\"language-") 539 r.Writer.Write(w, language) 540 _, _ = w.WriteString("\"") 541 } 542 _ = w.WriteByte('>') 543 } 544 l := n.Lines().Len() 545 for i := 0; i < l; i++ { 546 line := n.Lines().At(i) 547 r.Writer.RawWrite(w, line.Value(source)) 548 } 549 if r.WrapperRenderer != nil { 550 r.WrapperRenderer(w, c, false) 551 } else { 552 _, _ = w.WriteString("</code></pre>\n") 553 } 554 return ast.WalkContinue, nil 555 } 556 557 type highlighting struct { 558 options []Option 559 } 560 561 // Highlighting is a goldmark.Extender implementation. 562 var Highlighting = &highlighting{ 563 options: []Option{}, 564 } 565 566 // NewHighlighting returns a new extension with given options. 567 func NewHighlighting(opts ...Option) goldmark.Extender { 568 return &highlighting{ 569 options: opts, 570 } 571 } 572 573 // Extend implements goldmark.Extender. 574 func (e *highlighting) Extend(m goldmark.Markdown) { 575 m.Renderer().AddOptions(renderer.WithNodeRenderers( 576 util.Prioritized(NewHTMLRenderer(e.options...), 200), 577 )) 578 }