├── .gitignore ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── highlighting.go └── highlighting_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | *.pprof 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Yusuke Inuzuka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | goldmark-highlighting 2 | ========================= 3 | 4 | goldmark-highlighting is an extension for the [goldmark](http://github.com/yuin/goldmark) 5 | that adds syntax-highlighting to the fenced code blocks. 6 | 7 | goldmark-highlighting uses [chroma](https://github.com/alecthomas/chroma) as a 8 | syntax highlighter. 9 | 10 | Installation 11 | -------------------- 12 | 13 | ``` 14 | go get github.com/yuin/goldmark-highlighting/v2 15 | ``` 16 | 17 | Usage 18 | -------------------- 19 | 20 | ```go 21 | package main 22 | 23 | import ( 24 | "bytes" 25 | "fmt" 26 | 27 | "github.com/yuin/goldmark" 28 | 29 | chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 30 | highlighting "github.com/yuin/goldmark-highlighting/v2" 31 | ) 32 | 33 | func main() { 34 | mdsrc := ` 35 | Title 36 | ======= 37 | ` + "```" + ` 38 | func main() { 39 | fmt.Println("ok") 40 | } 41 | ` + "```" + ` 42 | ` 43 | 44 | // Simple usage 45 | markdown := goldmark.New( 46 | goldmark.WithExtensions( 47 | highlighting.Highlighting, 48 | ), 49 | ) 50 | var buf bytes.Buffer 51 | if err := markdown.Convert([]byte(mdsrc), &buf); err != nil { 52 | panic(err) 53 | } 54 | title := buf.String() 55 | fmt.Print(title) 56 | 57 | // Custom configuration 58 | markdown2 := goldmark.New( 59 | goldmark.WithExtensions( 60 | highlighting.NewHighlighting( 61 | highlighting.WithStyle("monokai"), 62 | highlighting.WithFormatOptions( 63 | chromahtml.WithLineNumbers(true), 64 | ), 65 | ), 66 | ), 67 | ) 68 | var buf2 bytes.Buffer 69 | if err := markdown2.Convert([]byte(mdsrc), &buf2); err != nil { 70 | panic(err) 71 | } 72 | title2 := buf2.String() 73 | fmt.Print(title2) 74 | } 75 | 76 | ``` 77 | 78 | License 79 | -------------------- 80 | MIT 81 | 82 | Author 83 | -------------------- 84 | Yusuke Inuzuka 85 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/yuin/goldmark-highlighting/v2 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/alecthomas/chroma/v2 v2.2.0 7 | github.com/dlclark/regexp2 v1.7.0 // indirect 8 | github.com/yuin/goldmark v1.4.15 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/chroma/v2 v2.2.0 h1:Aten8jfQwUqEdadVFFjNyjx7HTexhKP0XuqBG67mRDY= 2 | github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= 3 | github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae h1:zzGwJfFlFGD94CyyYwCJeSuD32Gj9GTaSi5y9hoVzdY= 4 | github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 9 | github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= 10 | github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 11 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 12 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 13 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 14 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 15 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 16 | github.com/yuin/goldmark v1.4.15 h1:CFa84T0goNn/UIXYS+dmjjVxMyTAvpOmzld40N/nfK0= 17 | github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 18 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 19 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 20 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 21 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 22 | -------------------------------------------------------------------------------- /highlighting.go: -------------------------------------------------------------------------------- 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("
')
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("
\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 | } 579 | -------------------------------------------------------------------------------- /highlighting_test.go: -------------------------------------------------------------------------------- 1 | package highlighting 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/alecthomas/chroma/v2" 10 | chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 11 | "github.com/yuin/goldmark" 12 | "github.com/yuin/goldmark/testutil" 13 | "github.com/yuin/goldmark/util" 14 | ) 15 | 16 | func TestHighlighting(t *testing.T) { 17 | var css bytes.Buffer 18 | markdown := goldmark.New( 19 | goldmark.WithExtensions( 20 | NewHighlighting( 21 | WithStyle("monokai"), 22 | WithCSSWriter(&css), 23 | WithFormatOptions( 24 | chromahtml.WithClasses(true), 25 | chromahtml.WithLineNumbers(false), 26 | ), 27 | WithWrapperRenderer(func(w util.BufWriter, c CodeBlockContext, entering bool) { 28 | _, ok := c.Language() 29 | if entering { 30 | if !ok { 31 | w.WriteString("
")
 32 | 							return
 33 | 						}
 34 | 						w.WriteString(`
`) 35 | } else { 36 | if !ok { 37 | w.WriteString("
") 38 | return 39 | } 40 | w.WriteString(``) 41 | } 42 | }), 43 | WithCodeBlockOptions(func(c CodeBlockContext) []chromahtml.Option { 44 | if language, ok := c.Language(); ok { 45 | // Turn on line numbers for Go only. 46 | if string(language) == "go" { 47 | return []chromahtml.Option{ 48 | chromahtml.WithLineNumbers(true), 49 | } 50 | } 51 | } 52 | return nil 53 | }), 54 | ), 55 | ), 56 | ) 57 | var buffer bytes.Buffer 58 | if err := markdown.Convert([]byte(` 59 | Title 60 | ======= 61 | `+"``` go\n"+`func main() { 62 | fmt.Println("ok") 63 | } 64 | `+"```"+` 65 | `), &buffer); err != nil { 66 | t.Fatal(err) 67 | } 68 | 69 | if strings.TrimSpace(buffer.String()) != strings.TrimSpace(` 70 |

Title

71 |
1func main() {
 72 | 2    fmt.Println("ok")
 73 | 3}
 74 | 
75 | `) { 76 | t.Error("failed to render HTML\n") 77 | } 78 | 79 | expected := strings.TrimSpace(`/* Background */ .bg { color: #f8f8f2; background-color: #272822; } 80 | /* PreWrapper */ .chroma { color: #f8f8f2; background-color: #272822; } 81 | /* LineNumbers targeted by URL anchor */ .chroma .ln:target { color: #f8f8f2; background-color: #3c3d38 } 82 | /* LineNumbersTable targeted by URL anchor */ .chroma .lnt:target { color: #f8f8f2; background-color: #3c3d38 } 83 | /* Error */ .chroma .err { color: #960050; background-color: #1e0010 } 84 | /* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; } 85 | /* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; } 86 | /* LineHighlight */ .chroma .hl { background-color: #3c3d38 } 87 | /* LineNumbersTable */ .chroma .lnt { white-space: pre; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f } 88 | /* LineNumbers */ .chroma .ln { white-space: pre; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f } 89 | /* Line */ .chroma .line { display: flex; } 90 | /* Keyword */ .chroma .k { color: #66d9ef } 91 | /* KeywordConstant */ .chroma .kc { color: #66d9ef } 92 | /* KeywordDeclaration */ .chroma .kd { color: #66d9ef } 93 | /* KeywordNamespace */ .chroma .kn { color: #f92672 } 94 | /* KeywordPseudo */ .chroma .kp { color: #66d9ef } 95 | /* KeywordReserved */ .chroma .kr { color: #66d9ef } 96 | /* KeywordType */ .chroma .kt { color: #66d9ef } 97 | /* NameAttribute */ .chroma .na { color: #a6e22e } 98 | /* NameClass */ .chroma .nc { color: #a6e22e } 99 | /* NameConstant */ .chroma .no { color: #66d9ef } 100 | /* NameDecorator */ .chroma .nd { color: #a6e22e } 101 | /* NameException */ .chroma .ne { color: #a6e22e } 102 | /* NameFunction */ .chroma .nf { color: #a6e22e } 103 | /* NameOther */ .chroma .nx { color: #a6e22e } 104 | /* NameTag */ .chroma .nt { color: #f92672 } 105 | /* Literal */ .chroma .l { color: #ae81ff } 106 | /* LiteralDate */ .chroma .ld { color: #e6db74 } 107 | /* LiteralString */ .chroma .s { color: #e6db74 } 108 | /* LiteralStringAffix */ .chroma .sa { color: #e6db74 } 109 | /* LiteralStringBacktick */ .chroma .sb { color: #e6db74 } 110 | /* LiteralStringChar */ .chroma .sc { color: #e6db74 } 111 | /* LiteralStringDelimiter */ .chroma .dl { color: #e6db74 } 112 | /* LiteralStringDoc */ .chroma .sd { color: #e6db74 } 113 | /* LiteralStringDouble */ .chroma .s2 { color: #e6db74 } 114 | /* LiteralStringEscape */ .chroma .se { color: #ae81ff } 115 | /* LiteralStringHeredoc */ .chroma .sh { color: #e6db74 } 116 | /* LiteralStringInterpol */ .chroma .si { color: #e6db74 } 117 | /* LiteralStringOther */ .chroma .sx { color: #e6db74 } 118 | /* LiteralStringRegex */ .chroma .sr { color: #e6db74 } 119 | /* LiteralStringSingle */ .chroma .s1 { color: #e6db74 } 120 | /* LiteralStringSymbol */ .chroma .ss { color: #e6db74 } 121 | /* LiteralNumber */ .chroma .m { color: #ae81ff } 122 | /* LiteralNumberBin */ .chroma .mb { color: #ae81ff } 123 | /* LiteralNumberFloat */ .chroma .mf { color: #ae81ff } 124 | /* LiteralNumberHex */ .chroma .mh { color: #ae81ff } 125 | /* LiteralNumberInteger */ .chroma .mi { color: #ae81ff } 126 | /* LiteralNumberIntegerLong */ .chroma .il { color: #ae81ff } 127 | /* LiteralNumberOct */ .chroma .mo { color: #ae81ff } 128 | /* Operator */ .chroma .o { color: #f92672 } 129 | /* OperatorWord */ .chroma .ow { color: #f92672 } 130 | /* Comment */ .chroma .c { color: #75715e } 131 | /* CommentHashbang */ .chroma .ch { color: #75715e } 132 | /* CommentMultiline */ .chroma .cm { color: #75715e } 133 | /* CommentSingle */ .chroma .c1 { color: #75715e } 134 | /* CommentSpecial */ .chroma .cs { color: #75715e } 135 | /* CommentPreproc */ .chroma .cp { color: #75715e } 136 | /* CommentPreprocFile */ .chroma .cpf { color: #75715e } 137 | /* GenericDeleted */ .chroma .gd { color: #f92672 } 138 | /* GenericEmph */ .chroma .ge { font-style: italic } 139 | /* GenericInserted */ .chroma .gi { color: #a6e22e } 140 | /* GenericStrong */ .chroma .gs { font-weight: bold } 141 | /* GenericSubheading */ .chroma .gu { color: #75715e }`) 142 | 143 | gotten := strings.TrimSpace(css.String()) 144 | 145 | if expected != gotten { 146 | diff := testutil.DiffPretty([]byte(expected), []byte(gotten)) 147 | t.Errorf("incorrect CSS.\n%s", string(diff)) 148 | } 149 | } 150 | 151 | func TestHighlighting2(t *testing.T) { 152 | markdown := goldmark.New( 153 | goldmark.WithExtensions( 154 | Highlighting, 155 | ), 156 | ) 157 | var buffer bytes.Buffer 158 | if err := markdown.Convert([]byte(` 159 | Title 160 | ======= 161 | `+"```"+` 162 | func main() { 163 | fmt.Println("ok") 164 | } 165 | `+"```"+` 166 | `), &buffer); err != nil { 167 | t.Fatal(err) 168 | } 169 | 170 | if strings.TrimSpace(buffer.String()) != strings.TrimSpace(` 171 |

Title

172 |
func main() {
173 |     fmt.Println("ok")
174 | }
175 | 
176 | `) { 177 | t.Error("failed to render HTML") 178 | } 179 | } 180 | 181 | func TestHighlighting3(t *testing.T) { 182 | markdown := goldmark.New( 183 | goldmark.WithExtensions( 184 | Highlighting, 185 | ), 186 | ) 187 | var buffer bytes.Buffer 188 | if err := markdown.Convert([]byte(` 189 | Title 190 | ======= 191 | 192 | `+"```"+`cpp {hl_lines=[1,2]} 193 | #include 194 | int main() { 195 | std::cout<< "hello" << std::endl; 196 | } 197 | `+"```"+` 198 | `), &buffer); err != nil { 199 | t.Fatal(err) 200 | } 201 | if strings.TrimSpace(buffer.String()) != strings.TrimSpace(` 202 |

Title

203 |
#include <iostream>
204 | int main() {
205 |     std::cout<< "hello" << std::endl;
206 | }
207 | 
208 | `) { 209 | t.Error("failed to render HTML") 210 | } 211 | } 212 | 213 | func TestHighlightingCustom(t *testing.T) { 214 | custom := chroma.MustNewStyle("custom", chroma.StyleEntries{ 215 | chroma.Background: "#cccccc bg:#1d1d1d", 216 | chroma.Comment: "#999999", 217 | chroma.CommentSpecial: "#cd0000", 218 | chroma.Keyword: "#cc99cd", 219 | chroma.KeywordDeclaration: "#cc99cd", 220 | chroma.KeywordNamespace: "#cc99cd", 221 | chroma.KeywordType: "#cc99cd", 222 | chroma.Operator: "#67cdcc", 223 | chroma.OperatorWord: "#cdcd00", 224 | chroma.NameClass: "#f08d49", 225 | chroma.NameBuiltin: "#f08d49", 226 | chroma.NameFunction: "#f08d49", 227 | chroma.NameException: "bold #666699", 228 | chroma.NameVariable: "#00cdcd", 229 | chroma.LiteralString: "#7ec699", 230 | chroma.LiteralNumber: "#f08d49", 231 | chroma.LiteralStringBoolean: "#f08d49", 232 | chroma.GenericHeading: "bold #000080", 233 | chroma.GenericSubheading: "bold #800080", 234 | chroma.GenericDeleted: "#e2777a", 235 | chroma.GenericInserted: "#cc99cd", 236 | chroma.GenericError: "#e2777a", 237 | chroma.GenericEmph: "italic", 238 | chroma.GenericStrong: "bold", 239 | chroma.GenericPrompt: "bold #000080", 240 | chroma.GenericOutput: "#888", 241 | chroma.GenericTraceback: "#04D", 242 | chroma.GenericUnderline: "underline", 243 | chroma.Error: "border:#e2777a", 244 | }) 245 | 246 | var css bytes.Buffer 247 | markdown := goldmark.New( 248 | goldmark.WithExtensions( 249 | NewHighlighting( 250 | WithStyle("monokai"), // to make sure it is overrided even if present 251 | WithCustomStyle(custom), 252 | WithCSSWriter(&css), 253 | WithFormatOptions( 254 | chromahtml.WithClasses(true), 255 | chromahtml.WithLineNumbers(false), 256 | ), 257 | WithWrapperRenderer(func(w util.BufWriter, c CodeBlockContext, entering bool) { 258 | _, ok := c.Language() 259 | if entering { 260 | if !ok { 261 | w.WriteString("
")
262 | 							return
263 | 						}
264 | 						w.WriteString(`
`) 265 | } else { 266 | if !ok { 267 | w.WriteString("
") 268 | return 269 | } 270 | w.WriteString(``) 271 | } 272 | }), 273 | WithCodeBlockOptions(func(c CodeBlockContext) []chromahtml.Option { 274 | if language, ok := c.Language(); ok { 275 | // Turn on line numbers for Go only. 276 | if string(language) == "go" { 277 | return []chromahtml.Option{ 278 | chromahtml.WithLineNumbers(true), 279 | } 280 | } 281 | } 282 | return nil 283 | }), 284 | ), 285 | ), 286 | ) 287 | var buffer bytes.Buffer 288 | if err := markdown.Convert([]byte(` 289 | Title 290 | ======= 291 | `+"``` go\n"+`func main() { 292 | fmt.Println("ok") 293 | } 294 | `+"```"+` 295 | `), &buffer); err != nil { 296 | t.Fatal(err) 297 | } 298 | 299 | if strings.TrimSpace(buffer.String()) != strings.TrimSpace(` 300 |

Title

301 |
1func main() {
302 | 2    fmt.Println("ok")
303 | 3}
304 | 
305 | `) { 306 | t.Error("failed to render HTML", buffer.String()) 307 | } 308 | 309 | expected := strings.TrimSpace(`/* Background */ .bg { color: #cccccc; background-color: #1d1d1d; } 310 | /* PreWrapper */ .chroma { color: #cccccc; background-color: #1d1d1d; } 311 | /* LineNumbers targeted by URL anchor */ .chroma .ln:target { color: #cccccc; background-color: #333333 } 312 | /* LineNumbersTable targeted by URL anchor */ .chroma .lnt:target { color: #cccccc; background-color: #333333 } 313 | /* Error */ .chroma .err { } 314 | /* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; } 315 | /* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; } 316 | /* LineHighlight */ .chroma .hl { background-color: #333333 } 317 | /* LineNumbersTable */ .chroma .lnt { white-space: pre; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #666666 } 318 | /* LineNumbers */ .chroma .ln { white-space: pre; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #666666 } 319 | /* Line */ .chroma .line { display: flex; } 320 | /* Keyword */ .chroma .k { color: #cc99cd } 321 | /* KeywordConstant */ .chroma .kc { color: #cc99cd } 322 | /* KeywordDeclaration */ .chroma .kd { color: #cc99cd } 323 | /* KeywordNamespace */ .chroma .kn { color: #cc99cd } 324 | /* KeywordPseudo */ .chroma .kp { color: #cc99cd } 325 | /* KeywordReserved */ .chroma .kr { color: #cc99cd } 326 | /* KeywordType */ .chroma .kt { color: #cc99cd } 327 | /* NameBuiltin */ .chroma .nb { color: #f08d49 } 328 | /* NameClass */ .chroma .nc { color: #f08d49 } 329 | /* NameException */ .chroma .ne { color: #666699; font-weight: bold } 330 | /* NameFunction */ .chroma .nf { color: #f08d49 } 331 | /* NameVariable */ .chroma .nv { color: #00cdcd } 332 | /* LiteralString */ .chroma .s { color: #7ec699 } 333 | /* LiteralStringAffix */ .chroma .sa { color: #7ec699 } 334 | /* LiteralStringBacktick */ .chroma .sb { color: #7ec699 } 335 | /* LiteralStringChar */ .chroma .sc { color: #7ec699 } 336 | /* LiteralStringDelimiter */ .chroma .dl { color: #7ec699 } 337 | /* LiteralStringDoc */ .chroma .sd { color: #7ec699 } 338 | /* LiteralStringDouble */ .chroma .s2 { color: #7ec699 } 339 | /* LiteralStringEscape */ .chroma .se { color: #7ec699 } 340 | /* LiteralStringHeredoc */ .chroma .sh { color: #7ec699 } 341 | /* LiteralStringInterpol */ .chroma .si { color: #7ec699 } 342 | /* LiteralStringOther */ .chroma .sx { color: #7ec699 } 343 | /* LiteralStringRegex */ .chroma .sr { color: #7ec699 } 344 | /* LiteralStringSingle */ .chroma .s1 { color: #7ec699 } 345 | /* LiteralStringSymbol */ .chroma .ss { color: #7ec699 } 346 | /* LiteralNumber */ .chroma .m { color: #f08d49 } 347 | /* LiteralNumberBin */ .chroma .mb { color: #f08d49 } 348 | /* LiteralNumberFloat */ .chroma .mf { color: #f08d49 } 349 | /* LiteralNumberHex */ .chroma .mh { color: #f08d49 } 350 | /* LiteralNumberInteger */ .chroma .mi { color: #f08d49 } 351 | /* LiteralNumberIntegerLong */ .chroma .il { color: #f08d49 } 352 | /* LiteralNumberOct */ .chroma .mo { color: #f08d49 } 353 | /* Operator */ .chroma .o { color: #67cdcc } 354 | /* OperatorWord */ .chroma .ow { color: #cdcd00 } 355 | /* Comment */ .chroma .c { color: #999999 } 356 | /* CommentHashbang */ .chroma .ch { color: #999999 } 357 | /* CommentMultiline */ .chroma .cm { color: #999999 } 358 | /* CommentSingle */ .chroma .c1 { color: #999999 } 359 | /* CommentSpecial */ .chroma .cs { color: #cd0000 } 360 | /* CommentPreproc */ .chroma .cp { color: #999999 } 361 | /* CommentPreprocFile */ .chroma .cpf { color: #999999 } 362 | /* GenericDeleted */ .chroma .gd { color: #e2777a } 363 | /* GenericEmph */ .chroma .ge { font-style: italic } 364 | /* GenericError */ .chroma .gr { color: #e2777a } 365 | /* GenericHeading */ .chroma .gh { color: #000080; font-weight: bold } 366 | /* GenericInserted */ .chroma .gi { color: #cc99cd } 367 | /* GenericOutput */ .chroma .go { color: #888888 } 368 | /* GenericPrompt */ .chroma .gp { color: #000080; font-weight: bold } 369 | /* GenericStrong */ .chroma .gs { font-weight: bold } 370 | /* GenericSubheading */ .chroma .gu { color: #800080; font-weight: bold } 371 | /* GenericTraceback */ .chroma .gt { color: #0044dd } 372 | /* GenericUnderline */ .chroma .gl { text-decoration: underline }`) 373 | 374 | gotten := strings.TrimSpace(css.String()) 375 | 376 | if expected != gotten { 377 | diff := testutil.DiffPretty([]byte(expected), []byte(gotten)) 378 | t.Errorf("incorrect CSS.\n%s", string(diff)) 379 | } 380 | } 381 | 382 | func TestHighlightingHlLines(t *testing.T) { 383 | markdown := goldmark.New( 384 | goldmark.WithExtensions( 385 | NewHighlighting( 386 | WithFormatOptions( 387 | chromahtml.WithClasses(true), 388 | ), 389 | ), 390 | ), 391 | ) 392 | 393 | for i, test := range []struct { 394 | attributes string 395 | expect []int 396 | }{ 397 | {`hl_lines=["2"]`, []int{2}}, 398 | {`hl_lines=["2-3",5],linenostart=5`, []int{2, 3, 5}}, 399 | {`hl_lines=["2-3"]`, []int{2, 3}}, 400 | {`hl_lines=["2-3",5],linenostart="5"`, []int{2, 3}}, // linenostart must be a number. string values are ignored 401 | } { 402 | t.Run(fmt.Sprint(i), func(t *testing.T) { 403 | var buffer bytes.Buffer 404 | codeBlock := fmt.Sprintf(`bash {%s} 405 | LINE1 406 | LINE2 407 | LINE3 408 | LINE4 409 | LINE5 410 | LINE6 411 | LINE7 412 | LINE8 413 | `, test.attributes) 414 | 415 | if err := markdown.Convert([]byte(` 416 | `+"```"+codeBlock+"```"+` 417 | `), &buffer); err != nil { 418 | t.Fatal(err) 419 | } 420 | 421 | for _, line := range test.expect { 422 | expectStr := fmt.Sprintf("LINE%d\n", line) 423 | if !strings.Contains(buffer.String(), expectStr) { 424 | t.Fatal("got\n", buffer.String(), "\nexpected\n", expectStr) 425 | } 426 | } 427 | }) 428 | } 429 | } 430 | 431 | type nopPreWrapper struct{} 432 | 433 | // Start is called to write a start
 element.
434 | func (nopPreWrapper) Start(code bool, styleAttr string) string { return "" }
435 | 
436 | // End is called to write the end 
element. 437 | func (nopPreWrapper) End(code bool) string { return "" } 438 | 439 | func TestHighlightingLinenos(t *testing.T) { 440 | outputLineNumbersInTable := `
441 | 444 |
442 | 1 443 | 445 | LINE1 446 |
447 |
` 448 | 449 | for i, test := range []struct { 450 | attributes string 451 | lineNumbers bool 452 | lineNumbersInTable bool 453 | expect string 454 | }{ 455 | {`linenos=true`, false, false, `1LINE1 456 | `}, 457 | {`linenos=false`, false, false, `LINE1 458 | `}, 459 | {``, true, false, `1LINE1 460 | `}, 461 | {``, true, true, outputLineNumbersInTable}, 462 | {`linenos=inline`, true, true, `1LINE1 463 | `}, 464 | {`linenos=foo`, false, false, `1LINE1 465 | `}, 466 | {`linenos=table`, false, false, outputLineNumbersInTable}, 467 | } { 468 | t.Run(fmt.Sprint(i), func(t *testing.T) { 469 | markdown := goldmark.New( 470 | goldmark.WithExtensions( 471 | NewHighlighting( 472 | WithFormatOptions( 473 | chromahtml.WithLineNumbers(test.lineNumbers), 474 | chromahtml.LineNumbersInTable(test.lineNumbersInTable), 475 | chromahtml.WithPreWrapper(nopPreWrapper{}), 476 | chromahtml.WithClasses(true), 477 | ), 478 | ), 479 | ), 480 | ) 481 | 482 | var buffer bytes.Buffer 483 | codeBlock := fmt.Sprintf(`bash {%s} 484 | LINE1 485 | `, test.attributes) 486 | 487 | content := "```" + codeBlock + "```" 488 | 489 | if err := markdown.Convert([]byte(content), &buffer); err != nil { 490 | t.Fatal(err) 491 | } 492 | 493 | s := strings.TrimSpace(buffer.String()) 494 | 495 | if s != test.expect { 496 | t.Fatal("got\n", s, "\nexpected\n", test.expect) 497 | } 498 | }) 499 | } 500 | } 501 | 502 | func TestHighlightingGuessLanguage(t *testing.T) { 503 | markdown := goldmark.New( 504 | goldmark.WithExtensions( 505 | NewHighlighting( 506 | WithGuessLanguage(true), 507 | WithFormatOptions( 508 | chromahtml.WithClasses(true), 509 | chromahtml.WithLineNumbers(true), 510 | ), 511 | ), 512 | ), 513 | ) 514 | var buffer bytes.Buffer 515 | if err := markdown.Convert([]byte("```"+` 516 | LINE 517 | `+"```"), &buffer); err != nil { 518 | t.Fatal(err) 519 | } 520 | if strings.TrimSpace(buffer.String()) != strings.TrimSpace(` 521 |
1LINE	
522 | 
523 | `) { 524 | t.Errorf("render mismatch, got\n%s", buffer.String()) 525 | } 526 | } 527 | 528 | func TestCoalesceNeeded(t *testing.T) { 529 | markdown := goldmark.New( 530 | goldmark.WithExtensions( 531 | NewHighlighting( 532 | // WithGuessLanguage(true), 533 | WithFormatOptions( 534 | chromahtml.WithClasses(true), 535 | chromahtml.WithLineNumbers(true), 536 | ), 537 | ), 538 | ), 539 | ) 540 | var buffer bytes.Buffer 541 | if err := markdown.Convert([]byte("```http"+` 542 | GET /foo HTTP/1.1 543 | Content-Type: application/json 544 | User-Agent: foo 545 | 546 | { 547 | "hello": "world" 548 | } 549 | `+"```"), &buffer); err != nil { 550 | t.Fatal(err) 551 | } 552 | if strings.TrimSpace(buffer.String()) != strings.TrimSpace(` 553 |
1GET /foo HTTP/1.1
554 | 2Content-Type: application/json
555 | 3User-Agent: foo
556 | 4
557 | 5{
558 | 6  "hello": "world"
559 | 7}
560 | 
561 | `) { 562 | t.Errorf("render mismatch, got\n%s", buffer.String()) 563 | } 564 | } 565 | --------------------------------------------------------------------------------