├── .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 |
442 | 1
443 |
444 |
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 |
--------------------------------------------------------------------------------