written by Talaxy on 2021/5/5

Previous article native Markdown Rendering – SwiftUI

Markdown is a module of RoomTime that renders Markdown text. Those interested can go to Star 😊.

Markdown use

Markdown helps you render Markdown text in SwiftUI. You can also choose which syntax rules to enable, add custom rules, or completely customize the element view.

The basic use

If you want to simply render markdown text, you can:

import Markdown

struct MarkdownDemo: View {
    let text: String
    
    var body: some View {
        ScrollView {
            Markdown(text: text) { element in
                ElementView(element: element)
            }
            .padding()
        }
    }
}
Copy the code

In this Demo, we passed Markdown a text, the Markdown text, and an ElementView mapping component, ElementView. ElementView tells Markdown what view each element should present.

Element Element

Currently, Markdown has the following elements (and their attributes) :

  • HeaderElement title

    Property Type Description
    title String The title content
    level Int The title level
  • QuoteElement reference

    Property Type Description
    element [Element] Reference to an embedded element in a block
  • CodeElement title

    Property Type Description
    lines [String] Lines of code
    lang String? Language annotation
  • OrderListElement An ordered list

    Property Type Description
    offset Int The serial number offset
    items [[Element]] List element group
  • UnorderListElement Unordered list

    Property Type Description
    sign UnorderListElement.Sign A list of symbols
    items [[Element]] List element group
  • TableElement form

    Property Type Description
    heads [String] Header row
    aligns [TableElement.Alignment] Column alignment
    rows [[String]] Content of the line
  • BorderElement divider (no attributes)

  • LineElement Plain text

    Property Type Description
    text String The text content

Custom element view

Here I can give the ElementView implementation code, using ViewBuilder switch syntax support:

public struct ElementView: View {
    public let element: Element
    
    public init(element: Element) {
        self.element = element
    }
    
    public var body: some View {
        switch element {
        case let header as HeaderElement:
            Header(element: header)
        case let quote as QuoteElement:
            Quote(element: quote) { item in
                Markdown(elements: item) { element in
                    ElementView(element: element)
                }
            }
        case let code as CodeElement:
            Code(element: code)
        case let orderList as OrderListElement:
            OrderList(element: orderList) { item in
                Markdown(elements: item) { element in
                    ElementView(element: element)
                }
            }
        case let unorderList as UnorderListElement:
            UnorderList(element: unorderList) { item in
                Markdown(elements: item) { element in
                    ElementView(element: element)
                }
            }
        case let table as TableElement:
            Table(element: table)
        case _ as BorderElement:
            Border(a)case let line as LineElement:
            Line(element: line)
        default:
            EmptyView()}}}Copy the code

Here, if you want to customize the view, you can follow the form above:

struct YourCustomElementView: View {
    let element: Element

    var body: some View {
        switch element {
        case let header as HeaderElement:
            // customize your own `Header`, for example:
            Text(header.title).bold()
        case let quote as QuoteElement:
            // ...
        case let code as CodeElement:
            // ...
        /* some other cases */
        default:
            EmptyView()}}}Copy the code

Then use it directly in Markdown:

import Markdown

struct MarkdownDemo: View {
    let text: String
    
    var body: some View {
        ScrollView {
            Markdown(text: text) { element in
                YourCustomElementView(element: element)
            }
            .padding()
        }
    }
}
Copy the code

Also, if you’re careful, you’ll notice that default in ElementView is set to EmptyView. This means that we can extend directly beyond the original ElementView (which I find very cool) :

import Markdown

struct MarkdownDemo: View {
    let text: String
    
    var body: some View {
        ScrollView {
            Markdown(text: text) { element in
                ElementView(element: element)

                switch element {
                case let customElement as CustomElement:
                    CustomView(element: customElement)
                /* other cases */
                default:
                    EmptyView()
                }
            }
            .padding()
        }
    }
}
Copy the code

You may have noticed that the Markdown component is also nested in Quote OrderList UnorderList, because these elements support content nesting, such as syntax nesting in text like this:

* fruit
  - apple
  - banana
  - pine
* flow
  1. eat
  2. code!
  3. sleep
Copy the code

Apply colours to a drawing mechanism

The profile

If you want to customize Markdown’s rendering rules, I think you need to understand the rendering mechanism inside Markdown first. Here’s what I think is a particularly clear picture of the principle:

This diagram shows roughly the input (Text) and output (View) of Markdown.

