MarkdownKit is a high-performance native Markdown renderer for Apple platforms, built in Swift with swift-markdown and TextKit-based layout.
- CommonMark + GitHub Flavored Markdown (tables, task lists, strikethrough, links)
- Native table rendering (
NSTextTable) with GitHub-like styling - Math support (
$...$,$$...$$, and fencedmath) via MathJaxSwift - Collapsed sections support (
<details>/<summary>) - Diagram fence detection (
mermaid,geojson,topojson,stl) with pluggable adapter fallback - Async layout pipeline and virtualized iOS/macOS collection views
- Swift 6.2+
- iOS 17.0+
- macOS 26.0+
swift build
swift test
swift run MarkdownKitDemoimport MarkdownKit
let parser = MarkdownKitEngine.makeParser()
let solver = MarkdownKitEngine.makeLayoutSolver()
let document = parser.parse("# Hello MarkdownKit")
let layout = await solver.solve(node: document, constrainedToWidth: 800)
print(layout.children.count)parser.parse(_:) is a lossy compatibility convenience: it logs diagnostics and falls back to
an empty (or partially-truncated) document instead of surfacing rejection. Hosts that parse
untrusted or unbounded content should use parser.parseOutcome(_:) instead — see
Parser Resource Limits & Typed Outcomes below.
import MarkdownKit
let layout = await MarkdownKitEngine.layout(
markdown: "# Hello\n\nThis is **MarkdownKit**.",
constrainedToWidth: 800
)
print(layout.children.count)Direct layout APIs use a deterministic .light appearance by default. Pass
appearance: .dark to makeLayoutSolver or MarkdownKitEngine.layout for
dark output. SwiftUI MarkdownView follows the environment colorScheme
automatically.
SwiftUI hosts should import both MarkdownKit and SwiftUI; MarkdownKit does not re-export
SwiftUI. Syntax highlighting is an implementation detail and Splash is not re-exported.
Markdown images are inline content. During layout, ImageAttachmentBuilder asks the unified
ImageResourceLoader to load an allowed source, decodes a width-constrained thumbnail, and
inserts an NSTextAttachment. Rejected or undecodable sources render as bracketed,
secondary-color alt text. MarkdownKit does not expose a separate top-level/block-image path.
Image I/O is opt-in:
.defaultand.disableddeny all image I/O..remoteHTTPSallows HTTPS sources only and rejects disallowed redirects before following them..trustedallows local/relative paths plus HTTP and HTTPS; use it only for trusted content.
Configure the policy on the layout/render surface:
import MarkdownKit
import SwiftUI
let solver = MarkdownKitEngine.makeLayoutSolver(imageLoadingPolicy: .remoteHTTPS)
let layout = await MarkdownKitEngine.layout(
markdown: "Logo: ",
constrainedToWidth: 800,
solver: solver
)
let view = MarkdownView(
text: "Logo: ",
imageLoadingPolicy: .remoteHTTPS
)imageLoadingPolicy is a layout input. Changing it relayouts the document and rebuilds inline
attachments; it does not enable visible-cell image loading. Remote response bodies are streamed
and canceled once maximumResponseBytes would be exceeded.
MarkdownParser uses a per-instance ResourceLimits policy to bound accepted input size and
recursive native-AST mapping work. The default policy
(MarkdownParser.ResourceLimits.default) is:
maximumInputBytes: 1,048,576 UTF-8 bytes (1 MiB), inclusive — input whose UTF-8 byte count equals the limit is accepted; only strictly larger input is rejected.maximumNestingDepth: 50 — the maximum retained container nesting beneath the rootDocument. The root is not counted; at the boundary, the container remains while its descendants are omitted. This is not aswift-markdownfront-end parser limit and not a layout/rendering depth limit.
For untrusted or unbounded input, use the synchronous, non-logging parseOutcome(_:) API and
inspect its diagnostics directly instead of relying on the lossy parse(_:) convenience:
import MarkdownKit
let parser = MarkdownParser(limits: .init(maximumInputBytes: 2_000_000, maximumNestingDepth: 80))
switch parser.parseOutcome(untrustedMarkdown) {
case .parsed(let document, let diagnostics):
// `diagnostics` may report a truncated subtree even though parsing succeeded.
let layout = await solver.solve(node: document, constrainedToWidth: 800)
case .rejected(let diagnostic):
// Input exceeded `maximumInputBytes` before any swift-markdown parsing occurred.
handle(diagnostic)
}MarkdownParser itself is synchronous and not Sendable (its plugins need not be Sendable).
Construct task-confined parser/plugin instances rather than sharing one across concurrent
tasks; host call sites decide whether to invoke it off the main actor.
MarkdownView funnels updates through @MainActor MarkdownEngine (UI/SwiftUI/MarkdownRenderCoordinator.swift):
- At most one detached parse/layout task is active, plus one latest pending request.
- Every new request immediately invalidates older generations; debounced updates wait 200ms before submit.
- Publication is generation-guarded (
output.generation == latestGeneration), so stale completions never replace current layouts. - Raw AST reuse happens only when
MarkdownParseKeyis unchanged (text,resourceLimits, ordered plugin fingerprint). - Layout-only dimensions (
width,theme,appearance,diagramRegistry,imageLoadingPolicy) reuse the raw AST and relayout only. - Details disclosure is reapplied as an override onto the latest configuration before layout, preventing stale-config regressions.
MarkdownKitEngine.makeParser(autolinkResolver:includeGitHubAutolinks:) and
GitHubAutolinkPlugin(resolver:) accept an optional MarkdownAutolinkResolver:
final class ImmutableAutolinkResolver: MarkdownAutolinkResolver {
let ownerRepo: String
init(ownerRepo: String) {
self.ownerRepo = ownerRepo
}
func resolveMention(username: String) -> URL? {
URL(string: "https://github.com/\(username)")
}
func resolveReference(reference: String) -> URL? {
guard reference.hasPrefix("#") else {
return URL(string: "https://github.com/\(reference)")
}
return URL(string: "https://github.com/\(ownerRepo)/issues/\(reference.dropFirst())")
}
func resolveCommit(sha: String) -> URL? {
URL(string: "https://github.com/\(ownerRepo)/commit/\(sha)")
}
func cacheFingerprint(into hasher: inout Hasher) {
hasher.combine(String(reflecting: Self.self))
hasher.combine(ownerRepo)
}
}
let parser = MarkdownKitEngine.makeParser(
autolinkResolver: ImmutableAutolinkResolver(ownerRepo: "apple/swift"),
includeGitHubAutolinks: true
)Guidance:
GitHubAutolinkPluginstrongly retains its resolver. Use a dedicated resolver object and avoid a cycle in which it also retains the parser/plugin graph.- Resolver methods are synchronous and may run off-main during detached render work, so resolver state must be immutable or explicitly synchronized. A main-actor UI model should not conform directly.
- Include all output-affecting resolver configuration in
cacheFingerprint(into:)so SwiftUI render identity invalidates when resolver behavior changes. - UI interactions (link taps, checkbox toggles, details disclosure) remain view-owned via closures like
onLinkTapandonCheckboxToggle. - The deprecated
MarkdownContextDelegatename andcontextDelegate:labels remain migration shims, but conformers must satisfy the newSendablecontract.
Fast regression gate (recommended for daily iteration, correctness-only in every environment):
bash scripts/verify_fast.shPlatform public API baselines:
bash scripts/verify_public_api.sh --platform macos --check
bash scripts/verify_public_api.sh --platform ios-simulator --checkAfter an intentional, reviewed public API change or an approved toolchain update, regenerate the matching baseline explicitly:
bash scripts/verify_public_api.sh --platform macos --record
bash scripts/verify_public_api.sh --platform ios-simulator --recordPublicAPISmokeTests remains the fast normal-import compile and behavior contract for a consumer.
The committed symbol-graph baselines in API/PublicAPI/ contain every source-declared public
symbol plus its compiler-emitted public relationships, including protocol requirements,
extensions, overloads, availability, inherited platform conformances, and platform-conditional
APIs. The iOS gate verifies both arm64 and x86_64 Simulator graphs against one
architecture-neutral baseline. Recording is intentionally explicit and pinned to Xcode 26.4.1;
review baseline diffs rather than treating --record as normal verification.
Strict documentation freshness gate:
bash scripts/check_doc_freshness.shRelease metadata / provenance gate:
bash scripts/verify_provenance.shThe wrapper first resolves the package graph from Package.swift, then runs the
offline Python verifier against Package.resolved, checked-in legal files, and
vendored-resource policy anchors. See
docs/MERMAID_PROVENANCE.md for the separate,
networked Mermaid rebuild and inventory-refresh procedure.
Snapshot contracts (macOS only, two independent modes — see below):
bash scripts/verify_snapshots.sh --visual
bash scripts/verify_snapshots.sh --determinismBenchmark-only gate (heavier):
bash scripts/verify_benchmarks.shCombined wrapper (fast + optional heavy):
bash scripts/verify_all.shverify_all.sh always resolves dependencies and runs the provenance gate first, then checks both
platform API baselines. --full uses swift test instead of the fast correctness split.
Release owners should use the complete release procedure, not this convenience
wrapper alone.
Optional heavy benchmark suites:
bash scripts/verify_all.sh --with-benchmarksOne-shot full suite (includes all tests, including benchmarks/snapshots):
bash scripts/verify_all.sh --fulliOS Simulator correctness lane: verify_ios.sh creates a package-only workspace from
Package.swift, Package.resolved, Sources, and Tests, then runs the package's tests with
xcodebuild against an iOS Simulator matching the active Xcode iPhone Simulator SDK. The app-less
XCTest process runs the 10 Mermaid state-machine contracts with an explicitly injected
deterministic image backend, so it never constructs WKWebView. After all 746 XCTest tests pass,
the script assembles the SwiftPM demo executable into an ad-hoc-signed Simulator app and requires
exactly one PASS marker after a Mermaid fence travels through the public MarkdownView pipeline
and its registry-backed real WebKit adapter. The smoke is additional and is not counted as XCTest.
Set MARKDOWNKIT_IOS_SIMULATOR_UDID to explicitly override simulator selection:
bash scripts/verify_ios.shVerification is split into seven honestly-scoped contracts rather than one monolithic test run:
- Provenance gate (
verify_provenance.sh, all CI jobs): Resolves the manifest-derived package graph before invoking the read-only, offlineverify_provenance.pydrift check forPackage.resolved, the vendored Mermaid artifact, and checked-in third-party notice coverage. - Correctness gate (
verify_fast.sh, CI jobverify): Discovers everyXCTestCasesuite inMarkdownKitTestsviaswift test listand runs all of them except the benchmark suites and the two true snapshot suites (SnapshotTests,iOSSnapshotTests). It is correctness-only in every environment — it never records or verifies snapshots, locally or in CI — so newly added test classes are covered automatically instead of relying on a hand-maintained allow-list.DiagramSnapshotTestsis a deterministic suite and stays in this gate. - Public API graph baselines (
verify_public_api.sh, macOSverifyand iOSverify-iosCI jobs): SwiftPM builds andswift-symbolgraph-extractproduce every source-declared public symbol and its compiler-emitted public relationships for macOS 26.0 and iOS 17.0 Simulator. The iOS gate checks both simulator architectures.PublicAPISmokeTestsremains the complementary fast normal-import compile/behavior contract. Checks are read-only; recording a baseline is an intentional, reviewed Xcode 26.4.1 operation. - Documentation freshness gate (
check_doc_freshness.sh, CI jobverify): A strict, Bash 3.2-compatible, read-only check that the discoverable test count and generated benchmark docs match their sources. Runs after the correctness gate as its own explicit CI step. - Snapshot contracts (
verify_snapshots.sh, CI jobverify-snapshots): OwnsSnapshotTestsexclusively, split into two independent, honestly-labeled modes:--visualdiffs the current run against the committed baseline PNGs. Although Xcode is pinned, themacos-26runner's fonts and OS point releases can still move under us, so this is a genuine visual-regression signal but is non-blocking (continue-on-error: true) since environment drift alone can flip it.--determinismrecords fresh baselines and immediately re-verifies against them in the same run/environment, then restores the original snapshot directory. This proves the renderer is internally deterministic and is blocking.iOSSnapshotTestscurrently has no committed baseline or dedicated CI lane; it is intentionally excluded from bothverify_fast.shandverify_snapshots.shand should not be read as covered by either gate.
- iOS Simulator suite (
verify_ios.sh, CI jobverify-ios): Discovers the same correctness suites by scanning source (minus benchmarks and true snapshot suites), verifies the compiled iOS test bundle contains every UIKit-bearing suite, and runs exactly 746 tests viaxcodebuildon an SDK-matched iOS Simulator (unlessMARKDOWNKIT_IOS_SIMULATOR_UDIDexplicitly overrides it), with exact enumeration/execution validation, per-test timeouts, crash/restart detection, and a private system-font fallback check. Mermaid's FIFO/cache/cancellation contracts use an explicit deterministic image driver only in this app-less XCTest host. The same script then builds and packages the SwiftPM demo product, launches it with a realUIApplication, and requires exactly one PASS marker from a Mermaid fence rendered through publicMarkdownViewand a registry-backed real-WebKit adapter. - Benchmark suite (
verify_benchmarks.sh): Heavy performance regression tests. Run locally or through deliberately configured manual/scheduled automation; they are not part of PR CI. The gate first checks thatdocs/BENCHMARK_BASELINE.mdis up to date withTests/MarkdownKitTests/Fixtures/benchmark_baseline.json(the authoritative, machine-readable baseline consumed by both the docs andBenchmarkRegressionGuard) viapython3 scripts/render_benchmark_baseline.py --check, then builds the test bundle once in Release and launches 13 canonical isolated Release workloads in their own processes.BenchmarkPreparedContentTestsmeasures true-cold first solve, persistent width sweep, and a rebuild control whose fresh solvers are constructed outside timing; the permanent guard requires persistent avg and p95 <=60% of the rebuild control. After editing the baseline JSON, refresh the doc withpython3 scripts/render_benchmark_baseline.py. - Running bare
swift testexecutes everything including benchmarks and true snapshot suites. Preferverify_fast.shfor daily iteration.
Sources/MarkdownKit: core parser, AST nodes, plugins, layout engine, UI componentsSources/MarkdownKitDemo: demo appTests/MarkdownKitTests: unit/integration testsAPI/PublicAPI: committed macOS and iOS Simulator public symbol-graph baselinesThirdParty/: checked-in third-party licenses/notices andprovenance.lock.jsondocs/: PRD, feature notes, roadmap, and the release procedurescripts/: local automation and verification entrypointstasks/: implementation checklist
MarkdownKit is licensed under the MIT License. Third-party redistribution notices live in
THIRD_PARTY_NOTICES.md, and the machine-readable dependency/resource provenance lock lives at
ThirdParty/provenance.lock.json. See CHANGELOG.md for consumer-facing release
changes and migration notes.