skills/txt-markdown/SKILL.md
Render and parse Markdown in SwiftUI Text and AttributedString — inline syntax that works, block-level syntax that doesn't, PresentationIntent interpretation, and custom `^[text](key:value)` attributes via MarkdownDecodableAttributedStringKey. Use when Markdown isn't rendering as expected, headings/lists are silently dropped, a `String` variable shows literal asterisks, you're choosing between native parsing and a third-party renderer, or you need block-level formatting in UITextView. Do NOT use for the AttributedString-vs-NSAttributedString decision in general — see txt-attributed-string. Do NOT use for parser/regex mechanics on non-Markdown text — see txt-regex.
npx skillsauth add sitapix/apple-text txt-markdownInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Authored against iOS 26.x / Swift 6.x / Xcode 26.x.
This skill covers what Markdown actually does inside Apple's text APIs — which syntax SwiftUI Text renders, which it silently ignores, how AttributedString(markdown:) parses block structure into presentationIntent, and what it takes to render that block structure in TextKit. The patterns here describe how Markdown rendering usually fails; before assuming a feature is missing, check the actual interpretedSyntax option on the call site and verify whether the destination view is SwiftUI Text or a TextKit-backed view, since those have very different rendering surfaces.
The rule that catches every team: SwiftUI Text renders inline Markdown (bold, italic, code, links, strikethrough) and silently drops everything else. There is no error, no warning, no fallback rendering — headings render as plain text, lists render as plain lines with literal - characters, code blocks render as inline-code spans without block formatting. Block-level Markdown requires either a TextKit view interpreting presentationIntent or a third-party SwiftUI renderer.
A string literal passed to Text(_:) is a LocalizedStringKey, and LocalizedStringKey parses inline Markdown automatically:
Text("**Bold** and *italic* and `code`")
Text("~~Strikethrough~~ and [Link](https://apple.com)")
Text("***Bold italic*** together")
The inline syntax that renders:
| Syntax | Renders as |
|--------|-----------|
| **bold**, __bold__ | Bold |
| *italic*, _italic_ | Italic |
| ***bold italic*** | Bold + italic |
| `code` | Monospaced inline span |
| ~~strikethrough~~ | Strikethrough |
| [text](url) | Tappable link in accent color |
The block-level syntax that does not render in SwiftUI Text: headings (# Heading), unordered lists (- item), ordered lists (1. item), block quotes (> quote), fenced code blocks, images (), tables, horizontal rules, task lists (- [ ]). They are silently dropped — heading characters are stripped, list bullets become literal - characters, code fences appear as inline-code spans.
The most common "Markdown stopped working" bug is feeding a String variable to Text instead of a literal:
// String literal — Text(_:) takes LocalizedStringKey, Markdown renders
Text("**bold** text")
// String variable — Text(_:) takes a String overload, Markdown does NOT render
let text: String = "**bold** text"
Text(text) // displays literal asterisks
// Force Markdown on a String variable
Text(LocalizedStringKey(text))
// Disable Markdown on a literal
Text(verbatim: "**not bold**")
For attributed text, parse to an AttributedString first and pass that:
let str = try AttributedString(
markdown: userMessage,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
)
Text(str)
Text(_: AttributedString) is the third overload and renders all attributes the SwiftUI compatibility scope understands.
AttributedString(markdown:) runs Apple's parser and returns an attributed string with attributes set per the matching syntax. The interpretedSyntax option controls how aggressive the parser is:
| Option | Parses | Whitespace | Best for |
|--------|--------|-----------|---------|
| .inlineOnly | Inline only — bold, italic, code, links, strikethrough | Collapsed (Markdown rules) | Simple formatted strings |
| .inlineOnlyPreservingWhitespace | Inline only | Preserved verbatim | Chat messages, multi-line user input |
| .full | Full Markdown — inline plus block-level | Markdown rules | Documents, articles, reading content |
.inlineOnlyPreservingWhitespace is usually the right choice for short user-generated content because it doesn't fold runs of whitespace or strip newlines. .full enables block parsing but requires the destination view to interpret presentationIntent — passing a .full-parsed AttributedString to SwiftUI Text gives the same inline-only rendering as .inlineOnly, just with extra metadata stored that nothing reads.
let inline = try AttributedString(
markdown: "Visit [Apple](https://apple.com) for **details**",
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
)
let document = try AttributedString(
markdown: rawDocumentMarkdown,
options: .init(interpretedSyntax: .full)
)
Block-level structure parsed by .full is stored in the presentationIntent attribute on each run. It is structural metadata, not visual instructions — presentationIntent describes "this run is part of a level-2 heading inside a block quote," and rendering is left to the destination.
for run in document.runs {
guard let intent = run.presentationIntent else { continue }
for component in intent.components {
switch component.kind {
case .header(let level): break // level 1-6
case .unorderedList: break
case .orderedList: break
case .listItem(let ordinal): break
case .blockQuote: break
case .codeBlock(let lang): break // language hint or nil
case .paragraph: break
case .table: break
case .tableHeaderRow: break
case .tableRow(let index): break
case .tableCell(let column): break
case .thematicBreak: break // horizontal rule
@unknown default: break
}
}
}
A run can carry multiple intents — a paragraph inside a list item inside a block quote produces three components on each run in that paragraph. The components are ordered outermost-first.
To render block-level Markdown in a TextKit view, walk the runs, read presentationIntent, and translate each kind into the corresponding NSAttributedString attributes (paragraph style for indentation/spacing, font for headings, background color for code blocks). The translation isn't built in — Apple parses but doesn't render block structure outside SwiftUI's own document-rendering surfaces.
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 size = sizes[level] ?? 16
mutable.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: size), 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
}
This is the minimal version. Lists with bullets need text-list configuration; nested lists need indent multiplied by depth; code blocks need padding via line layout — all of which is the work the third-party renderers do for you.
Apple's Markdown parser supports a custom inline syntax: ^[text](key1: value1, key2: value2). Custom attribute keys that conform to MarkdownDecodableAttributedStringKey are populated automatically when the parser encounters their name.
enum HighlightAttribute: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey {
typealias Value = Bool
static let name = "highlight"
}
enum ColorNameAttribute: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey {
typealias Value = String
static let name = "color"
}
extension AttributeScopes {
struct MyMarkdownAttributes: AttributeScope {
let highlight: HighlightAttribute
let color: ColorNameAttribute
let foundation: FoundationAttributes
let swiftUI: SwiftUIAttributes
}
var myMarkdown: MyMarkdownAttributes.Type { MyMarkdownAttributes.self }
}
extension AttributeDynamicLookup {
subscript<T: AttributedStringKey>(
dynamicMember keyPath: KeyPath<AttributeScopes.MyMarkdownAttributes, T>
) -> T { self[T.self] }
}
let str = try AttributedString(
markdown: "Read ^[this](highlight: true, color: 'blue') carefully",
including: \.myMarkdown
)
Without including: \.myMarkdown, the parser ignores ^[...](...) syntax and the inline text renders without the custom attributes. The scope is required at parse time, not just at render time.
The general protocol — defining a custom AttributedStringKey, building a scope, extending AttributeDynamicLookup — is covered in txt-attributed-string. The Markdown-specific bit is MarkdownDecodableAttributedStringKey conformance, which gives the parser permission to populate the key from ^[...](key: value) syntax.
Native parsing (AttributedString(markdown:) plus Text or a TextKit translator) is dependency-free, type-safe, Codable-friendly, and localization-aware. Its weakness is rendering: SwiftUI Text doesn't render block structure, and writing the TextKit translator is real work for every project.
Third-party libraries solve different problems:
A practical decision rule: simple bold/italic/links in SwiftUI is native Text territory; a full reading view with images and code blocks is MarkdownUI territory; an editor with live syntax highlighting is a TextKit view with parsed presentationIntent plus custom attribute application.
Expecting SwiftUI Text to render headings or lists. Inline Markdown only. The fix is either an inline-only data model (no headings expected) or switch to MarkdownUI / a TextKit-rendered view. There is no Text-only escape hatch.
Markdown not rendering on a String variable. Text(_:) has overloads for both LocalizedStringKey and String; Swift picks String for a variable, which doesn't parse Markdown.
// WRONG — picks String overload, displays literal asterisks
let body: String = userInput
Text(body)
// CORRECT — force LocalizedStringKey
Text(LocalizedStringKey(body))
// CORRECT — parse to AttributedString first
Text(try AttributedString(markdown: body, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)))
Parsing with .full and expecting block rendering. .full populates presentationIntent but doesn't render block structure visually. Without an interpreter, the result in SwiftUI Text is identical to .inlineOnly. The visual difference appears only when something walks the runs and applies paragraph styles.
Forgetting including: \.myMarkdown for custom attributes. The parser doesn't know about your scope unless you tell it at parse time. Custom ^[text](...) syntax without the matching scope is silently ignored — no error, no warning.
Treating presentationIntent as a per-paragraph attribute. It is per-run. A single paragraph with a bold word in the middle is three runs, all carrying the paragraph's intent. When applying paragraph styles, take the union of intents across the paragraph or apply on a per-paragraph basis derived from intent boundaries.
Whitespace folding eating user content. .inlineOnly collapses runs of whitespace per Markdown rules, which is wrong for chat-style content with intentional line breaks. Use .inlineOnlyPreservingWhitespace for any user-typed string.
Unhandled @unknown default on intent kind. PresentationIntent.Kind is a non-frozen enum; future Foundation releases can add cases. A switch over the kinds needs @unknown default to compile cleanly under -Wexhaustive-switch.
txt-attributed-string — choosing between AttributedString and NSAttributedString, custom attribute keys and scopestxt-attribute-keys — full catalog of NSAttributedString.Key values applied during PresentationIntent renderingtxt-swiftui-interop — what survives the SwiftUI/TextKit boundarytools
Integrate Writing Tools into UITextView, NSTextView, custom UITextInput views, or fully custom editors via UIWritingToolsCoordinator. Configure writingToolsBehavior and allowedWritingToolsResultOptions, declare protected ranges via writingToolsIgnoredRangesInEnclosingRange, gate edits with isWritingToolsActive, and pause syncing in willBegin/didEnd. Trigger on 'Apple Intelligence rewrite', 'AI summarize selection', 'compose with AI', 'why won't Writing Tools appear', or 'rewrite is breaking my code blocks' even without UIWritingToolsCoordinator named. Use when Writing Tools is missing from the menu, only the panel mode appears, rewrites corrupt code blocks, the inline animation isn't running, or a custom text engine needs to adopt UIWritingToolsCoordinator. Do NOT use for diagnosing general TextKit 1 fallback symptoms — see txt-fallback-triggers.
tools
Wrap UITextView (UIViewRepresentable) and NSTextView (NSViewRepresentable) inside SwiftUI without breaking editing. Covers binding sync, infinite-update-loop guards, cursor preservation across programmatic mutations, focus / first-responder bridging, auto-sizing strategies, environment value propagation, toolbar integration, and the iOS vs macOS scroll-view differences. Use when building or debugging a SwiftUI text-view wrapper, when cursor jumps after typing, when binding updates don't propagate, when @FocusState seems ignored, or when a wrapped editor won't size to its content. Do NOT use for picking which view class (txt-view-picker) or for which AttributedString attributes survive the SwiftUI boundary (txt-swiftui-interop).
tools
Configure TextKit 2 viewport-driven layout, NSTextLayoutFragment / NSTextLineFragment geometry, and rendering attributes vs storage attributes. Covers NSTextViewportLayoutController callbacks, layoutFragmentFrame vs renderingSurfaceBounds, line-fragment local coordinates, the extra trailing line fragment, exclusion paths that split a visual line, lineFragmentPadding vs container insets, font substitution via fixAttributes, and visible/overscroll/estimated regions. Use when working with custom layout fragments, debugging clipped diacritics or descenders, computing document coordinates from a line fragment, integrating with a custom scroll view, or when scroll-bar behavior under estimated heights is the visible problem. Do NOT use for symptom-driven debugging (txt-textkit-debug), the invalidation model (txt-layout-invalidation), or the TextKit 2 API surface in general (txt-textkit2).
development
Choose between SwiftUI Text/TextField/TextEditor, UIKit UITextView, and AppKit NSTextView. Capability comparison, tradeoffs, and decision criteria for read-only display vs single-line input vs multi-line editing vs rich attributed editing vs TextKit access. Use when the user asks "which text view should I use," "should I use TextField or TextEditor," "do I need UITextView for this," or describes a feature without naming a view class. Do NOT use for wrapping UITextView in SwiftUI — see txt-wrap-textview. Do NOT use for SwiftUI/TextKit attribute compatibility rules — see txt-swiftui-interop. Do NOT use for the iOS 26 SwiftUI TextEditor rich-text APIs themselves — see txt-swiftui-texteditor.