A Swift markdown parsing and rendering library for iOS and macOS. Two targets:
- Hairball — parsing, AST, processors. No UI dependencies.
- HairballUI — SwiftUI rendering with theming, syntax highlighting (Highlightr), LaTeX (SwiftMath), and streaming support.
.package(url: "https://github.com/dnakov/hairball.git", from: "1.0.0")Platforms: iOS 16+, macOS 13+
import HairballUI
// Render a markdown string
MarkdownView("# Hello\n\nSome **bold** and *italic* text.")
// With processors
MarkdownView("Check $E=mc^2$ and https://example.com", processors: [
LatexTransformer(),
AutoLinkTransformer(),
CitationProcessor(),
])
// With a theme
MarkdownView("# Styled")
.markdownTheme(.assistantBubble)
// With syntax highlighting theme
MarkdownView("```swift\nlet x = 42\n```")
.codeSyntaxHighlighter(HighlightrCodeSyntaxHighlighter(theme: "github-dark"))Four layers, from highest to lowest level of control:
Takes a string, parses it, renders it.
MarkdownView("# Title\n\nParagraph with **bold**.")You parse the markdown yourself. The view just renders.
let parser = MarkdownParser()
let doc = parser.parse(myMarkdown)
MarkdownDocumentView(document: doc)You parse, identify blocks, and control streaming animation.
let blocks = IdentifiedBlock.identify(document.blocks)
MarkdownBlocksView(blocks: blocks, isStreaming: true)
.tokenAnimator(FadeTokenAnimator())
.tokenReveal(.init(duration: 0.15, mode: .continuous))Render individual blocks with zero opinions from the library.
ForEach(IdentifiedBlock.identify(doc.blocks)) { item in
BlockNodeView(node: item.block)
}@StateObject var renderer = StreamingMarkdownRenderer(
processors: [LatexTransformer(), AutoLinkTransformer()],
throttleInterval: 0.016
)
// View
StreamingMarkdownContentView(renderer: renderer)
.tokenAnimator(FadeTokenAnimator())
.tokenReveal(.init(duration: 0.15, mode: .continuous))
// Feed tokens
Task {
for await token in myLLMStream {
renderer.append(token)
}
renderer.finish()
}@State private var document = Document(blocks: [])
let parser = MarkdownParser()
MarkdownDocumentView(document: document, isStreaming: true)
.tokenAnimator(FadeTokenAnimator())
.tokenReveal(.default)
func onToken(_ token: String) {
accumulated += token
document = parser.parse(accumulated)
}All animation is app-controlled via environment modifiers. The library has no hardcoded animations.
Token animator — how individual characters appear:
.tokenAnimator(FadeTokenAnimator()) // opacity 0→1 (default)
.tokenAnimator(RevealTokenAnimator()) // left-to-right reveal
.tokenAnimator(InstantTokenAnimator()) // no animation
// Custom:
struct GlowAnimator: TokenAnimator {
func animate(
revealed: AttributedString,
fresh: AttributedString,
progress: Double,
foregroundColor: Color
) -> Text {
var f = fresh
f.foregroundColor = foregroundColor.opacity(progress)
f.backgroundColor = .yellow.opacity((1 - progress) * 0.3)
if revealed.characters.isEmpty { return Text(f) }
return Text(revealed) + Text(f)
}
}Reveal config — timing and mode:
.tokenReveal(TokenRevealConfig(
duration: 0.15, // smoothing constant or speed
mode: .continuous // or .linear
))
// Presets
.tokenReveal(.fast) // 80ms
.tokenReveal(.slow) // 300ms
.tokenReveal(.disabled) // no animation
.tokenReveal(.default) // 150ms continuousTwo reveal modes:
- Continuous — a smooth cursor chases the stream at 60fps using exponential smoothing. Speeds up when behind, slows when close.
durationis the smoothing time constant. - Linear — constant-speed reveal at 60fps.
durationcontrols speed (0.1 ≈ 1000 chars/sec, 1.0 ≈ 100 chars/sec). Keeps going at the same rate after streaming ends.
Block-level animation — for new blocks appearing during streaming:
// Explicit (overrides auto-derivation from tokenReveal):
MarkdownBlocksView(
blocks: blocks,
isStreaming: true,
blockAnimation: .easeOut(duration: 0.2),
blockTransition: .opacity
)
// Or let the library derive it from tokenReveal config (default).
// When tokenReveal is enabled, new blocks fade in with matching timing.
// When disabled, no block animation.tokens → StreamingMarkdownRenderer → Document → MarkdownBlocksView
↑ throttleInterval ↑ tokenReveal config
(content buffer) (animation timing)
The renderer's throttleInterval controls how often tokens are parsed into blocks. The view's tokenReveal config controls how the single reveal cursor animates across blocks. Keep throttleInterval low (0.016) so content is available for the cursor.
Every element is configurable:
let theme = MarkdownTheme(
bodyFont: .system(size: 15),
foregroundColor: .white,
paragraphSpacing: 10,
codeBlock: CodeBlockStyle(
backgroundColor: Color(white: 0.1),
textColor: Color(white: 0.85),
cornerRadius: 10
),
blockquote: BlockquoteStyle(
borderColor: .blue,
borderWidth: 3,
textColor: .gray
),
table: TableStyle(
headerBackground: Color(white: 0.15),
backgroundStyle: .alternatingRows(even: Color(white: 0.08), odd: .clear)
),
link: LinkStyle(color: .blue, underline: true)
)
MarkdownView("...")
.markdownTheme(theme)Built-in presets: .default, .assistantBubble, .userBubble, .userBubblePending
let highlighter = HighlightrCodeSyntaxHighlighter(theme: "atom-one-dark")
highlighter.setTheme("github") // change at runtime
highlighter.availableThemes // ["atom-one-dark", "github", ...]
MarkdownView("...")
.codeSyntaxHighlighter(highlighter)Replace the view for any block type:
struct MyProvider: MarkdownViewComponentProvider {
func makeCodeBlock(language: String?, code: String, configuration: BlockConfiguration) -> some View {
MyFancyCodeBlock(code: code, language: language)
}
func makeHeading(level: Int, content: [InlineNode], configuration: BlockConfiguration) -> some View {
HeadingView(level: level, content: content)
}
}
MarkdownView("...")
.markdownComponentProvider(MyProvider())Or replace just the code block renderer:
struct NeonCodeRenderer: CodeBlockRenderer {
func makeBody(configuration: CodeBlockConfiguration) -> some View {
Text(configuration.highlightedCode)
.padding()
.background(.black)
.cornerRadius(12)
}
}
MarkdownView("...")
.codeBlockRenderer(NeonCodeRenderer())Transform the parsed AST before rendering:
| Processor | What it does |
|---|---|
LatexTransformer |
$...$ to inline math, $$...$$ to display math |
AutoLinkTransformer |
Raw URLs in text become tappable links |
CitationProcessor |
[^1] and [1](url) become citation nodes |
DefaultMarkdownProcessor |
Normalize whitespace, merge text nodes |
MarkdownView("...", processors: [
AutoLinkTransformer(),
LatexTransformer(),
CitationProcessor(),
])Write your own:
struct MyProcessor: MarkdownProcessor {
func process(_ document: Document) -> Document {
// Walk and transform the AST
}
}Parse markdown into a typed AST for programmatic use:
import Hairball
let parser = MarkdownParser()
let document = parser.parse("# Hello\n\n**bold** text")
for block in document.blocks {
switch block {
case .heading(let level, let content):
print("H\(level): \(content)")
case .paragraph(let content):
for inline in content {
switch inline {
case .strong(let children): print("Bold: \(children)")
case .text(let str): print("Text: \(str)")
default: break
}
}
default: break
}
}heading, paragraph, codeBlock, blockQuote, orderedList, unorderedList, table, thematicBreak, htmlBlock, latexBlock, blockDirective, customBlock
text, emphasis, strong, strikethrough, inlineCode, link, image, softBreak, hardBreak, lineBreak, inlineHTML, latex, citation, customInline
let doc = Document(blocks: [
.heading(level: 1, content: [.text("Title")]),
.paragraph(content: [
.text("Hello "),
.strong(children: [.text("world")]),
]),
.codeBlock(language: "swift", content: "let x = 42"),
.unorderedList(tight: true, items: [
ListItem(children: [.paragraph(content: [.text("Item 1")])], checkbox: .checked),
ListItem(children: [.paragraph(content: [.text("Item 2")])], checkbox: .unchecked),
]),
])
MarkdownView(document: doc)Or with the result builder:
MarkdownView {
Heading(level: 1, "Title")
Paragraph("Some text")
CodeBlock(language: "swift", "let x = 42")
if showOptional {
Paragraph("Conditional content")
}
}