Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

markup/goldmark: Add attributes support for blocks (tables etc.) #8215

Merged
merged 1 commit into from
Feb 8, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/content/en/getting-started/configuration-markup.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,34 @@ unsafe
typographer
: This extension substitutes punctuations with typographic entities like [smartypants](https://daringfireball.net/projects/smartypants/).

attribute
: Enable custom attribute support for titles and blocks by adding attribute lists inside single curly brackets (`{.myclass class="class1 class2" }`) and placing it _after the Markdown element it decorates_, on the same line for titles and on a new line directly below for blocks.

{{< new-in "0.81" >}} In Hugo 0.81.0 we added support for adding attributes (e.g. CSS classes) to Markdown blocks, e.g. tables, lists, paragraphs etc.

A blockquote with a CSS class:

```md
> foo
> bar
{.myclass}
```

There are some current limitations: For tables you can currently only apply it to the full table, and for lists the `ul`/`ol`-nodes only, e.g.:

```md
* Fruit
* Apple
* Orange
* Banana
{.fruits}
* Dairy
* Milk
* Cheese
{.dairies}
{.list}
```

autoHeadingIDType ("github") {{< new-in "0.62.2" >}}
: The strategy used for creating auto IDs (anchor names). Available types are `github`, `github-ascii` and `blackfriday`. `github` produces GitHub-compatible IDs, `github-ascii` will drop any non-Ascii characters after accent normalization, and `blackfriday` will make the IDs work as with [Blackfriday](#blackfriday), the default Markdown engine before Hugo 0.60. Note that if Goldmark is your default Markdown engine, this is also the strategy used in the [anchorize](/functions/anchorize/) template func.

Expand Down
15 changes: 12 additions & 3 deletions docs/data/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1509,7 +1509,10 @@
"parser": {
"autoHeadingID": true,
"autoHeadingIDType": "github",
"attribute": true
"attribute": {
"title": true,
"block": false
}
},
"extensions": {
"typographer": true,
Expand Down Expand Up @@ -3023,7 +3026,7 @@
"Examples": []
},
"Merge": {
"Description": "Merge creates a copy of the final parameter and merges the preceeding\nparameters into it in reverse order.\nCurrently only maps are supported. Key handling is case insensitive.",
"Description": "Merge creates a copy of the final parameter and merges the preceding\nparameters into it in reverse order.\nCurrently only maps are supported. Key handling is case insensitive.",
"Args": [
"params"
],
Expand Down Expand Up @@ -3526,6 +3529,12 @@
"Aliases": null,
"Examples": null
},
"Overlay": {
"Description": "",
"Args": null,
"Aliases": null,
"Examples": null
},
"Pixelate": {
"Description": "",
"Args": null,
Expand Down Expand Up @@ -4371,7 +4380,7 @@
]
},
"CountRunes": {
"Description": "CountRunes returns the number of runes in s, excluding whitepace.",
"Description": "CountRunes returns the number of runes in s, excluding whitespace.",
"Args": [
"s"
],
Expand Down
8 changes: 7 additions & 1 deletion markup/goldmark/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import (
"path/filepath"
"runtime/debug"

"github.com/gohugoio/hugo/markup/goldmark/internal/extensions/attributes"

"github.com/gohugoio/hugo/identity"

"github.com/pkg/errors"
Expand Down Expand Up @@ -137,10 +139,14 @@ func newMarkdown(pcfg converter.ProviderConfig) goldmark.Markdown {
parserOptions = append(parserOptions, parser.WithAutoHeadingID())
}

if cfg.Parser.Attribute {
if cfg.Parser.Attribute.Title {
parserOptions = append(parserOptions, parser.WithAttribute())
}

if cfg.Parser.Attribute.Block {
extensions = append(extensions, attributes.New())
}

md := goldmark.New(
goldmark.WithExtensions(
extensions...,
Expand Down
99 changes: 99 additions & 0 deletions markup/goldmark/convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import (
"strings"
"testing"

"github.com/spf13/cast"

"github.com/gohugoio/hugo/markup/goldmark/goldmark_config"

"github.com/gohugoio/hugo/markup/highlight"
Expand Down Expand Up @@ -193,6 +195,103 @@ func TestConvertAutoIDBlackfriday(t *testing.T) {
c.Assert(got, qt.Contains, "<h2 id=\"let-s-try-this-shall-we\">")
}

func TestConvertAttributes(t *testing.T) {
c := qt.New(t)

withBlockAttributes := func(conf *markup_config.Config) {
conf.Goldmark.Parser.Attribute.Block = true
conf.Goldmark.Parser.Attribute.Title = false
}

withTitleAndBlockAttributes := func(conf *markup_config.Config) {
conf.Goldmark.Parser.Attribute.Block = true
conf.Goldmark.Parser.Attribute.Title = true
}

for _, test := range []struct {
name string
withConfig func(conf *markup_config.Config)
input string
expect interface{}
}{
{
"Title",
nil,
"## heading {#id .className attrName=attrValue class=\"class1 class2\"}",
"<h2 id=\"id\" class=\"className class1 class2\" attrName=\"attrValue\">heading</h2>\n",
},
{
"Blockquote",
withBlockAttributes,
"> foo\n> bar\n{#id .className attrName=attrValue class=\"class1 class2\"}\n",
"<blockquote id=\"id\" class=\"className class1 class2\"><p>foo\nbar</p>\n</blockquote>\n",
},
{
"Paragraph",
withBlockAttributes,
"\nHi there.\n{.myclass }",
"<p class=\"myclass\">Hi there.</p>\n",
},
{
"Ordered list",
withBlockAttributes,
"\n1. First\n2. Second\n{.myclass }",
"<ol class=\"myclass\">\n<li>First</li>\n<li>Second</li>\n</ol>\n",
},
{
"Unordered list",
withBlockAttributes,
"\n* First\n* Second\n{.myclass }",
"<ul class=\"myclass\">\n<li>First</li>\n<li>Second</li>\n</ul>\n",
},
{
"Unordered list, indented",
withBlockAttributes,
`* Fruit
* Apple
* Orange
* Banana
{.fruits}
* Dairy
* Milk
* Cheese
{.dairies}
{.list}`,
[]string{"<ul class=\"list\">\n<li>Fruit\n<ul class=\"fruits\">", "<li>Dairy\n<ul class=\"dairies\">"},
},
{
"Table",
withBlockAttributes,
`| A | B |
| ------------- |:-------------:| -----:|
| AV | BV |
{.myclass }`,
"<table class=\"myclass\">\n<thead>",
},
{
"Title and Blockquote",
withTitleAndBlockAttributes,
"## heading {#id .className attrName=attrValue class=\"class1 class2\"}\n> foo\n> bar\n{.myclass}",
"<h2 id=\"id\" class=\"className class1 class2\" attrName=\"attrValue\">heading</h2>\n<blockquote class=\"myclass\"><p>foo\nbar</p>\n</blockquote>\n",
},
} {
c.Run(test.name, func(c *qt.C) {
mconf := markup_config.Default
if test.withConfig != nil {
test.withConfig(&mconf)
}
b := convert(c, mconf, test.input)
got := string(b.Bytes())

for _, s := range cast.ToStringSlice(test.expect) {
c.Assert(got, qt.Contains, s)
}

})
}

}

func TestConvertIssues(t *testing.T) {
c := qt.New(t)

Expand Down
14 changes: 12 additions & 2 deletions markup/goldmark/goldmark_config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ var Default = Config{
Parser: Parser{
AutoHeadingID: true,
AutoHeadingIDType: AutoHeadingIDTypeGitHub,
Attribute: true,
Attribute: ParserAttribute{
Title: true,
Block: false,
},
},
}

Expand Down Expand Up @@ -82,5 +85,12 @@ type Parser struct {
AutoHeadingIDType string

// Enables custom attributes.
Attribute bool
Attribute ParserAttribute
}

type ParserAttribute struct {
// Enables custom attributes for titles.
Title bool
// Enables custom attributeds for blocks.
Block bool
}
119 changes: 119 additions & 0 deletions markup/goldmark/internal/extensions/attributes/attributes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package attributes

import (
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)

// This extenion is based on/inspired by https://github.com/mdigger/goldmark-attributes
// MIT License
// Copyright (c) 2019 Dmitry Sedykh

var (
kindAttributesBlock = ast.NewNodeKind("AttributesBlock")

defaultParser = new(attrParser)
defaultTransformer = new(transformer)
attributes goldmark.Extender = new(attrExtension)
)

func New() goldmark.Extender {
return attributes
}

type attrExtension struct{}

func (a *attrExtension) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(
parser.WithBlockParsers(
util.Prioritized(defaultParser, 100)),
parser.WithASTTransformers(
util.Prioritized(defaultTransformer, 100),
),
)
}

type attrParser struct{}

func (a *attrParser) CanAcceptIndentedLine() bool {
return false
}

func (a *attrParser) CanInterruptParagraph() bool {
return true
}

func (a *attrParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {
}

func (a *attrParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
return parser.Close
}

func (a *attrParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
if attrs, ok := parser.ParseAttributes(reader); ok {
// add attributes
var node = &attributesBlock{
BaseBlock: ast.BaseBlock{},
}
for _, attr := range attrs {
node.SetAttribute(attr.Name, attr.Value)
}
return node, parser.NoChildren
}
return nil, parser.RequireParagraph
}

func (a *attrParser) Trigger() []byte {
return []byte{'{'}
}

type attributesBlock struct {
ast.BaseBlock
}

func (a *attributesBlock) Dump(source []byte, level int) {
attrs := a.Attributes()
list := make(map[string]string, len(attrs))
for _, attr := range attrs {
var (
name = util.BytesToReadOnlyString(attr.Name)
value = util.BytesToReadOnlyString(util.EscapeHTML(attr.Value.([]byte)))
)
list[name] = value
}
ast.DumpHelper(a, source, level, list, nil)
}

func (a *attributesBlock) Kind() ast.NodeKind {
return kindAttributesBlock
}

type transformer struct{}

func (a *transformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
var attributes = make([]ast.Node, 0, 500)
ast.Walk(node, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering && node.Kind() == kindAttributesBlock && !node.HasBlankPreviousLines() {
attributes = append(attributes, node)
return ast.WalkSkipChildren, nil
}
return ast.WalkContinue, nil
})

for _, attr := range attributes {
if prev := attr.PreviousSibling(); prev != nil &&
prev.Type() == ast.TypeBlock {
for _, attr := range attr.Attributes() {
if _, found := prev.Attribute(attr.Name); !found {
prev.SetAttribute(attr.Name, attr.Value)
}
}
}
// remove attributes node
attr.Parent().RemoveChild(attr.Parent(), attr)
}
}
13 changes: 13 additions & 0 deletions markup/markup_config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ type Config struct {
func Decode(cfg config.Provider) (conf Config, err error) {
conf = Default

normalizeConfig(cfg)

m := cfg.GetStringMap("markup")
if m == nil {
return
Expand All @@ -65,6 +67,17 @@ func Decode(cfg config.Provider) (conf Config, err error) {
return
}

func normalizeConfig(cfg config.Provider) {
// Changed from a bool in 0.81.0
const attrKey = "markup.goldmark.parser.attribute"
av := cfg.Get(attrKey)
if avb, ok := av.(bool); ok {
cfg.Set(attrKey, goldmark_config.ParserAttribute{
Title: avb,
})
}
}

func applyLegacyConfig(cfg config.Provider, conf *Config) error {
if bm := cfg.GetStringMap("blackfriday"); bm != nil {
// Legacy top level blackfriday config.
Expand Down
Loading