diff --git a/attributes.go b/attributes.go new file mode 100644 index 00000000..ffdfeff7 --- /dev/null +++ b/attributes.go @@ -0,0 +1,92 @@ +package blackfriday + +import "strings" + +// attr - Abstraction for html attribute +type attr []string + +// Add - adds one more attribute value +func (a attr) add(value string) attr { + for _, item := range a { + if item == value { + return a + } + } + return append(a, value) +} + +// Remove - removes given value from attribute +func (a attr) remove(value string) attr { + for i := range a { + if a[i] == value { + return append(a[:i], a[i+1:]...) + } + } + return a +} + +func (a attr) String() string { + return strings.Join(a, " ") +} + +// Attributes - store for many attributes +type Attributes struct { + attrsMap map[string]attr + keys []string +} + +// NewAttributes - creates new Attributes instance +func NewAttributes() *Attributes { + return &Attributes{ + attrsMap: make(map[string]attr), + } +} + +// Add - adds attribute if not exists and sets value for it +func (a *Attributes) Add(name, value string) *Attributes { + if _, ok := a.attrsMap[name]; !ok { + a.attrsMap[name] = make(attr, 0) + a.keys = append(a.keys, name) + } + + a.attrsMap[name] = a.attrsMap[name].add(value) + return a +} + +// Remove - removes attribute by name +func (a *Attributes) Remove(name string) *Attributes { + for i := range a.keys { + if a.keys[i] == name { + a.keys = append(a.keys[:i], a.keys[i+1:]...) + } + } + + delete(a.attrsMap, name) + return a +} + +// RemoveValue - removes given value from attribute by name +// If given attribues become empty it alose removes entire attribute +func (a *Attributes) RemoveValue(name, value string) *Attributes { + if attr, ok := a.attrsMap[name]; ok { + a.attrsMap[name] = attr.remove(value) + if len(a.attrsMap[name]) == 0 { + a.Remove(name) + } + } + return a +} + +// Empty - checks if attributes is empty +func (a *Attributes) Empty() bool { + return len(a.keys) == 0 +} + +func (a *Attributes) String() string { + r := []string{} + for _, attrName := range a.keys { + r = append(r, attrName+"=\""+a.attrsMap[attrName].String()+"\"") + } + + return strings.Join(r, " ") +} diff --git a/attributes_test.go b/attributes_test.go new file mode 100644 index 00000000..f6cf7fe4 --- /dev/null +++ b/attributes_test.go @@ -0,0 +1,132 @@ +package blackfriday + +import ( + "bytes" + "testing" +) + +func TestEmtyAttributes(t *testing.T) { + a := NewAttributes() + r := a.String() + e := "" + if r != e { + t.Errorf("Expected: %s\nActual: %s\n", e, r) + } +} + +func TestAddOneAttribute(t *testing.T) { + a := NewAttributes() + a.Add("class", "wrapper") + r := a.String() + e := "class=\"wrapper\"" + if r != e { + t.Errorf("Expected: %s\nActual: %s\n", e, r) + } +} + +func TestAddFewValuesToOneAttribute(t *testing.T) { + a := NewAttributes() + a.Add("class", "wrapper").Add("class", "-with-image") + r := a.String() + e := "class=\"wrapper -with-image\"" + if r != e { + t.Errorf("Expected: %s\nActual: %s\n", e, r) + } +} + +func TestAddSameValueToAttribute(t *testing.T) { + a := NewAttributes() + a.Add("class", "wrapper").Add("class", "wrapper") + r := a.String() + e := "class=\"wrapper\"" + if r != e { + t.Errorf("Expected: %s\nActual: %s\n", e, r) + } +} + +func TestRemoveValueFromOneAttribute(t *testing.T) { + a := NewAttributes() + a.Add("class", "wrapper").Add("class", "-with-image") + a.RemoveValue("class", "wrapper") + r := a.String() + e := "class=\"-with-image\"" + if r != e { + t.Errorf("Expected: %s\nActual: %s\n", e, r) + } +} + +func TestRemoveWholeAttribute(t *testing.T) { + a := NewAttributes() + a.Add("class", "wrapper") + a.Remove("class") + r := a.String() + e := "" + if r != e { + t.Errorf("Expected: %s\nActual: %s\n", e, r) + } +} + +func TestRemoveWholeAttributeByValue(t *testing.T) { + a := NewAttributes() + a.Add("class", "wrapper") + a.RemoveValue("class", "wrapper") + r := a.String() + e := "" + if r != e { + t.Errorf("Expected: %s\nActual: %s\n", e, r) + } +} + +func TestAddFewAttributes(t *testing.T) { + a := NewAttributes() + a.Add("class", "wrapper").Add("id", "main-block") + r := a.String() + e := "class=\"wrapper\" id=\"main-block\"" + if r != e { + t.Errorf("Expected: %s\nActual: %s\n", e, r) + } +} + +func TestAddComplexAttributes(t *testing.T) { + a := NewAttributes() + a. + Add("style", "background: #fff;"). + Add("style", "font-size: 14px;"). + Add("data-test-id", "block") + r := a.String() + e := "style=\"background: #fff; font-size: 14px;\" data-test-id=\"block\"" + if r != e { + t.Errorf("Expected: %s\nActual: %s\n", e, r) + } +} + +func TestASTModification(t *testing.T) { + input := "\nPicture signature\n![alt text](/p.jpg)\n" + expected := "