Markdown is internally split into a Resolver and an element ViewMapper. The Resolver’s job is to convert text to a set of elements, while a ViewMapper maps elements to views, such as the ElementView in the previous code presentation.

In fact, we can also see from Markdown’s initializer that it takes three arguments:

public struct Markdown<Content: View> :View {
    public init(
        text: String.resolver: Resolver? = Resolver(),
        @ViewBuilder content: @escaping (Element) - >Content
    )
}
Copy the code

Here I’d like to focus on Resolver, a text parser. Its initialization takes two parameters: a set of “partition rules” and a set of “mapping rules”.

public class Resolver {
    public init(splitRules: [SplitRule].mapRules: [MapRule])
}
Copy the code

The Resolver works in two stages: “Spliting” and “Mapping, “which is text splitting and element Mapping.

Spliting Text splitting

At this stage, the parser first splits the text and marks the type of each small piece of text.

First, the parser converts the Text to Raw data. Raw is defined as follows:

public struct Raw: Hashable {
    // Whether to allow partitioning
    public let lock: Bool
    // Text content
    public let text: String
    // The type marked
    public let type: String?
    
    public init(lock: Bool.text: String.type: String? = nil)
}
Copy the code

The parser then splits the text according to SplitRule. Here’s the definition of SplitRule:

// if you want to customize a split rule, simply inherit the 'SplitRule' class and implement the 'split(from:)' method
open class SplitRule {
    public let priority: Double
    
    public init(priority: Double)
    // Text segmentation rules
    open func split(from text: String)- > [Raw]
    // Batch split, called by 'Resolver'
    final func splitAll(raws: [Raw])- > [Raw]}Copy the code

Because the parser has a set of Splitrules, the parser first sorts the set of splitrules in ascending order of priority. The parser then calls splitAll(raws:) of SplitRule in turn to split any Raw whose lock is false.

This is the default split rule set for Markdown:

public let defaultSplitRules: [SplitRule] = [
    // Preprocess whitespace, converting all whitespace characters (such as '\t') to pure whitespace
    SpaceConvertRule(priority: 0),
    // Splitter line fragment split
    BorderSplitRule(priority: 0.5),
    // List fragment split
    ListSplitRule(priority: 1),
    // Table fragment split
    TableSplitRule(priority: 1.5),
    // Block fragmentation
    CodeBlockSplitRule(priority: 3),
    // Indent code block fragment split
    CodeIndentSplitRule(priority: 3.1),
    // Title fragment split
    HeaderSplitRule(priority: 4),
    // Reference block fragment split
    QuoteSplitRule(priority: 5),
    // Line text split
    LineSplitRule(priority: 6)]Copy the code

The point here is that although we are talking about splitting, we can also do other things with Raw, such as modifying Raw, or even discarding Raw. For example, SpaceConvertRule above is not a split function, but a modification of the original Raw.

Mapping element Mapping

At this stage, the parser subclasses Raw to Element in an orderly manner based on a set of MapRules, also based on priority. This is the definition of MapRule and Element:

// if you want to customize a mapping rule, simply inherit the 'SplitRule' class and implement the 'map(from:)' method
open class MapRule {
    public let priority: Double
    
    public init(priority: Double)
    // Raw mapping rule
    open func map(from raw: Raw.resolver: Resolver?). -> Element?
}

// If you want to customize an Element type, you need to inherit the 'Element' class
open class Element: Identifiable {
    public let id = UUID(a)public init(a)
}
Copy the code

After [Raw] is converted to [Element], the parser passes [Element] to Markdown as its attribute. Markdown also provides another constructor:

public struct Markdown<Content: View> :View {

    public let elements: [Element]
    public let content: (Element) - >Content
    
    public init(
        elements: [Element].@ViewBuilder content: @escaping (Element) - >Content
    )
}
Copy the code

This is the default mapping rule group for Markdown:

public let defaultMapRules: [MapRule] = [
    HeaderMapRule(priority: 0),
    QuoteMapRule(priority: 1),
    CodeMapRule(priority: 2),
    ListMapRule(priority: 3),
    TableMapRule(priority: 3.5),
    BorderMapRule(priority: 4),
    LineMapRule(priority: 5)]Copy the code

Customize a set of grammar rules

Based on the above rendering mechanism, we can add some custom syntax. It may be a bit of a step, but it’s worth it. Here’s an example of how to add custom syntax:

We expect bold yellow text lines starting with $. For example, “$warning” is a text line starting with a $.

First, we define segmentation rules, mapping rules, and elements:

import Markdown

