element-ios/Riot/Modules/MatrixKit/Utils/EventFormatter/MarkdownToHTMLRenderer.swift

169 lines
6.2 KiB
Swift

/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import Foundation
import Down
import libcmark
@objc public protocol MarkdownToHTMLRendererProtocol: NSObjectProtocol {
func renderToHTML(markdown: String) -> String?
}
@objcMembers
public class MarkdownToHTMLRenderer: NSObject {
fileprivate var options: DownOptions = []
// Do not expose an initializer with options, because `DownOptions` is not ObjC compatible.
public override init() {
super.init()
}
}
extension MarkdownToHTMLRenderer: MarkdownToHTMLRendererProtocol {
public func renderToHTML(markdown: String) -> String? {
do {
let ast = try DownASTRenderer.stringToAST(markdown, options: options)
defer {
cmark_node_free(ast)
}
ast.repairLinks()
return try DownHTMLRenderer.astToHTML(ast, options: options)
} catch {
MXLog.error("[MarkdownToHTMLRenderer] renderToHTML failed")
return nil
}
}
}
@objcMembers
public class MarkdownToHTMLRendererHardBreaks: MarkdownToHTMLRenderer {
public override init() {
super.init()
options = .hardBreaks
}
}
// MARK: - AST-handling private extensions
private extension CMarkNode {
/// Formatting symbol associated with given note type
/// Note: this is only defined for node types that are handled in repairLinks
var formattingSymbol: String {
switch self.type {
case CMARK_NODE_EMPH:
return "_"
case CMARK_NODE_STRONG:
return "__"
default:
return ""
}
}
/// Repairs links that were broken down by markdown formatting.
/// Should be used on the first node of libcmark's AST
/// (e.g. the object returned by DownASTRenderer.stringToAST).
func repairLinks() {
let iterator = cmark_iter_new(self)
var text = ""
var isInParagraph = false
var previousNode: CMarkNode?
var orphanNodes: [CMarkNode] = []
var shouldUnlinkFormattingMode = false
var event: cmark_event_type?
while event != CMARK_EVENT_DONE {
event = cmark_iter_next(iterator)
guard let node = cmark_iter_get_node(iterator) else { return }
if node.type == CMARK_NODE_PARAGRAPH {
if event == CMARK_EVENT_ENTER {
isInParagraph = true
} else {
isInParagraph = false
text = ""
}
}
if isInParagraph {
switch node.type {
case CMARK_NODE_SOFTBREAK,
CMARK_NODE_LINEBREAK:
text = ""
case CMARK_NODE_TEXT:
if let literal = node.literal {
text += literal
// Reset text if it ends up with a whitespace.
if text.last?.isWhitespace == true {
text = ""
}
// Only the last part could be a link conflicting with next node.
text = String(text.split(separator: " ").last ?? "")
}
case CMARK_NODE_EMPH where previousNode?.type == CMARK_NODE_TEXT,
CMARK_NODE_STRONG where previousNode?.type == CMARK_NODE_TEXT:
if event == CMARK_EVENT_ENTER {
if !text.containedUrls.isEmpty,
let childLiteral = node.pointee.first_child.literal {
// If current text is a link, the formatted text is reverted back to a
// plain text as a part of the link.
let symbol = node.formattingSymbol
let nonFormattedText = "\(symbol)\(childLiteral)\(symbol)"
let replacementTextNode = cmark_node_new(CMARK_NODE_TEXT)
cmark_node_set_literal(replacementTextNode, nonFormattedText)
cmark_node_insert_after(previousNode, replacementTextNode)
// Set child literal to empty string so we dont read it.
// This avoids having to re-create the main
// iterator in the middle of the process.
cmark_node_set_literal(node.pointee.first_child, "")
let newIterator = cmark_iter_new(node)
_ = cmark_iter_next(newIterator)
cmark_node_unlink(node)
orphanNodes.append(node)
let nextNode = cmark_iter_get_node(newIterator)
cmark_node_insert_after(previousNode, nextNode)
shouldUnlinkFormattingMode = true
}
} else {
if shouldUnlinkFormattingMode {
cmark_node_unlink(node)
orphanNodes.append(node)
shouldUnlinkFormattingMode = false
}
}
default:
break
}
}
previousNode = node
}
// Free all nodes removed from the AST.
// This is done as a last step to avoid messing
// up with the main itertor.
for orphanNode in orphanNodes {
cmark_node_free(orphanNode)
}
}
}
private extension String {
/// Returns array of URLs detected inside the String.
var containedUrls: [NSTextCheckingResult] {
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue),
let percentEncoded = self.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) else {
return []
}
return detector.matches(in: percentEncoded, options: [], range: NSRange(location: 0, length: percentEncoded.utf16.count))
}
}