Picture signature\n\"alt

\n" + + r := NewHTMLRenderer(HTMLRendererParameters{ + Flags: CommonHTMLFlags, + }) + var buf bytes.Buffer + optList := []Option{ + WithRenderer(r), + WithExtensions(CommonExtensions)} + parser := New(optList...) + ast := parser.Parse([]byte(input)) + r.RenderHeader(&buf, ast) + ast.Walk(func(node *Node, entering bool) WalkStatus { + if node.Type == Image && entering && node.Parent.Type == Paragraph { + node.Parent.Attributes.Add("class", "img") + } + return GoToNext + }) + ast.Walk(func(node *Node, entering bool) WalkStatus { + return r.RenderNode(&buf, node, entering) + }) + r.RenderFooter(&buf, ast) + actual := buf.String() + + if actual != expected { + t.Errorf("Expected: %s\nActual: %s\n", expected, actual) + } +} diff --git a/html.go b/html.go index 284c8718..93d1a47c 100644 --- a/html.go +++ b/html.go @@ -276,28 +276,22 @@ func (r *HTMLRenderer) addAbsPrefix(link []byte) []byte { return link } -func appendLinkAttrs(attrs []string, flags HTMLFlags, link []byte) []string { +func appendLinkAttrs(attrs *Attributes, flags HTMLFlags, link []byte) { if isRelativeLink(link) { - return attrs + return + } + if flags&HrefTargetBlank != 0 { + attrs.Add("target", "_blank") } - val := []string{} if flags&NofollowLinks != 0 { - val = append(val, "nofollow") + attrs.Add("rel", "nofollow") } if flags&NoreferrerLinks != 0 { - val = append(val, "noreferrer") + attrs.Add("rel", "noreferrer") } if flags&NoopenerLinks != 0 { - val = append(val, "noopener") - } - if flags&HrefTargetBlank != 0 { - attrs = append(attrs, "target=\"_blank\"") - } - if len(val) == 0 { - return attrs + attrs.Add("rel", "noopener") } - attr := fmt.Sprintf("rel=%q", strings.Join(val, " ")) - return append(attrs, attr) } func isMailto(link []byte) bool { @@ -316,23 +310,26 @@ func isSmartypantable(node *Node) bool { return pt != Link && pt != CodeBlock && pt != Code } -func appendLanguageAttr(attrs []string, info []byte) []string { +func appendLanguageAttr(attrs *Attributes, info []byte) { if len(info) == 0 { - return attrs + return } endOfLang := bytes.IndexAny(info, "\t ") if endOfLang < 0 { endOfLang = len(info) } - return append(attrs, fmt.Sprintf("class=\"language-%s\"", info[:endOfLang])) + attrs.Add("class", fmt.Sprintf("language-%s", info[:endOfLang])) } -func (r *HTMLRenderer) tag(w io.Writer, name []byte, attrs []string) { - w.Write(name) - if len(attrs) > 0 { - w.Write(spaceBytes) - w.Write([]byte(strings.Join(attrs, " "))) +func (r *HTMLRenderer) tag(w io.Writer, name []byte, attrs *Attributes) { + if attrs.Empty() { + r.out(w, name) + return } + + w.Write(name[:len(name)-1]) + w.Write(spaceBytes) + w.Write([]byte(attrs.String())) w.Write(gtBytes) r.lastOutputLen = 1 } @@ -414,7 +411,7 @@ var ( delCloseTag = []byte("") ttTag = []byte("") ttCloseTag = []byte("") - aTag = []byte("") aCloseTag = []byte("") preTag = []byte("
")
 	preCloseTag        = []byte("
") @@ -440,9 +437,9 @@ var ( dtCloseTag = []byte("") tableTag = []byte("") tableCloseTag = []byte("
") - tdTag = []byte("") tdCloseTag = []byte("") - thTag = []byte("") thCloseTag = []byte("") theadTag = []byte("") theadCloseTag = []byte("") @@ -450,17 +447,17 @@ var ( tbodyCloseTag = []byte("") trTag = []byte("") trCloseTag = []byte("") - h1Tag = []byte("") h1CloseTag = []byte("") - h2Tag = []byte("") h2CloseTag = []byte("") - h3Tag = []byte("") h3CloseTag = []byte("") - h4Tag = []byte("") h4CloseTag = []byte("") - h5Tag = []byte("") h5CloseTag = []byte("") - h6Tag = []byte("") h6CloseTag = []byte("") footnotesDivBytes = []byte("\n
\n\n") @@ -503,7 +500,7 @@ func (r *HTMLRenderer) outHRTag(w io.Writer) { // The typical behavior is to return GoToNext, which asks for the usual // traversal to the next node. func (r *HTMLRenderer) RenderNode(w io.Writer, node *Node, entering bool) WalkStatus { - attrs := []string{} + attrs := node.Attributes switch node.Type { case Text: if r.Flags&Smartypants != 0 { @@ -522,26 +519,26 @@ func (r *HTMLRenderer) RenderNode(w io.Writer, node *Node, entering bool) WalkSt // TODO: make it configurable via out(renderer.softbreak) case Hardbreak: if r.Flags&UseXHTML == 0 { - r.out(w, brTag) + r.tag(w, brTag, attrs) } else { r.out(w, brXHTMLTag) } r.cr(w) case Emph: if entering { - r.out(w, emTag) + r.tag(w, emTag, attrs) } else { r.out(w, emCloseTag) } case Strong: if entering { - r.out(w, strongTag) + r.tag(w, strongTag, attrs) } else { r.out(w, strongCloseTag) } case Del: if entering { - r.out(w, delTag) + r.tag(w, delTag, attrs) } else { r.out(w, delCloseTag) } @@ -555,7 +552,7 @@ func (r *HTMLRenderer) RenderNode(w io.Writer, node *Node, entering bool) WalkSt dest := node.LinkData.Destination if needSkipLink(r.Flags, dest) { if entering { - r.out(w, ttTag) + r.tag(w, ttTag, attrs) } else { r.out(w, ttCloseTag) } @@ -563,21 +560,17 @@ func (r *HTMLRenderer) RenderNode(w io.Writer, node *Node, entering bool) WalkSt if entering { dest = r.addAbsPrefix(dest) var hrefBuf bytes.Buffer - hrefBuf.WriteString("href=\"") escLink(&hrefBuf, dest) - hrefBuf.WriteByte('"') - attrs = append(attrs, hrefBuf.String()) + attrs.Add("href", hrefBuf.String()) if node.NoteID != 0 { r.out(w, footnoteRef(r.FootnoteAnchorPrefix, node)) break } - attrs = appendLinkAttrs(attrs, r.Flags, dest) + appendLinkAttrs(attrs, r.Flags, dest) if len(node.LinkData.Title) > 0 { var titleBuff bytes.Buffer - titleBuff.WriteString("title=\"") escapeHTML(&titleBuff, node.LinkData.Title) - titleBuff.WriteByte('"') - attrs = append(attrs, titleBuff.String()) + attrs.Add("title", titleBuff.String()) } r.tag(w, aTag, attrs) } else { @@ -611,11 +604,13 @@ func (r *HTMLRenderer) RenderNode(w io.Writer, node *Node, entering bool) WalkSt r.out(w, []byte(`" title="`)) escapeHTML(w, node.LinkData.Title) } - r.out(w, []byte(`" />`)) + r.out(w, []byte(`" `)) + r.out(w, []byte(attrs.String())) + r.out(w, []byte(`/>`)) } } case Code: - r.out(w, codeTag) + r.tag(w, codeTag, attrs) escapeHTML(w, node.Literal) r.out(w, codeCloseTag) case Document: @@ -636,7 +631,7 @@ func (r *HTMLRenderer) RenderNode(w io.Writer, node *Node, entering bool) WalkSt if node.Parent.Type == BlockQuote && node.Prev == nil { r.cr(w) } - r.out(w, pTag) + r.tag(w, pTag, attrs) } else { r.out(w, pCloseTag) if !(node.Parent.Type == Item && node.Next == nil) { @@ -646,7 +641,7 @@ func (r *HTMLRenderer) RenderNode(w io.Writer, node *Node, entering bool) WalkSt case BlockQuote: if entering { r.cr(w) - r.out(w, blockquoteTag) + r.tag(w, blockquoteTag, attrs) } else { r.out(w, blockquoteCloseTag) r.cr(w) @@ -663,7 +658,7 @@ func (r *HTMLRenderer) RenderNode(w io.Writer, node *Node, entering bool) WalkSt openTag, closeTag := headingTagsFromLevel(headingLevel) if entering { if node.IsTitleblock { - attrs = append(attrs, `class="title"`) + attrs.Add("class", "title") } if node.HeadingID != "" { id := r.ensureUniqueHeadingID(node.HeadingID) @@ -673,7 +668,7 @@ func (r *HTMLRenderer) RenderNode(w io.Writer, node *Node, entering bool) WalkSt if r.HeadingIDSuffix != "" { id = id + r.HeadingIDSuffix } - attrs = append(attrs, fmt.Sprintf(`id="%s"`, id)) + attrs.Add("id", id) } r.cr(w) r.tag(w, openTag, attrs) @@ -708,7 +703,7 @@ func (r *HTMLRenderer) RenderNode(w io.Writer, node *Node, entering bool) WalkSt if node.Parent.Type == Item && node.Parent.Parent.Tight { r.cr(w) } - r.tag(w, openTag[:len(openTag)-1], attrs) + r.tag(w, openTag, attrs) r.cr(w) } else { r.out(w, closeTag) @@ -746,7 +741,7 @@ func (r *HTMLRenderer) RenderNode(w io.Writer, node *Node, entering bool) WalkSt r.out(w, footnoteItem(r.FootnoteAnchorPrefix, slug)) break } - r.out(w, openTag) + r.tag(w, openTag, attrs) } else { if node.ListData.RefLink != nil { slug := slugify(node.ListData.RefLink) @@ -758,10 +753,10 @@ func (r *HTMLRenderer) RenderNode(w io.Writer, node *Node, entering bool) WalkSt r.cr(w) } case CodeBlock: - attrs = appendLanguageAttr(attrs, node.Info) + appendLanguageAttr(attrs, node.Info) r.cr(w) r.out(w, preTag) - r.tag(w, codeTag[:len(codeTag)-1], attrs) + r.tag(w, codeTag, attrs) escapeHTML(w, node.Literal) r.out(w, codeCloseTag) r.out(w, preCloseTag) @@ -771,7 +766,7 @@ func (r *HTMLRenderer) RenderNode(w io.Writer, node *Node, entering bool) WalkSt case Table: if entering { r.cr(w) - r.out(w, tableTag) + r.tag(w, tableTag, attrs) } else { r.out(w, tableCloseTag) r.cr(w) @@ -786,7 +781,7 @@ func (r *HTMLRenderer) RenderNode(w io.Writer, node *Node, entering bool) WalkSt if entering { align := cellAlignment(node.Align) if align != "" { - attrs = append(attrs, fmt.Sprintf(`align="%s"`, align)) + attrs.Add("align", align) } if node.Prev == nil { r.cr(w) @@ -799,7 +794,7 @@ func (r *HTMLRenderer) RenderNode(w io.Writer, node *Node, entering bool) WalkSt case TableHead: if entering { r.cr(w) - r.out(w, theadTag) + r.tag(w, theadTag, attrs) } else { r.out(w, theadCloseTag) r.cr(w) @@ -807,7 +802,7 @@ func (r *HTMLRenderer) RenderNode(w io.Writer, node *Node, entering bool) WalkSt case TableBody: if entering { r.cr(w) - r.out(w, tbodyTag) + r.tag(w, tbodyTag, attrs) // XXX: this is to adhere to a rather silly test. Should fix test. if node.FirstChild == nil { r.cr(w) @@ -819,7 +814,7 @@ func (r *HTMLRenderer) RenderNode(w io.Writer, node *Node, entering bool) WalkSt case TableRow: if entering { r.cr(w) - r.out(w, trTag) + r.tag(w, trTag, attrs) } else { r.out(w, trCloseTag) r.cr(w) diff --git a/node.go b/node.go index 51b9e8c1..af6a1b86 100644 --- a/node.go +++ b/node.go @@ -128,6 +128,8 @@ type Node struct { LinkData // Populated if Type is Link TableCellData // Populated if Type is TableCell + Attributes *Attributes // Contains HTML-attributes for current node + content []byte // Markdown content of the block nodes open bool // Specifies an open block node that has not been finished to process yet } @@ -135,8 +137,9 @@ type Node struct { // NewNode allocates a node of a specified type. func NewNode(typ NodeType) *Node { return &Node{ - Type: typ, - open: true, + Type: typ, + open: true, + Attributes: NewAttributes(), } }