class DollarLineElement: Element {
    let text: String
    
    init(text: String) {
        self.text = text
    }
}

fileprivate let dollerLineType = "doller"
fileprivate let dollerLineRegex = # "^ \ + $* $#"
fileprivate let dollerSignRegex = # ^ \ "+ $(? # =. * $)"

class DollarSplitRule: SplitRule {
    override func split(from text: String)- > [Raw] {
        // We can use the inherited 'split(by:text:type:)' method to quickly split text against the re
        // But what I want to say here is that we need to confirm a good Raw type for MapRule to recognize
        return split(by: dollerLineRegex, text: text, type: dollerLineType)
    }
}

class DollarMapRule: MapRule {
    override func map(from raw: Raw.resolver: Resolver?). -> Element? {
        if raw.type = = dollerLineType {
            // 'replace(by:with:)' is an extension of 'StringProtocol' in 'Markdown'
            // It helps you quickly replace text according to the re
            // The 'Markdown' module also provides reged-correlation methods, which will be described later
            let line = raw.text.replace(by: dollerSignRegex, with: "")
            return DollarLineElement(text: line)
        } else {
            return nil}}}Copy the code

Next, define the element view:

import SwiftUI

struct DollarLine: View {
    let element: DollarLineElement
    
    var body: some View {
        Text(element.text)
            .bold()
            .foregroundColor(Color.yellow)
    }
}
Copy the code

Then, configure Resolver and add custom rules:

let splitRules: [SplitRule] = defaultSplitRules + [
    DollarSplitRule(priority: 4.5)]let mapRules: [MapRule] = defaultMapRules + [
    DollarMapRule(priority: 4.5)]let resolver = Resolver(splitRules: splitRules, mapRules: mapRules)
Copy the code

Finally, apply everything to the Markdown component:

struct MarkdownDemo: View {
    let text: String = """ # DollarLine $ Here is a dollar line. """
    
    var body: some View {
        ScrollView {
            Markdown(text: text, resolver: resolver) { element in
                // default view mapping
                ElementView(element: element)
                
                switch element {
                case let dollarLine as DollarLineElement:
                    DollarLine(element: dollarLine)
                default:
                    EmptyView()
                }
            }
            .padding()
        }
    }
}
Copy the code

Here’s the final result:

Regular text processing

Markdown’s regular text processing support will definitely help if you’re comfortable with regular and don’t want to use NSRegularExpression directly. Markdown provides a number of extended support for StringProtocol:

// Single line processing
public extension StringProtocol {
    // add the suffix '\n'
    var withLine: String
    // add the ' 'suffix
    var withSpace: String
    // add the suffix ','
    var withComma: String
    // Add the suffix '.'
    var withDot: String
    // remove the ' ', '\n' symbols at the beginning and end
    func trimmed(a) -> String
    // remove the first and last '\n' symbols
    func trimLine(a) -> String
}

// The default regex option supports global multiple lines
public let lineRegexOption: NSRegularExpression.Options = [.anchorsMatchLines]

// Regular support
public extension StringProtocol {
    // The number of prefixes
    var preBlankNum: Int
    
    // Whether the regular expression is satisfied
    func match(
        by regexText: String.options: NSRegularExpression.Options = lineRegexOption
    ) -> Bool
    
    // Replace the content in the text that satisfies the re
    func replace(
        by regexText: String.with template: String.options: NSRegularExpression.Options = lineRegexOption
    )
    
    // If there is anything in the text that satisfies the re
    func contains(
        by regexText: String.options: NSRegularExpression.Options = lineRegexOption
    ) -> Bool
    
    // The amount of content in the text that satisfies the re
    func matchNum(
        by regexText: String.options: NSRegularExpression.Options = lineRegexOption
    ) -> Int

    // Returns the text content that matches the re
    func matchResult(
        by rawRegex: String.options: NSRegularExpression.Options = lineRegexOption
    )- > [String]
    
    // Split the text content according to the re
    func split(
        by rawRegex: String.options: NSRegularExpression.Options = lineRegexOption
    ) -> RegexSplitResult
}

// split(by:options:) returns the type
public struct RegexSplitResult {
    // range indicates the subscript range of the match, and match indicates whether the match is performed
    public typealias Result = (range: Range<String.Index>, match: Bool)
    public let raw: String
    public let result: [Result]}Copy the code

conclusion

Only the first release of the open source project is currently available, and those interested can poke RoomTime/Markdown. A Star is a great support.

Thank you for reading!