Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ final class RendererFactory {
return EmphasisRenderer(factory: self, config: config)
case .heading:
return HeadingRenderer(factory: self, config: config)
case .link:
return LinkRenderer(factory: self, config: config)
default:
return childrenOnlyRenderer
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import UIKit

final class LinkRenderer: NodeRenderer {
private let factory: RendererFactory
private let config: MarkdownStyleConfig

init(factory: RendererFactory, config: MarkdownStyleConfig) {
self.factory = factory
self.config = config
}

func render(node: MarkdownASTNode, into output: NSMutableAttributedString, context: RenderContext) {
let start = output.length
factory.renderChildren(of: node, into: output, context: context)

let range = RenderContext.rangeForRenderedContent(in: output, start: start)
guard range.length > 0 else { return }

let url = node.attribute("url") ?? ""
let linkStyle = config.link
let linkColor = linkStyle.foregroundColor
let underline = linkStyle.underline ?? true
let underlineStyle = underline ? NSUnderlineStyle.single.rawValue : 0

let linkValue: Any = URL(string: url) ?? url
output.addAttribute(.link, value: linkValue, range: range)

output.enumerateAttributes(in: range, options: []) { _, subrange, _ in
if let linkColor {
output.addAttribute(.foregroundColor, value: linkColor, range: subrange)
output.addAttribute(.underlineColor, value: linkColor, range: subrange)
}
output.addAttribute(.underlineStyle, value: underlineStyle, range: subrange)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ enum DefaultMarkdownTheme {
.foregroundStyle(Semantic.secondary)
.marginBottom(8)

Link()
.foregroundStyle(Semantic.tint)
.underline()

Strong()
Emphasis()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import SwiftUI

public struct Link: MarkdownThemeElement {
public var fontSpec: ThemeFontSpec?
public var fontWeight: Font.Weight?
public var fontDesign: Font.Design?
public var foregroundColorSpec: ThemeColorSpec?
public var marginTop: CGFloat?
public var marginBottom: CGFloat?
public var lineHeight: CGFloat?
public var textAlignment: TextAlignment?
public var underline: Bool?

public init() {}

public func underline(_ enabled: Bool = true) -> Self {
var copy = self
copy.underline = enabled
return copy
}

public func apply(to config: inout MarkdownStyleConfig, traitCollection: UITraitCollection) {
applyElementStyle(to: &config.link, traitCollection: traitCollection)
if let underline {
config.link.underline = underline
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,24 @@ public struct ElementStyle: Equatable, Sendable {
public var marginBottom: CGFloat?
public var lineHeight: CGFloat?
public var textAlignment: NSTextAlignment?
public var underline: Bool?

public init(
font: UIFont? = nil,
foregroundColor: UIColor? = nil,
marginTop: CGFloat? = nil,
marginBottom: CGFloat? = nil,
lineHeight: CGFloat? = nil,
textAlignment: NSTextAlignment? = nil
textAlignment: NSTextAlignment? = nil,
underline: Bool? = nil
) {
self.font = font
self.foregroundColor = foregroundColor
self.marginTop = marginTop
self.marginBottom = marginBottom
self.lineHeight = lineHeight
self.textAlignment = textAlignment
self.underline = underline
}

public mutating func merge(_ other: ElementStyle) {
Expand All @@ -31,6 +34,7 @@ public struct ElementStyle: Equatable, Sendable {
if let marginBottom = other.marginBottom { self.marginBottom = marginBottom }
if let lineHeight = other.lineHeight { self.lineHeight = lineHeight }
if let textAlignment = other.textAlignment { self.textAlignment = textAlignment }
if let underline = other.underline { self.underline = underline }
}
}

Expand All @@ -42,6 +46,7 @@ public struct MarkdownStyleConfig: Equatable, Sendable {
public var heading4: ElementStyle
public var heading5: ElementStyle
public var heading6: ElementStyle
public var link: ElementStyle
public var strong: ElementStyle
public var emphasis: ElementStyle

Expand All @@ -53,6 +58,7 @@ public struct MarkdownStyleConfig: Equatable, Sendable {
heading4: ElementStyle = ElementStyle(),
heading5: ElementStyle = ElementStyle(),
heading6: ElementStyle = ElementStyle(),
link: ElementStyle = ElementStyle(),
strong: ElementStyle = ElementStyle(),
emphasis: ElementStyle = ElementStyle()
) {
Expand All @@ -63,6 +69,7 @@ public struct MarkdownStyleConfig: Equatable, Sendable {
self.heading4 = heading4
self.heading5 = heading5
self.heading6 = heading6
self.link = link
self.strong = strong
self.emphasis = emphasis
}
Expand All @@ -75,6 +82,7 @@ public struct MarkdownStyleConfig: Equatable, Sendable {
heading4.merge(other.heading4)
heading5.merge(other.heading5)
heading6.merge(other.heading6)
link.merge(other.link)
strong.merge(other.strong)
emphasis.merge(other.emphasis)
}
Expand Down Expand Up @@ -102,4 +110,3 @@ public struct MarkdownStyleConfig: Equatable, Sendable {
default: heading1 = style
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import SwiftUI

private struct MarkdownLinkPressHandlerKey: EnvironmentKey {
static let defaultValue: ((URL) -> Void)? = nil
}

public extension EnvironmentValues {
var markdownLinkPressHandler: ((URL) -> Void)? {
get { self[MarkdownLinkPressHandlerKey.self] }
set { self[MarkdownLinkPressHandlerKey.self] = newValue }
}
}

public extension View {
func onLinkPress(_ action: @escaping (URL) -> Void) -> some View {
environment(\.markdownLinkPressHandler, action)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,50 @@ import UIKit

struct MarkdownTextViewRepresentable: UIViewRepresentable {
let attributedText: NSAttributedString
let onLinkPress: ((URL) -> Void)?

func makeCoordinator() -> Coordinator {
Coordinator()
}

func makeUIView(context: Context) -> MarkdownTextView {
MarkdownTextView()
let textView = MarkdownTextView()
textView.delegate = context.coordinator
return textView
}

func updateUIView(_ textView: MarkdownTextView, context: Context) {
context.coordinator.onLinkPress = onLinkPress
textView.setMarkdownAttributedText(attributedText)
}

static func dismantleUIView(_ uiView: MarkdownTextView, coordinator: Coordinator) {
uiView.delegate = nil
}

@available(iOS 16.0, *)
func sizeThatFits(_ proposal: ProposedViewSize, uiView: MarkdownTextView, context: Context) -> CGSize? {
let width = proposal.width ?? UIScreen.main.bounds.width
let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
return CGSize(width: width, height: size.height)
}

final class Coordinator: NSObject, UITextViewDelegate {
var onLinkPress: ((URL) -> Void)?

func textView(
_ textView: UITextView,
shouldInteractWith URL: URL,
in characterRange: NSRange,
interaction: UITextItemInteraction
) -> Bool {
if let onLinkPress {
onLinkPress(URL)
return false
}
return true
}
}
}

final class MarkdownTextView: UITextView {
Expand All @@ -42,10 +71,13 @@ final class MarkdownTextView: UITextView {

private func configure() {
isEditable = false
isSelectable = true
isScrollEnabled = false
backgroundColor = .clear
textContainerInset = .zero
textContainer.lineFragmentPadding = 0
dataDetectorTypes = []
linkTextAttributes = [:]
setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,97 @@ final class RendererTests: XCTestCase {
}
XCTAssertTrue(boldFound)
}


func testLinkHasURLAttribute() {
let result = MarkdownRenderer.render("[text](https://example.com)", config: config)
XCTAssertTrue(result.string.contains("text"))

var foundLink = false
result.enumerateAttribute(.link, in: NSRange(location: 0, length: result.length)) { value, range, _ in
let urlString: String?
if let url = value as? URL {
urlString = url.absoluteString
} else {
urlString = value as? String
}
guard let urlString else { return }
let substring = (result.string as NSString).substring(with: range)
if substring == "text" {
XCTAssertEqual(urlString, "https://example.com")
foundLink = true
}
}
XCTAssertTrue(foundLink)
}


func testLinkThemeOverrideAppliesColor() {
var customConfig = config!
customConfig.link.foregroundColor = .systemBlue

let result = MarkdownRenderer.render("[link](https://example.com)", config: customConfig)

var foundBlue = false
result.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: result.length)) { value, range, _ in
guard let color = value as? UIColor else { return }
if result.string[range] == "link" {
XCTAssertEqual(color, UIColor.systemBlue)
foundBlue = true
}
}
XCTAssertTrue(foundBlue)
}


func testLinkInsideParagraphWithInlineStyles() {
let result = MarkdownRenderer.render("**bold** and [link](https://example.com) and *italic*", config: config)
XCTAssertTrue(result.string.contains("link"))

var linkFound = false
result.enumerateAttribute(.link, in: NSRange(location: 0, length: result.length)) { value, range, _ in
guard value != nil else { return }
if result.string[range] == "link" {
linkFound = true
}
}
XCTAssertTrue(linkFound)
}


func testAllAttributeRangesWithinBounds() {
let samples = [
"# Heading one\n\n## Heading two\n\nA paragraph with a [link](https://example.com) and **bold** nearby.",
Self.headingsAndLinksFixture,
"Plain\n\n**bold**",
"Line one \nLine two",
"Use `code` and **bold**",
"![Mountain](https://example.com/img.png)",
"Hello ![icon](https://example.com/icon.png) world",
"---",
"```\ncode\n```",
"> quote",
"- item",
"1. ordered"
]

for sample in samples {
let result = MarkdownRenderer.render(sample, config: config)
let length = result.length
result.enumerateAttributes(in: NSRange(location: 0, length: length)) { _, range, _ in
XCTAssertGreaterThanOrEqual(range.location, 0)
XCTAssertLessThanOrEqual(NSMaxRange(range), length)
}
}
}

private static let headingsAndLinksFixture = """
# Heading one

## Heading two

A paragraph with a [link](https://example.com) and **bold** nearby.
"""
}

private extension String {
Expand Down