iosswiftmarkdownswift-markdown

How to add custom Markup to the MarkupVisitor in Swift-Markdown?


I am using the swift-markdown library in my app.

I am trying to figure out how to add my own custom Markup to the MarkupVisitor?

I have been able to use a workaround using visitInlineAttributes which applies to ^[]().

Is there a way to add my own custom Markup?

For example, if I wanted my own inline markup where text inside @[text] would have gray color? Similar to how **bold** makes things bold.

Someone asked this question in the library but I don't understand how to implement the only answer there.

https://github.com/swiftlang/swift-markdown/issues/122

Here's a below example I have:

import UIKit
import Markdown
import SnapKit

class ViewController: UIViewController, UITextViewDelegate {
    
    let label = UITextView()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        label.isEditable = false
        label.delegate = self
        label.linkTextAttributes = [:]
        view.addSubview(label)
        label.snp.makeConstraints { make in
            make.edges.equalTo(view.safeAreaLayoutGuide).inset(10)
        }
        
        var md = MarkdownParser()
        let attr = md.attributedString(from: "Hello World! Here's some text which is **bold** and here's some which is *italics*. Here's some ^[[metadata containing link](https://old.reddit.com/r/SaaS/comments/1fgv248/fuck_founder_mode_work_in_fuck_off_mode/)]() which is gray and not underlined. And here's some normal [link](https://hckrnews.com) which is underlined and red. You are welcome!")
        print("ATTR:\n\n\(attr)")
        label.attributedText = attr
        
    }
    
    func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
        print("shouldInteractWith: \(URL)")
        return true
    }

}

struct MarkdownParser: MarkupVisitor {

    typealias Result = NSMutableAttributedString
    
    mutating func attributedString(from : String) -> NSMutableAttributedString {
        let document = Document(parsing: from)
        print(document.debugDescription())
        return NSMutableAttributedString(attributedString: visit(document))
    }
    
    mutating func visit(_ markup: Markup) -> NSAttributedString {
        return markup.accept(&self)
    }
    
    mutating func defaultVisit(_ markup: any Markdown.Markup) -> NSMutableAttributedString {
        let result = NSMutableAttributedString()

        for child in markup.children {
            result.append(visit(child))
        }

        return result
    }
    
    mutating func visitText(_ text: Text) -> NSMutableAttributedString {
        return NSMutableAttributedString(string: text.plainText, attributes: [.font:UIFont.systemFont(ofSize: 18, weight: .regular),.foregroundColor:UIColor.label])
    }
    
    mutating func visitEmphasis(_ emphasis: Emphasis) -> NSMutableAttributedString {
        return doVisit(emphasis)
    }
    
    mutating func visitStrong(_ strong: Strong) -> NSMutableAttributedString {
        return doVisit(strong)
    }
    
    mutating func visitInlineAttributes(_ attributes: InlineAttributes) -> NSMutableAttributedString {
        return doVisit(attributes)
    }
    
    mutating func visitLink(_ link: Link) -> NSMutableAttributedString {
        return doVisit(link)
    }
    
    mutating func doVisit(_ markup: any Markup) -> NSMutableAttributedString {
        let result = NSMutableAttributedString()
        
        for child in markup.children {
            result.append(visit(child))
        }
        
        switch markup {
        case is Strong:
            fallthrough
        case is Emphasis:
            result.enumerateAttribute(.font, in: result.fullRange, options: []) { value, range, stop in
                if let newFont = (value as? UIFont)?.addTrait(trait: markup is Strong ? .traitBold : .traitItalic) {
                    result.addAttribute(.font, value: newFont, range: range)
                }
            }
        case is InlineAttributes:
            result.removeAttributes([.underlineStyle,.underlineColor])
            result.addAttribute(.foregroundColor, value: UIColor.tertiaryLabel)
        case is Link:
            if let destination = (markup as? Link)?.destination, let url = URL(string: destination) {
                let color = UIColor.systemRed
                result.addAttributes([.underlineStyle : NSUnderlineStyle.single.rawValue, .underlineColor : color,.foregroundColor : color, .link : url])
            }
        default:
            break
        }
        
        return result
    }
}

extension NSAttributedString {
    var fullRange : NSRange {
        NSRange(location: 0, length: length)
    }
}

extension NSMutableAttributedString {
    func addAttribute(_ name: NSAttributedString.Key, value: Any) {
        addAttribute(name, value: value, range: fullRange)
    }

    func addAttributes(_ attrs: [NSAttributedString.Key : Any]) {
        addAttributes(attrs, range: fullRange)
    }
    
    func removeAttribute(_ name: NSAttributedString.Key) {
        removeAttribute(name, range: fullRange)
    }
    
    func removeAttributes(_ names: [NSAttributedString.Key]) {
        for attribute in names {
            removeAttribute(attribute)
        }
    }
}

extension UIFont {
    func addTrait(trait : UIFontDescriptor.SymbolicTraits) -> UIFont {
        var traits = fontDescriptor.symbolicTraits
        if traits.contains(trait) {
            return self
        } else {
            traits.insert([trait])
            return UIFont(descriptor: fontDescriptor.withSymbolicTraits(traits)!, size: pointSize)
        }
    }
}

This shows the following:

enter image description here


Solution

  • There is no general way to add a new type of Markup that has arbitrary syntax (e.g. starting with @[ and ending with ]). To do that you'd have to fork cmark and swift-markdown. From the documentation of Markup,

    All supported markup elements are already implemented in the framework. Use this protocol only as a generic constraint.

    The GitHub you found is about parsing a BlockDirective. This is kind of like extending the syntax, because you can assign different meanings to block directives with different names. For example, you can implement @Gray { ... } meaning gray text, and @LaTeX { ... } meaning LaTeX code. But at the end of the day, these are just BlockDirectives. There is no new type of Markup being parsed. It's always the same syntax - @ followed by the name, optionally followed by some arguments, optionally followed by the children.

    If you want to adapt the solution in the linked GitHub issue, you need to pass the parseBlockDirectives option when creating the Document.

    let document = Document(parsing: from, options: .parseBlockDirectives)
    

    Then implement the corresponding visitor method

    mutating func visitBlockDirective(_ blockDirective: BlockDirective) -> NSMutableAttributedString {
        return doVisit(blockDirective)
    }
    

    Then add a corresponding case in your switch:

    case is BlockDirective:
        guard let block = markup as? BlockDirective, block.name == "Gray" else {
            break
        }
        result.addAttribute(.foregroundColor, value: UIColor.tertiaryLabel)
    

    However, this is a block container, not an inline one like **bold**. That is, you must write it on a separate line in the markdown source:

    Some Text
    @Gray { Some Gray Text }
    Some Other Text
    

    If you want it to be inline, then InlineAttributes is the intended way to do this. You are already doing this correctly.

    The text inside a ^[]() doesn't have to be a link. You can just write

    ^[Some Gray Text](gray)
    

    and check for it like this:

    case is InlineAttributes:
        guard let attribute = markup as? InlineAttributes, attribute.attributes == "gray" else {
            break
        }
        result.addAttribute(.foregroundColor, value: UIColor.tertiaryLabel)
    

    Just like the name and arguments of a BlockDirective, arbitrary data can be put into the () of a InlineAttributes. You can implement ^[...](gray) to mean gray text, ^[...](serif) to mean the text should be written with a serif font, and so on.