Swift Markdown Kit · DOCS
Rendering
Rendering
For anything that is one document rather than a conversation: an article, a release note, a help page, a single model answer.
SwiftUI
import SwiftUI
import SwiftMarkdownKit
struct ArticleView: View {
let article: String
var body: some View {
MarkdownRenderView(markdown: article)
}
}
Binding a string that grows is enough to stream it. When the new value extends the old one, the view appends the delta instead of re-rendering; anything else is a full re-render.
To observe what the renderer is doing, pass a handler:
MarkdownRenderView(markdown: article) { event in
switch event {
case .ready:
break
case .rendered(let height), .heightChanged(let height):
print("height", height)
case .action(let action):
print("action", action)
case .error(let error):
print(error.code, error.message)
@unknown default:
break
}
}
UIKit
import UIKit
import SwiftMarkdownKit
final class ArticleViewController: UIViewController {
private let renderer = MarkdownRenderViewController()
override func viewDidLoad() {
super.viewDidLoad()
addChild(renderer)
renderer.view.frame = view.bounds
renderer.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(renderer.view)
renderer.didMove(toParent: self)
renderer.render("# Hello\n\nIt **works**.")
}
}
render(_:) replaces the content. clear() empties it, and reload() rebuilds
the renderer from scratch — you will not normally need the last one, because the
SDK reloads and replays the content by itself if WebKit reclaims the content
process under memory pressure.
Streaming one document
renderer.startStreaming()
renderer.appendChunk("Streaming **markdown")
renderer.appendChunk("** with math $x^2$", isFinal: true)
Or hand it the accumulated string each time and let it work out the delta:
renderer.renderStream(everythingSoFar)
Both take the incremental path: rendered HTML is cached up to the last completed block boundary and only the unstable tail is re-rendered, so the total cost over a whole stream grows with the length of the answer, not with its square.
Laying it out
A renderer scrolls itself by default. Inside your own ScrollView, turn that
off and let it report its height instead:
struct EmbeddedArticle: View {
let markdown: String
@State private var height: CGFloat = 0
var body: some View {
ScrollView {
VStack {
Text("Anything above")
MarkdownRenderView(
markdown: markdown,
options: MarkdownRenderOptions(isScrollEnabled: false)
) { event in
if case .rendered(let measured) = event { height = measured }
if case .heightChanged(let measured) = event { height = measured }
}
.frame(height: height)
}
}
}
}
heightChanged is debounced while a message is streaming so it does not drive a
layout pass per frame; the interval is heightChangeDebounceInterval.
What the parser accepts
Model output is not textbook markdown, and the parser is built for that. A $
next to a price, a bracketed citation, a formula whose closing brace has not
streamed in yet: none of these turn red. Anything KaTeX cannot parse falls back
to the characters the model actually wrote, in the surrounding text colour.
var options = MarkdownRenderOptions()
options.markdown.math = true // KaTeX, inline and display
options.markdown.taskLists = true // - [x] / - [ ]
options.markdown.emoji = true // :smile:
options.markdown.linkify = true // bare URLs become links
options.markdown.typographer = true // smart quotes, dashes, ellipses
options.markdown.breaks = false // single newline → <br>
options.markdown.allowRawHTML = false // keep off for model output
allowRawHTML is off by default and should stay off for anything a model wrote.
Reasoning folds do not need it — <think> is recognised without opening raw
HTML.
var options = MarkdownRenderOptions()
options.markdown.reasoning.completedLabel = "Thought process"
That configures the fold for document rendering. A conversation is
configured through MarkdownChatRenderOptions.reasoning instead, which also
drives the live fold while the model is still thinking.