From 785421acb42d7fe1fdca4d28b7a60b099acd71ac Mon Sep 17 00:00:00 2001 From: yuin Date: Sun, 5 May 2019 13:42:39 +0900 Subject: [PATCH] Add WithAttribute --- README.md | 18 +++++++ ast/ast.go | 8 +++ parser/atx_heading.go | 108 ++++++++++++++++++++++++++++++-------- parser/html_block.go | 2 +- parser/parser.go | 31 ++++++++--- parser/setext_headings.go | 23 ++++++-- renderer/html/html.go | 18 ++++--- util/util.go | 50 +++++++++++++++++- 8 files changed, 216 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index c07efe2..6f05908 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ Parser and Renderer options | `parser.WithInlineParsers` | A `util.PrioritizedSlice` whose elements are `parser.InlineParser` | Parsers for parsing inline level elements. | | `parser.WithParagraphTransformers` | A `util.PrioritizedSlice` whose elements are `parser.ParagraphTransformer` | Transformers for transforming paragraph nodes. | | `parser.WithAutoHeadingID` | `-` | Enables auto heading ids. | +| `parser.WithAttribute` | `-` | Enables custom attributes. Currently only headings supports attributes. | | `parser.WithFilterTags` | `...string` | HTML tag names forbidden in HTML blocks and Raw HTMLs. | ### HTML Renderer options @@ -120,6 +121,23 @@ Parser and Renderer options - `extension.Footnote` - [PHP Markdown Extra: Footnotes](https://michelf.ca/projects/php-markdown/extra/#footnotes) +### Attributes +`parser.WithAttribute` option allows you to define attributes on some elements. + +Currently only headings supports attributes. + +#### Headings + +``` +## heading ## {#id .className attrName=attrValue class="class1 class2"} +``` + +``` +heading {#id .className attrName=attrValue} +============ +``` + + Create extensions -------------------- **TODO** diff --git a/ast/ast.go b/ast/ast.go index 5e90cbb..81b0da1 100644 --- a/ast/ast.go +++ b/ast/ast.go @@ -157,6 +157,9 @@ type Node interface { // Attributes returns a list of attributes. // This may be a nil if there are no attributes. Attributes() []Attribute + + // RemoveAttributes removes all attributes from this node. + RemoveAttributes() } // A BaseNode struct implements the Node interface. @@ -371,6 +374,11 @@ func (n *BaseNode) Attributes() []Attribute { return n.attributes } +// RemoveAttributes implements Node.RemoveAttributes +func (n *BaseNode) RemoveAttributes() { + n.attributes = nil +} + // DumpHelper is a helper function to implement Node.Dump. // kv is pairs of an attribute name and an attribute value. // cb is a function called after wrote a name and attributes. diff --git a/parser/atx_heading.go b/parser/atx_heading.go index 4e1ca7d..abfa788 100644 --- a/parser/atx_heading.go +++ b/parser/atx_heading.go @@ -4,12 +4,12 @@ import ( "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" - "regexp" ) // A HeadingConfig struct is a data structure that holds configuration of the renderers related to headings. type HeadingConfig struct { AutoHeadingID bool + Attribute bool } // SetOption implements SetOptioner. @@ -17,11 +17,14 @@ func (b *HeadingConfig) SetOption(name OptionName, value interface{}) { switch name { case AutoHeadingID: b.AutoHeadingID = true + case Attribute: + b.Attribute = true } } // A HeadingOption interface sets options for heading parsers. type HeadingOption interface { + Option SetHeadingOption(*HeadingConfig) } @@ -31,7 +34,7 @@ var AutoHeadingID OptionName = "AutoHeadingID" type withAutoHeadingID struct { } -func (o *withAutoHeadingID) SetConfig(c *Config) { +func (o *withAutoHeadingID) SetParserOption(c *Config) { c.Options[AutoHeadingID] = true } @@ -41,14 +44,22 @@ func (o *withAutoHeadingID) SetHeadingOption(p *HeadingConfig) { // WithAutoHeadingID is a functional option that enables custom heading ids and // auto generated heading ids. -func WithAutoHeadingID() interface { - Option - HeadingOption -} { +func WithAutoHeadingID() HeadingOption { return &withAutoHeadingID{} } -var atxHeadingRegexp = regexp.MustCompile(`^[ ]{0,3}(#{1,6})(?:\s+(.*?)\s*([\s]#+\s*)?)?\n?$`) +type withHeadingAttribute struct { + Option +} + +func (o *withHeadingAttribute) SetHeadingOption(p *HeadingConfig) { + p.Attribute = true +} + +// WithHeadingAttribute is a functional option that enables custom heading attributes. +func WithHeadingAttribute() HeadingOption { + return &withHeadingAttribute{WithAttribute()} +} type atxHeadingParser struct { HeadingConfig @@ -79,22 +90,70 @@ func (b *atxHeadingParser) Open(parent ast.Node, reader text.Reader, pc Context) } start := i + l stop := len(line) - util.TrimRightSpaceLength(line) - if stop <= start { // empty headings like '##[space]' - stop = start + 1 - } else { - i = stop - 1 - for ; line[i] == '#' && i >= start; i-- { - } - if i != stop-1 && !util.IsSpace(line[i]) { - i = stop - 1 - } - i++ - stop = i - } node := ast.NewHeading(level) - if len(util.TrimRight(line[start:stop], []byte{'#'})) != 0 { // empty heading like '### ###' - node.Lines().Append(text.NewSegment(segment.Start+start, segment.Start+stop)) + parsed := false + if b.Attribute { // handles special case like ### heading ### {#id} + start-- + closureOpen := -1 + closureClose := -1 + for i := start; i < stop; { + c := line[i] + if util.IsEscapedPunctuation(line, i) { + i += 2 + } else if util.IsSpace(c) && i < stop-1 && line[i+1] == '#' { + closureOpen = i + 1 + j := i + 1 + for ; j < stop && line[j] == '#'; j++ { + } + closureClose = j + break + } else { + i++ + } + } + if closureClose > 0 { + i := closureClose + for ; i < stop && util.IsSpace(line[i]); i++ { + } + if i < stop-1 || line[i] == '{' { + as := i + 1 + for as < stop { + ai := util.FindAttributeIndex(line[as:], true) + if ai[0] < 0 { + break + } + node.SetAttribute(line[as+ai[0]:as+ai[1]], + line[as+ai[2]:as+ai[3]]) + as += ai[3] + } + if line[as] == '}' && (as > stop-2 || util.IsBlank(line[as:])) { + parsed = true + node.Lines().Append(text.NewSegment(segment.Start+start+1, segment.Start+closureOpen)) + } else { + node.RemoveAttributes() + } + } + } + } + if !parsed { + stop := len(line) - util.TrimRightSpaceLength(line) + if stop <= start { // empty headings like '##[space]' + stop = start + 1 + } else { + i = stop - 1 + for ; line[i] == '#' && i >= start; i-- { + } + if i != stop-1 && !util.IsSpace(line[i]) { + i = stop - 1 + } + i++ + stop = i + } + + if len(util.TrimRight(line[start:stop], []byte{'#'})) != 0 { // empty heading like '### ###' + node.Lines().Append(text.NewSegment(segment.Start+start, segment.Start+stop)) + } } return node, NoChildren } @@ -107,7 +166,12 @@ func (b *atxHeadingParser) Close(node ast.Node, reader text.Reader, pc Context) if !b.AutoHeadingID { return } - generateAutoHeadingID(node.(*ast.Heading), reader, pc) + if !b.Attribute { + _, ok := node.AttributeString("id") + if !ok { + generateAutoHeadingID(node.(*ast.Heading), reader, pc) + } + } } func (b *atxHeadingParser) CanInterruptParagraph() bool { diff --git a/parser/html_block.go b/parser/html_block.go index 1e19c9e..00085d3 100644 --- a/parser/html_block.go +++ b/parser/html_block.go @@ -34,7 +34,7 @@ type withFilterTags struct { value map[string]bool } -func (o *withFilterTags) SetConfig(c *Config) { +func (o *withFilterTags) SetParserOption(c *Config) { c.Options[FilterTags] = o.value } diff --git a/parser/parser.go b/parser/parser.go index bab8c89..ad1b3c5 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -375,12 +375,27 @@ func NewConfig() *Config { // An Option interface is a functional option type for the Parser. type Option interface { - SetConfig(*Config) + SetParserOption(*Config) } // OptionName is a name of parser options. type OptionName string +// Attribute is an option name that spacify attributes of elements. +const Attribute OptionName = "Attribute" + +type withAttribute struct { +} + +func (o *withAttribute) SetParserOption(c *Config) { + c.Options[Attribute] = true +} + +// WithAttribute is a functional option that enables custom attributes. +func WithAttribute() Option { + return &withAttribute{} +} + // A Parser interface parses Markdown text into AST nodes. type Parser interface { // Parse parses the given Markdown text into AST nodes. @@ -552,7 +567,7 @@ type withBlockParsers struct { value []util.PrioritizedValue } -func (o *withBlockParsers) SetConfig(c *Config) { +func (o *withBlockParsers) SetParserOption(c *Config) { c.BlockParsers = append(c.BlockParsers, o.value...) } @@ -566,7 +581,7 @@ type withInlineParsers struct { value []util.PrioritizedValue } -func (o *withInlineParsers) SetConfig(c *Config) { +func (o *withInlineParsers) SetParserOption(c *Config) { c.InlineParsers = append(c.InlineParsers, o.value...) } @@ -580,7 +595,7 @@ type withParagraphTransformers struct { value []util.PrioritizedValue } -func (o *withParagraphTransformers) SetConfig(c *Config) { +func (o *withParagraphTransformers) SetParserOption(c *Config) { c.ParagraphTransformers = append(c.ParagraphTransformers, o.value...) } @@ -594,7 +609,7 @@ type withASTTransformers struct { value []util.PrioritizedValue } -func (o *withASTTransformers) SetConfig(c *Config) { +func (o *withASTTransformers) SetParserOption(c *Config) { c.ASTTransformers = append(c.ASTTransformers, o.value...) } @@ -609,7 +624,7 @@ type withOption struct { value interface{} } -func (o *withOption) SetConfig(c *Config) { +func (o *withOption) SetParserOption(c *Config) { c.Options[o.name] = o.value } @@ -623,7 +638,7 @@ func WithOption(name OptionName, value interface{}) Option { func NewParser(options ...Option) Parser { config := NewConfig() for _, opt := range options { - opt.SetConfig(config) + opt.SetParserOption(config) } p := &parser{ @@ -636,7 +651,7 @@ func NewParser(options ...Option) Parser { func (p *parser) AddOptions(opts ...Option) { for _, opt := range opts { - opt.SetConfig(p.config) + opt.SetParserOption(p.config) } } diff --git a/parser/setext_headings.go b/parser/setext_headings.go index 220403f..4cc5b87 100644 --- a/parser/setext_headings.go +++ b/parser/setext_headings.go @@ -94,10 +94,27 @@ func (b *setextHeadingParser) Close(node ast.Node, reader text.Reader, pc Contex tmp.Parent().RemoveChild(tmp.Parent(), tmp) } - if !b.AutoHeadingID { - return + if b.Attribute { + lastIndex := node.Lines().Len() - 1 + lastLine := node.Lines().At(lastIndex) + line := lastLine.Value(reader.Source()) + indicies := util.FindAttributeIndiciesReverse(line, true) + if indicies != nil { + for _, index := range indicies { + node.SetAttribute(line[index[0]:index[1]], line[index[2]:index[3]]) + } + lastLine.Stop = lastLine.Start + indicies[0][0] - 1 + lastLine.TrimRightSpace(reader.Source()) + node.Lines().Set(lastIndex, lastLine) + } + } + + if b.AutoHeadingID { + _, ok := node.AttributeString("id") + if !ok { + generateAutoHeadingID(heading, reader, pc) + } } - generateAutoHeadingID(heading, reader, pc) } func (b *setextHeadingParser) CanInterruptParagraph() bool { diff --git a/renderer/html/html.go b/renderer/html/html.go index b127eaa..e0e7bd0 100644 --- a/renderer/html/html.go +++ b/renderer/html/html.go @@ -206,12 +206,7 @@ func (r *Renderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, w.WriteString("') } else { @@ -491,6 +486,17 @@ func (r *Renderer) renderText(w util.BufWriter, source []byte, node ast.Node, en return ast.WalkContinue, nil } +// RenderAttributes renders given node's attributes. +func (r *Renderer) RenderAttributes(w util.BufWriter, node ast.Node) { + for _, attr := range node.Attributes() { + w.WriteString(" ") + w.Write(attr.Name) + w.WriteString(`="`) + w.Write(attr.Value) + w.WriteByte('"') + } +} + // A Writer interface wirtes textual contents to a writer. type Writer interface { // Write writes the given source to writer with resolving references and unescaping diff --git a/util/util.go b/util/util.go index 4b084aa..43a1226 100644 --- a/util/util.go +++ b/util/util.go @@ -54,6 +54,12 @@ func (b *CopyOnWriteBuffer) IsCopied() bool { return b.copied } +// IsEscapedPunctuation returns true if caracter at a given index i +// is an escaped punctuation, otherwise false. +func IsEscapedPunctuation(source []byte, i int) bool { + return source[i] == '\\' && i < len(source)-1 && IsPunct(source[i+1]) +} + // ReadWhile read the given source while pred is true. func ReadWhile(source []byte, index [2]int, pred func(byte) bool) (int, bool) { j := index[0] @@ -549,6 +555,46 @@ func URLEscape(v []byte, resolveReference bool) []byte { return cob.Bytes() } +// FindAttributeIndiciesReverse searches attribute indicies from tail of the given +// bytes and returns indicies. +func FindAttributeIndiciesReverse(b []byte, canEscapeQuotes bool) [][4]int { + i := 0 +retry: + var result [][4]int + as := -1 + for i < len(b) { + if IsEscapedPunctuation(b, i) { + i += 2 + continue + } + if b[i] == '{' { + i++ + as = i + break + } + i++ + } + if as < 0 { + return nil + } + for as < len(b) { + ai := FindAttributeIndex(b[as:], canEscapeQuotes) + if ai[0] < 0 { + break + } + i = as + ai[3] + if result == nil { + result = [][4]int{} + } + result = append(result, [4]int{as + ai[0], as + ai[1], as + ai[2], as + ai[3]}) + as += ai[3] + } + if b[as] == '}' && (as > len(b)-2 || IsBlank(b[as:])) { + return result + } + goto retry +} + // FindAttributeIndex searchs // - #id // - .class @@ -613,10 +659,10 @@ func FindHTMLAttributeIndex(b []byte, canEscapeQuotes bool) [4]int { for ; i < l && IsSpace(b[i]); i++ { } if i >= l { - return result // empty attribute + return [4]int{-1, -1, -1, -1} } if b[i] != '=' { - return result // empty attribute + return [4]int{-1, -1, -1, -1} } i++ for ; i < l && IsSpace(b[i]); i++ {