apple-text-markdown

Use when working with Markdown in SwiftUI Text or AttributedString — what renders, PresentationIntent, or rendering gaps

Markdown in Apple's Text System

Use this skill when the main question is how Markdown maps into Apple text APIs and where rendering gaps remain.

When to Use

  • You are parsing or rendering Markdown in Text, UITextView, or TextKit.
  • You need PresentationIntent behavior explained.
  • You are deciding between native Markdown support and a third-party renderer.

Quick Decision

  • Simple inline Markdown in SwiftUI Text -> native support is fine
  • Full Markdown document rendering or editing -> stay here
  • Regex/parsing mechanics are the main question -> /skill apple-text-parsing

Core Guidance

SwiftUI Text Markdown (iOS 15+)

What Renders Automatically

// String literals in Text are LocalizedStringKey — Markdown processed automatically
Text("**Bold** and *italic* and `code`")
Text("~~Strikethrough~~ and [Link](https://apple.com)")
Text("***Bold italic*** together")
SyntaxRenders?Result
**bold** / __bold__Bold text
*italic* / _italic_Italic text
***bold italic***Bold + italic
`code`Monospaced font
~~strikethrough~~Strikethrough
[text](url)Tappable link (accent color)

What Does NOT Render

SyntaxRenders?What Happens
# HeadingTreated as plain text or ignored
- Item / 1. ItemNot rendered as list
> QuoteNot rendered as quote
```code block```Not rendered as code block
![alt](image)Images not supported
TablesNot supported
--- (horizontal rule)Not supported
Task lists - [ ]Not supported

Only inline Markdown works in SwiftUI Text. Block-level Markdown is not rendered.

String vs LocalizedStringKey

// ✅ Markdown renders — literal is LocalizedStringKey
Text("**bold** text")

// ❌ Markdown does NOT render — String variable
let text: String = "**bold** text"
Text(text)  // Displays literal asterisks

// ✅ Force Markdown on String variable
let text: String = "**bold** text"
Text(LocalizedStringKey(text))

// ❌ Disable Markdown
Text(verbatim: "**not bold**")  // Displays literal asterisks

With AttributedString

// Inline-only parsing (safe for user content)
let str = try AttributedString(
    markdown: "**Bold** and [link](https://apple.com)",
    options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
)
Text(str)

AttributedString Markdown Parsing

interpretedSyntax Options

OptionParsesWhitespaceBest For
.inlineOnlyInline only (bold, italic, code, links, strikethrough)CollapsesSimple formatted text
.inlineOnlyPreservingWhitespaceInline onlyPreserves originalChat messages, user input
.fullALL Markdown (inline + block-level)Markdown rulesDocuments, articles

Block-Level Parsing with .full

let markdown = """
# Heading

- Item 1
- Item 2

> A quote

code block

"""

let str = try AttributedString(
    markdown: markdown,
    options: .init(interpretedSyntax: .full)
)

// Block-level structure stored in presentationIntent attribute
for run in str.runs {
    if let intent = run.presentationIntent {
        for component in intent.components {
            switch component.kind {
            case .header(let level):
                print("Heading level \(level)")
            case .unorderedList:
                print("Unordered list")
            case .listItem(let ordinal):
                print("List item \(ordinal)")
            case .blockQuote:
                print("Block quote")
            case .codeBlock(let languageHint):
                print("Code block: \(languageHint ?? "none")")
            case .paragraph:
                print("Paragraph")
            case .orderedList:
                print("Ordered list")
            case .table:
                print("Table")
            case .tableHeaderRow:
                print("Table header")
            case .tableRow(let index):
                print("Table row \(index)")
            case .tableCell(let column):
                print("Table cell column \(column)")
            case .thematicBreak:
                print("Horizontal rule")
            @unknown default:
                break
            }
        }
    }
}

Critical: SwiftUI Text ignores presentationIntent entirely. It stores the data but renders nothing differently for headings, lists, quotes, etc.

Rendering PresentationIntent in UITextView

To actually render block-level Markdown in TextKit, you must interpret PresentationIntent and apply paragraph styles:

