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  }