Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for links in markdown #5406

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 147 additions & 6 deletions ios/MullvadVPN.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// AttributedMarkdownProtocol.swift
// MullvadVPN
//
// Created by pronebird on 03/11/2023.
// Copyright © 2023 Mullvad VPN AB. All rights reserved.
//

import Foundation

/// Type of markdown element.
enum MarkdownElement {
/// Bold text.
case bold
/// URL link.
case link
}

/// Callback type used to override the attributed string attributes during parsing.
typealias MarkdownEffectCallback = (MarkdownElement, String) -> [NSAttributedString.Key: Any]

/// Type implementing conversion from markdown to attributed string.
protocol AttributedMarkdown {
/// Convert the type to attributed string.
///
/// - Parameters:
/// - options: markdown styling options.
/// - applyEffect: the callback used to override the string attributes during parsing.
/// - Returns: the attributed string.
func attributedString(options: MarkdownStylingOptions, applyEffect: MarkdownEffectCallback?) -> NSAttributedString
}

extension NSAttributedString.Key {
/// The attributed string key used in place of `.link` whos text color is not customizable in UILabels.
/// The value associated with this key can be a `String` or an `URL`.
static let hyperlink = NSAttributedString.Key("HyperLink")
}

extension AttributedMarkdown {
/// Convert the type to attributed string.
///
/// - Parameter options: markdown styling options.
/// - Returns: the attributed string.
func attributedString(options: MarkdownStylingOptions) -> NSAttributedString {
attributedString(options: options, applyEffect: nil)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// MarkdownBoldNode+AttributedString.swift
// MullvadVPN
//
// Created by pronebird on 03/11/2023.
// Copyright © 2023 Mullvad VPN AB. All rights reserved.
//

import Foundation

extension MarkdownBoldNode: AttributedMarkdown {
func attributedString(options: MarkdownStylingOptions, applyEffect: MarkdownEffectCallback?) -> NSAttributedString {
let string = text?.withUnicodeLineSeparators() ?? ""
var attributes: [NSAttributedString.Key: Any] = [.font: options.boldFont]

if let textColor = options.textColor {
attributes[.foregroundColor] = textColor
}

attributes.merge(applyEffect?(.bold, string) ?? [:], uniquingKeysWith: { $1 })

return NSAttributedString(string: string, attributes: attributes)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// MarkdownDocumentNode+AttributedString.swift
// MullvadVPN
//
// Created by pronebird on 03/11/2023.
// Copyright © 2023 Mullvad VPN AB. All rights reserved.
//

import Foundation

extension MarkdownDocumentNode: AttributedMarkdown {
func attributedString(options: MarkdownStylingOptions, applyEffect: MarkdownEffectCallback?) -> NSAttributedString {
var isPrecededByParagraph = false

return children.reduce(into: NSMutableAttributedString()) { partialResult, node in
guard let transformableNode = node as? AttributedMarkdown else { return }

defer { isPrecededByParagraph = node.isParagraph }

// Add newline between paragraphs.
if node.isParagraph, isPrecededByParagraph {
partialResult.append(NSAttributedString(
string: "\n",
attributes: [.font: options.font, .paragraphStyle: options.paragraphStyle]
))
}

let attributedString = transformableNode.attributedString(options: options, applyEffect: applyEffect)
partialResult.append(attributedString)
}
}
}

private extension MarkdownNode {
var isParagraph: Bool {
type == .paragraph
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// MarkdownLinkNode+AttributedString.swift
// MullvadVPN
//
// Created by pronebird on 03/11/2023.
// Copyright © 2023 Mullvad VPN AB. All rights reserved.
//

import Foundation

extension MarkdownLinkNode: AttributedMarkdown {
func attributedString(options: MarkdownStylingOptions, applyEffect: MarkdownEffectCallback?) -> NSAttributedString {
var attributes: [NSAttributedString.Key: Any] = [.font: options.font, options.linkAttribute.attributeKey: url]

if let linkColor = options.linkColor {
attributes[.foregroundColor] = linkColor
}

attributes.merge(applyEffect?(.link, title) ?? [:], uniquingKeysWith: { $1 })

return NSAttributedString(string: title, attributes: attributes)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// MarkdownParagraphNode+AttributedString.swift
// MullvadVPN
//
// Created by pronebird on 03/11/2023.
// Copyright © 2023 Mullvad VPN AB. All rights reserved.
//

import Foundation

extension MarkdownParagraphNode: AttributedMarkdown {
func attributedString(options: MarkdownStylingOptions, applyEffect: MarkdownEffectCallback?) -> NSAttributedString {
let mutableAttributedString = children.compactMap { $0 as? AttributedMarkdown }
.reduce(into: NSMutableAttributedString()) { partialResult, node in
let attributedString = node.attributedString(options: options, applyEffect: applyEffect)
partialResult.append(attributedString)
}

let range = NSRange(location: 0, length: mutableAttributedString.length)
mutableAttributedString.addAttribute(.paragraphStyle, value: options.paragraphStyle, range: range)

return mutableAttributedString
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// MarkdownTextNode+AttributedString.swift
// MullvadVPN
//
// Created by pronebird on 03/11/2023.
// Copyright © 2023 Mullvad VPN AB. All rights reserved.
//

import Foundation

extension MarkdownTextNode: AttributedMarkdown {
func attributedString(options: MarkdownStylingOptions, applyEffect: MarkdownEffectCallback?) -> NSAttributedString {
let string = text?.withUnicodeLineSeparators() ?? ""
var attributes: [NSAttributedString.Key: Any] = [.font: options.font]

if let textColor = options.textColor {
attributes[.foregroundColor] = textColor
}

return NSAttributedString(string: string, attributes: attributes)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// NSAttributedString+Markdown.swift
// MullvadVPN
//
// Created by pronebird on 19/11/2021.
// Copyright © 2021 Mullvad VPN AB. All rights reserved.
//

import UIKit

extension NSAttributedString {
/// Initialize the attributed string from markdown.
///
/// - Parameters:
/// - markdownString: the markdown string.
/// - options: markdown styling options.
/// - applyEffect: the callback used to override the string attributes during parsing.
convenience init(
markdownString: String,
options: MarkdownStylingOptions,
applyEffect: MarkdownEffectCallback? = nil
) {
var parser = MarkdownParser(markdown: markdownString)
let document = parser.parse()

let attributedString = document.attributedString(options: options, applyEffect: applyEffect)

self.init(attributedString: attributedString)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// String+UnicodeLineSeparator.swift
// MullvadVPN
//
// Created by pronebird on 03/11/2023.
// Copyright © 2023 Mullvad VPN AB. All rights reserved.
//

import Foundation

/// Unicode line separator.
/// Declared on macOS as `NSLineSeparatorCharacter` but not on iOS.
private let unicodeLineSeparator: Character = "\u{2028}"

extension String {
/// Return a new string with all line seprators `\r\n` or `\n` replaced with unicode line separator.
///
/// `NSAttributedString` treats `\n` as a paragraph separator.
/// - Returns: a new string with all line separators converted to unicode line separator.
func withUnicodeLineSeparators() -> String {
String(map { ch in
ch.isNewline ? unicodeLineSeparator : ch
})
}
}
27 changes: 27 additions & 0 deletions ios/MullvadVPN/Classes/Markdown/IteratorProtocol+String.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// IteratorProtocol+String.swift
// MullvadVPN
//
// Created by pronebird on 03/11/2023.
// Copyright © 2023 Mullvad VPN AB. All rights reserved.
//

import Foundation

extension IteratorProtocol where Element == Character {
/// Collect characters into a string while the predicate evaluates to `true`. Rethrows errors thrown by the predicate.
///
/// - Parameter predicate: The predicate to evaluate each character before appending it to the result string.
/// - Returns: The result string.
mutating func take(while predicate: (Character) throws -> Bool) rethrows -> String {
var accummulated = ""

while let char = next() {
guard try predicate(char) else { break }

accummulated.append(char)
}

return accummulated
}
}
99 changes: 99 additions & 0 deletions ios/MullvadVPN/Classes/Markdown/MarkdownNode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//
// MarkdownNode.swift
// MullvadVPN
//
// Created by pronebird on 03/11/2023.
// Copyright © 2023 Mullvad VPN AB. All rights reserved.
//

import Foundation

/// The base type defining markdown node.
/// Do not instantiate this type directly. Use one of its subclasses instead.
class MarkdownNode: CustomDebugStringConvertible {
/// The type of node.
let type: MarkdownNodeType

/// The child nodes.
private(set) var children: [MarkdownNode] = []

/// The parent node.
private(set) weak var parent: MarkdownNode?

init(type: MarkdownNodeType, children: [MarkdownNode] = []) {
self.type = type
children.forEach { addChild($0) }
}

/// Returns last child.
var lastChild: MarkdownNode? {
return children.last
}

/// Add child node.
func addChild(_ child: MarkdownNode) {
child.parent = self
children.append(child)
}

/// Remove child.
func removeChild(_ child: MarkdownNode) {
children.removeAll { childFromArray in
guard child === childFromArray else { return false }

child.parent = nil
return true
}
}

/// Detach this node from parent.
func removeFromParent() {
parent?.removeChild(self)
}

var debugDescription: String {
// Subclasses should override this method.
return "\(self)"
}

/// Returns a recursive description of a markdown subtree. Useful when debugging.
///
/// - Parameter level: indentation level.
/// - Returns: recursive description of a subtree
func recursiveDescription(level: Int = 0) -> String {
let indent = String(repeating: " ", count: level)
var str = ""

let descriptionLines = debugDescription.components(separatedBy: .newlines)
if let firstLine = descriptionLines.first {
str += "\(indent)+ \(firstLine)"
}
descriptionLines.dropFirst().forEach { line in
str += "\n\(indent) \(line)"
}

for child in children {
str += "\n" + child.recursiveDescription(level: level + 1)
}

return str
}

/// Test equality.
///
/// Default implementation only checks node types.
///
/// - Parameter other: other node.
/// - Returns: `true` if objects are equal, otherwise `false`.
func isEqualTo(_ other: MarkdownNode) -> Bool {
guard type == other.type && children.count == other.children.count else { return false }

return zip(children, other.children).allSatisfy { $0.isEqualTo($1) }
}
}

extension MarkdownNode: Equatable {
static func == (lhs: MarkdownNode, rhs: MarkdownNode) -> Bool {
return lhs.isEqualTo(rhs)
}
}
30 changes: 30 additions & 0 deletions ios/MullvadVPN/Classes/Markdown/MarkdownNodeType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// MarkdownNodeType.swift
// MullvadVPN
//
// Created by pronebird on 03/11/2023.
// Copyright © 2023 Mullvad VPN AB. All rights reserved.
//

import Foundation

/// The type of node used within the markdown tree.
enum MarkdownNodeType {
/// The unstyled text fragment.
case text

/// The bold text node.
/// Syntax: `**Proceed carefully in unknown waters!**`
case bold

/// The URL link node.
/// Syntax: `[Mullvad VPN](https://mullvad.net)`
case link

/// The paragraph node.
/// Typically groups of elements separated by two newline characters form a paragraph.
case paragraph

/// The fragment of a markdown document.
case document
}
Loading
Loading