Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

3 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Swift Markdownify

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.

Features

  • 🌐 JavaScript-aware β€” loads pages in a hidden WKWebView and 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 ![alt](src) (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), or never
  • ⏱️ Configurable load timeout and optional User-Agent override
  • 🧱 Typed errors β€” MarkdownifyError with LocalizedError descriptions
  • πŸ“¦ Bundled JS β€” Readability, Turndown, and the GFM plugin ship inside the package

Requirements

  • macOS 13.0+ / iOS 16.0+
  • Swift 6.0+
  • Xcode 26.0+

Installation

Swift Package Manager

dependencies: [
    .package(url: "https://github.com/arraypress/swift-markdownify.git", from: "1.0.0")
]

Usage

Converting a URL

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 ?? "β€”")")

Converting pre-fetched HTML

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)

Configuration

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)

Error handling

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)")
    }
}

Waiting for client-rendered content

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 = 2

The 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 page

An 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.

Failing loudly

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 check

minimumContentLength 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.

How It Works

  1. The URL is loaded into a hidden WKWebView (or HTML is rendered via loadHTMLString).
  2. After the main frame finishes loading, Mozilla Readability extracts the article content β€” unless Configuration.readability is .never.
  3. 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:).

Models

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

Use Cases

  • 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

Testing

swift test

Tests run conversions against bundled HTML fixtures.

License

MIT License β€” see LICENSE file for details.

Author

Created by David Sherlock (ArrayPress) in 2026.

About

Convert webpages and HTML to clean Markdown in Swift using Readability + Turndown.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages