Convert webpages and HTML strings to clean Markdown. Markdownify runs Mozilla Readability for article extraction and Turndown for HTML-to-Markdown conversion inside a hidden WKWebView, so JavaScript-rendered pages are converted from their fully-rendered DOM β not the raw source.
- π JavaScript-aware β loads pages in a hidden
WKWebViewand converts the rendered DOM, so client-side-rendered content works - π° Readability extraction β Mozilla Readability isolates the main article from navigation, ads, and chrome
- π HTML or URL input β convert a remote URL, or pre-fetched HTML via
convert(html:baseURL:) - π GitHub-Flavored Markdown β Turndown with the GFM plugin (tables, strikethrough, task lists), toggleable
- π Front matter β optional YAML or TOML front matter prepended to the output
- πΌοΈ Image handling β keep remote
(absolutized) or strip images entirely - π Link control β preserve or drop
<a>links - π·οΈ Rich metadata β returns title, byline, site name, language, published time, excerpt, and length
- βοΈ Readability strategies β
auto(fallback),always(throw on failure), ornever - β±οΈ Configurable load timeout and optional User-Agent override
- π§± Typed errors β
MarkdownifyErrorwithLocalizedErrordescriptions - π¦ Bundled JS β Readability, Turndown, and the GFM plugin ship inside the package
- macOS 13.0+ / iOS 16.0+
- Swift 6.0+
- Xcode 26.0+
dependencies: [
.package(url: "https://github.com/arraypress/swift-markdownify.git", from: "1.0.0")
]import Markdownify
let url = URL(string: "https://en.wikipedia.org/wiki/Markdown")!
let doc = try await Markdownify.convert(url: url)
print(doc.markdown)
print("Title: \(doc.title ?? "β")")
print("Site: \(doc.siteName ?? "β")")let html = "<article><h1>Hello</h1><p>World</p></article>"
let doc = try await Markdownify.convert(html: html, baseURL: URL(string: "https://example.com"))
print(doc.markdown)var config = Markdownify.Configuration()
config.readability = .auto // .auto | .always | .never
config.frontMatter = .yaml // .none | .yaml | .toml
config.gfm = true // GitHub-Flavored Markdown extensions
config.imageHandling = .keepRemote // .keepRemote | .strip
config.preserveLinks = true
config.includeTitle = true
config.loadTimeout = 30
config.userAgent = nil
let doc = try await Markdownify.convert(url: url, config: config)
print(doc.markdown)do {
let doc = try await Markdownify.convert(url: url)
print(doc.markdown)
} catch let error as MarkdownifyError {
switch error {
case .invalidURL(let s): print("Invalid URL: \(s)")
case .loadFailed(let s): print("Load failed: \(s)")
case .timeout: print("Timed out")
case .missingResource(let s): print("Missing bundled resource: \(s)")
case .javaScriptFailed(let s): print("JS error: \(s)")
case .invalidResponse(let s): print("Bad bridge response: \(s)")
case .readabilityFailed(let s): print("Readability failed: \(s)")
}
}WKWebView reports didFinish when the initial document has loaded β which for a single-page app is before any content exists. Measured on reddit.com, the body held 0 characters at didFinish and 26,915 three seconds later. Converting at that moment yields an empty shell and reports success.
So after loading, the body's text length is sampled until it stops changing:
var config = Markdownify.Configuration()
config.waitForContent = true // default
config.settleTimeout = 10 // ceiling, not a delay
config.settleInterval = 0.25
config.settleStableSamples = 2The first sample is taken immediately, so a server-rendered page costs one interval (~0.25s), not the timeout. Turn it off for static HTML you control:
config.waitForContent = false // ~0.3s instead of ~0.57s on a large pageAn empty body is never treated as settled β it's usually the shell awaiting replacement β but it can't block forever either, so a page still blank after 1.5s is accepted as genuinely empty.
Three checks exist because each has a way of returning confident nonsense:
| Situation | Without the check | Now |
|---|---|---|
| Server returns 404 | A tidy document titled "404 Not Found" | httpError(status:url:) |
| URL is a PDF or JSON | 1 char, or 42 KB of converted JSON | unsupportedContentType(_:) |
| Extraction finds nothing | An 11-character "success" | emptyResult(url:) |
config.allowHTTPErrors = true // convert error pages anyway
config.allowNonHTML = true // attempt non-HTML responses
config.minimumContentLength = 0 // disable the empty checkminimumContentLength measures the body only β front matter and the title heading are generated from metadata, so a page yielding just its own title has converted nothing.
- The URL is loaded into a hidden
WKWebView(or HTML is rendered vialoadHTMLString). - After the main frame finishes loading, Mozilla Readability extracts the article content β unless
Configuration.readabilityis.never. - The extracted HTML is fed to Turndown (GFM enabled by default) and the resulting Markdown is returned alongside the detected metadata.
Because WKWebView is main-thread-only, both convert entry points are annotated @MainActor. Pages requiring authentication or cookies can't be converted from a plain convert(url:) call β fetch the HTML yourself and pass it to convert(html:baseURL:).
| Type | Description |
|---|---|
MarkdownDocument |
Result: markdown body plus title, byline, siteName, language, publishedTime, excerpt, sourceURL, length |
Markdownify.Configuration |
Tunables: readability, frontMatter, preserveLinks, imageHandling, gfm, includeTitle, loadTimeout, userAgent |
MarkdownifyError |
Typed conversion errors with LocalizedError descriptions |
- Saving web articles to a Markdown notes system
- Feeding clean article text to LLMs or search indexes
- Building read-it-later or clipping tools
- Archiving pages as portable Markdown
swift testTests run conversions against bundled HTML fixtures.
MIT License β see LICENSE file for details.
Created by David Sherlock (ArrayPress) in 2026.