func applyBlockFormatting(to attrStr: AttributedString) -> NSAttributedString {
    let mutable = NSMutableAttributedString(attrStr)

    for run in attrStr.runs {
        guard let intent = run.presentationIntent else { continue }
        let nsRange = NSRange(run.range, in: attrStr)
        let style = NSMutableParagraphStyle()

        for component in intent.components {
            switch component.kind {
            case .header(let level):
                let sizes: [Int: CGFloat] = [1: 28, 2: 24, 3: 20, 4: 18, 5: 16, 6: 14]
                let fontSize = sizes[level] ?? 16
                mutable.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: fontSize), range: nsRange)
                style.paragraphSpacingBefore = 12
                style.paragraphSpacing = 8

            case .unorderedList, .orderedList:
                style.headIndent = 24
                style.firstLineHeadIndent = 8

            case .blockQuote:
                style.headIndent = 16
                style.firstLineHeadIndent = 16
                mutable.addAttribute(.foregroundColor, value: UIColor.secondaryLabel, range: nsRange)

            case .codeBlock:
                mutable.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular), range: nsRange)
                mutable.addAttribute(.backgroundColor, value: UIColor.secondarySystemBackground, range: nsRange)

            default: break
            }
        }

        mutable.addAttribute(.paragraphStyle, value: style, range: nsRange)
    }

    return mutable
}

Custom Markdown Attributes

Syntax

Custom attributes use ^[text](key: value) in Markdown:

This has ^[custom styling](highlight: true, color: 'blue')

Implementation

// Step 1: Define attribute key
enum HighlightAttribute: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey {
    typealias Value = Bool
    static let name = "highlight"
}

enum ColorNameAttribute: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey {
    typealias Value = String
    static let name = "color"
}

// Step 2: Create scope
extension AttributeScopes {
    struct MyMarkdownAttributes: AttributeScope {
        let highlight: HighlightAttribute
        let color: ColorNameAttribute
        let foundation: FoundationAttributes
        let swiftUI: SwiftUIAttributes
    }
    var myMarkdown: MyMarkdownAttributes.Type { MyMarkdownAttributes.self }
}

// Step 3: Dynamic lookup
extension AttributeDynamicLookup {
    subscript<T: AttributedStringKey>(
        dynamicMember keyPath: KeyPath<AttributeScopes.MyMarkdownAttributes, T>
    ) -> T { self[T.self] }
}

// Step 4: Parse
let str = try AttributedString(
    markdown: "This is ^[highlighted](highlight: true, color: 'blue') text",
    including: \.myMarkdown
)

// Step 5: Use
for run in str.runs {
    if run.highlight == true {
        print("Color: \(run.color ?? "default")")
    }
}

Native vs Third-Party Markdown

Native (AttributedString + SwiftUI Text)

Pros:

  • No dependencies
  • Codable for serialization
  • Custom attributes via MarkdownDecodableAttributedStringKey
  • Type-safe
  • Localization-aware

Cons:

  • SwiftUI Text renders ONLY inline formatting
  • Block-level requires manual PresentationIntent interpretation
  • No image support
  • No table rendering
  • Significant work for full document rendering

Third-Party: MarkdownUI (gonzalezreal/swift-markdown-ui)

Pros:

  • Renders full Markdown in SwiftUI (headings, lists, code blocks, images, tables, block quotes)
  • Themeable
  • Syntax highlighting for code blocks
  • No manual PresentationIntent work

Cons:

  • External dependency
  • May not match your exact design needs
  • Custom rendering harder than native Text
  • Additional maintenance burden

Third-Party: Apple's swift-markdown

Pros:

  • Official Apple package
  • Full CommonMark parser
  • AST access for custom rendering
  • Used by DocC

Cons:

  • Parser only — no views included
  • Must build your own rendering pipeline

Decision Guide

NeedSolution
Simple bold/italic/links in SwiftUINative Text("**bold**")
Full Markdown document in SwiftUIMarkdownUI library
Markdown in UITextViewParse with .full, interpret PresentationIntent
Custom Markdown attributesMarkdownDecodableAttributedStringKey
Markdown AST manipulationswift-markdown (Apple)
Editable MarkdownTextKit view with syntax highlighting

Common Pitfalls

  1. Expecting Text to render headings/lists — SwiftUI Text only renders inline Markdown. Block-level is silently ignored.
  2. String variable doesn't render Markdown — Only LocalizedStringKey triggers Markdown. Wrap: Text(LocalizedStringKey(string)).
  3. .full parsing without PresentationIntent handling — You get the data but nothing renders differently unless you interpret it.
  4. Forgetting custom scope in parsingAttributedString(markdown:) without including: ignores custom ^[](key: value) attributes.
  5. Assuming PresentationIntent = visual rendering — It's structural data, not rendering instructions. You must map it to visual attributes yourself.

Related Skills

  • Use /skill apple-text-attributed-string for attribute-model and conversion choices.
  • Use /skill apple-text-swiftui-bridging when the real gap is SwiftUI rendering limits.
  • Use /skill apple-text-parsing when the problem is parser choice more than Markdown rendering semantics.