Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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,12 +41,16 @@ public struct BlockQuoteStyleConfiguration {
self.blockQuote = blockQuote
}

private var children: [Markup] {
Array(blockQuote.children)
}

@_documentation(visibility: internal)
public var body: some View {
VStack(alignment: .leading, spacing: configuration.componentSpacing) {
ForEach(Array(blockQuote.children.enumerated()), id: \.offset) { _, child in
ForEach(children.indices, id: \.self) { index in
CmarkNodeVisitor(configuration: configuration)
.makeBody(for: child)
.makeBody(for: children[index])
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ extension MarkdownTableStyleConfiguration.Table: View {
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {
Grid(horizontalSpacing: 0, verticalSpacing: 0) {
header
ForEach(Array(rows.enumerated()), id: \.offset) { (_, row) in
row
ForEach(rows.indices, id: \.self) { index in
rows[index]
}
}
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ fileprivate struct DefaultMarkdownTable: View {
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {
Grid(horizontalSpacing: 0, verticalSpacing: 0) {
configuration.table.header
ForEach(Array(configuration.table.rows.enumerated()), id: \.offset) { (_, row) in
ForEach(configuration.table.rows.indices, id: \.self) { index in
if showsRowSeparators {
Divider()
}
row
configuration.table.rows[index]
}
}
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ fileprivate struct GithubMarkdownTable: View {
Grid(horizontalSpacing: 0, verticalSpacing: 0) {
configuration.table.header

ForEach(Array(configuration.table.rows.enumerated()), id: \.offset) { (index, row) in
ForEach(configuration.table.rows.indices, id: \.self) { index in
let backgroundStyle = index % 2 == 0 ? AnyShapeStyle(backgroundColor) : AnyShapeStyle(alternativeRowColor)
row
configuration.table.rows[index]
.markdownTableRowBackgroundStyle(backgroundStyle)
}
}
Expand Down
12 changes: 6 additions & 6 deletions Sources/MarkdownView/MarkdownReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,25 @@ import SwiftUI
/// }
/// ```
public struct MarkdownReader<Content: View>: View {
@ObservedObject private var content: MarkdownContent
@StateObject private var content: MarkdownContent
private var _body: (_ markdownContent: MarkdownContent) -> Content

public init(
_ text: String,
@ViewBuilder contents: @escaping (MarkdownContent) -> Content
) {
content = MarkdownContent(text)
_content = StateObject(wrappedValue: MarkdownContent(text))
self._body = contents
}

public init(
_ url: URL,
@ViewBuilder contents: @escaping (MarkdownContent) -> Content
) {
content = MarkdownContent(url)
_content = StateObject(wrappedValue: MarkdownContent(url))
self._body = contents
}

public var body: some View {
_body(content)
}
Expand Down
83 changes: 70 additions & 13 deletions Sources/MarkdownView/MarkdownView.swift
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid data corruption, we should only keep single source-of-truth. But there are two.

I know there is a flag but the reason for switching to an ObservableObject is to be able to share the content across view hierarchy so for MarkdownView, it must use ObservedObject (the object is passed either from parent view or from MarkdownReader

Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,91 @@ import Markdown

/// A view that renders markdown content.
public struct MarkdownView: View {
@ObservedObject private var content: MarkdownContent

/// Owned content for the string/URL inits — created once, survives parent re‑renders.
@StateObject private var ownedContent: MarkdownContent
/// External content passed via ``init(_:)-MarkdownContent``.
@ObservedObject private var externalContent: MarkdownContent
/// Which source of truth to use.
private var usesExternalContent: Bool

private var content: MarkdownContent {
usesExternalContent ? externalContent : ownedContent
}

@Environment(\.markdownRendererConfiguration) private var configuration
@Environment(\.markdownViewRenderer) private var renderer

@Environment(\.headingStyleGroup) private var headingStyleGroup

/// Creates a view that renders given markdown string.
/// - Parameter text: The markdown source to render.
public init(_ text: String) {
self.content = MarkdownContent(text)
let content = MarkdownContent(text)
_ownedContent = StateObject(wrappedValue: content)
_externalContent = ObservedObject(wrappedValue: content)
usesExternalContent = false
}

/// Creates a view that renders the markdown from a local file at given url.
/// - Parameter url: The url to the markdown file to render.
public init(_ url: URL) {
self.content = MarkdownContent(url)
let content = MarkdownContent(url)
_ownedContent = StateObject(wrappedValue: content)
_externalContent = ObservedObject(wrappedValue: content)
usesExternalContent = false
}

/// Creates an instance that renders from a ``MarkdownContent`` .
/// - Parameter content: The ``MarkdownContent`` to render.
public init(_ content: MarkdownContent) {
self.content = content
_ownedContent = StateObject(wrappedValue: content)
_externalContent = ObservedObject(wrappedValue: content)
usesExternalContent = true
}

public var body: some View {
renderer
.makeBody(content: content, configuration: configuration)
.erasedToAnyView()
.font(configuration.fonts[.body] ?? Font.body)
var config = configuration
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's better to modify the existing view modifier

config.headingStyleGroup = headingStyleGroup
return MarkdownRenderingView(
content: content,
configuration: config
)
.font(configuration.fonts[.body] ?? Font.body)
}
}

/// A cache that stores the last rendered view alongside the inputs that
/// produced it. Because this is a reference type stored in `@State`, it
/// persists across body evaluations without triggering additional renders.
@MainActor
private final class RenderCache {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current (in v2 as well) is not efficient. Caches are rarely hit. I will implement a new approach later

var raw: MarkdownContent.Raw?
var configuration: MarkdownRendererConfiguration?
var rendered: AnyView = AnyView(EmptyView())
}

/// An inner view that caches the rendered output.
///
/// Rendering is skipped when the markdown source and configuration
/// have not changed since the last evaluation.
private struct MarkdownRenderingView: View {
let content: MarkdownContent
let configuration: MarkdownRendererConfiguration

@Environment(\.markdownViewRenderer) private var renderer
@State private var cache = RenderCache()

var body: some View {
let currentRaw = (try? content.markdown).map { MarkdownContent.Raw.plainText($0) }
if currentRaw != cache.raw || configuration != cache.configuration {
let rendered = renderer
.makeBody(content: content, configuration: configuration)
.erasedToAnyView()
cache.raw = currentRaw
cache.configuration = configuration
cache.rendered = rendered
return rendered
}
return cache.rendered
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ struct TextViewViewRenderer: MarkdownViewRenderer {
if !configuration.allowedBlockDirectiveRenderers.isEmpty {
parseOptions.insert(.parseBlockDirectives)
}

let textContent = CmarkTextContentVisitor(configuration: configuration)
.makeTextContent(for: content.document(options: parseOptions))
return TextView {
Expand Down
52 changes: 27 additions & 25 deletions Sources/MarkdownView/Renderers/Cmark/CmarkNodeVisitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,21 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor {
}

func visitDocument(_ document: Document) -> MarkdownNodeView {
var renderer = self
var visitor = self
let nodeViews = document.children.map {
renderer.visit($0)
visitor.visit($0)
}
return MarkdownNodeView(nodeViews, layoutPolicy: .linebreak)
}

func defaultVisit(_ markup: Markdown.Markup) -> MarkdownNodeView {
descendInto(markup)
}

func descendInto(_ markup: any Markup) -> MarkdownNodeView {
var nodeViews = [MarkdownNodeView]()
for child in markup.children {
var renderer = self
let nodeView = renderer.visit(child)
nodeViews.append(nodeView)
var visitor = self
let nodeViews = markup.children.map {
visitor.visit($0)
}
return MarkdownNodeView(nodeViews)
}
Expand Down Expand Up @@ -170,11 +168,9 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor {
}

func visitTableCell(_ cell: Markdown.Table.Cell) -> MarkdownNodeView {
var cellViews = [MarkdownNodeView]()
for child in cell.children {
var renderer = CmarkNodeVisitor(configuration: configuration)
let cellView = renderer.visit(child)
cellViews.append(cellView)
var visitor = self
let cellViews = cell.children.map {
visitor.visit($0)
}
return MarkdownNodeView(
cellViews,
Expand All @@ -193,10 +189,10 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor {
}

func visitEmphasis(_ emphasis: Markdown.Emphasis) -> MarkdownNodeView {
var visitor = self
var attributedString = AttributedString()
for child in emphasis.children {
var renderer = self
guard let text = renderer.visit(child).asAttributedString else { continue }
guard let text = visitor.visit(child).asAttributedString else { continue }
let intent = text.inlinePresentationIntent ?? []
attributedString += text.mergingAttributes(
AttributeContainer()
Expand All @@ -205,12 +201,12 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor {
}
return MarkdownNodeView(attributedString)
}

func visitStrong(_ strong: Strong) -> MarkdownNodeView {
var visitor = self
var attributedString = AttributedString()
for child in strong.children {
var renderer = self
guard let text = renderer.visit(child).asAttributedString else { continue }
guard let text = visitor.visit(child).asAttributedString else { continue }
let intent = text.inlinePresentationIntent ?? []
attributedString += text.mergingAttributes(
AttributeContainer()
Expand All @@ -219,12 +215,12 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor {
}
return MarkdownNodeView(attributedString)
}

func visitStrikethrough(_ strikethrough: Strikethrough) -> MarkdownNodeView {
var visitor = self
var attributedString = AttributedString()
for child in strikethrough.children {
var renderer = self
guard let text = renderer.visit(child).asAttributedString else { continue }
guard let text = visitor.visit(child).asAttributedString else { continue }
let intent = text.inlinePresentationIntent ?? []
attributedString += text.mergingAttributes(
AttributeContainer()
Expand All @@ -241,20 +237,26 @@ struct CmarkNodeVisitor: @preconcurrency MarkupVisitor {

let nodeView = descendInto(link)
let tintColor = configuration.preferredTintColors[.link] ?? .accentColor
let underline = configuration.underlineLinks
return if let attributedString = nodeView.asAttributedString {
MarkdownNodeView(
attributedString.mergingAttributes(
AttributeContainer()
attributedString.mergingAttributes({
var container = AttributeContainer()
.link(url)
.foregroundColor(tintColor)
)
if underline {
container.underlineStyle = .single
}
return container
}())
)
} else {
MarkdownNodeView {
Link(destination: url) {
nodeView
}
.foregroundStyle(tintColor)
.underline(underline)
}
}
}
Expand Down
Loading