Markdown toolkit for Swift and SwiftUI
Written in Swift 6 for Apple stuff:
Build with Xcode 16 or newer. Command-line interface depends on Swift Argument Parser.
DownerUI.EditorView
is a WYSIWYG Markdown editor for iOS/iPadOS and macOS apps. It's like if SwiftUI TextEditor
edited plain Markdown/HTML strings instead of attributed strings.
Please, god, don't use EditorView
for anything "real" yet. I have enumerated the ways it is not ready for production:
- Editor content complete styling control with CSS, configurable in editor public interface
- Provided default editor stylesheet with system look-and-feel and applying dynamic system margins, usable as template for custom stylesheet
-
@Observable
state and functions for document focus and selection #1
- Dynamic Type for editor document content via named system CSS values
- Editor content respect light/dark environment appearance via CSS media query
- Editor content respect high-contrast environment via CSS media query #14
- VoiceOver operation for all editor actions and content #5 #6
- Create link with URL/unlink (menu
cmd+k
) #2 - Insert image with URL
- Insert image picked from files/photos/camera (if camera) #3 #4
- Toggle bold (menu
cmd+b
) - Toggle italic (menu
cmd+i
) - Toggle strikethrough
- Toggle inline code #8
- Toggle numbered/ordered list
- Toggle bulleted list
- Insert table (UI TBD: adjustable if possible, pre-sized otherwise) #13 👀
- Insert pre-formatted code block #8
- Insert checkbox/checklist item #12
- Toggle block quote #11
- Insert heading (1-6) #10
- Insert ruled theme break #9
- Insert line break #9
import SwiftUI
import DownerUI
import Downer
@main
struct App: SwiftUI.App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
@State private var editor: Editor = Editor(document, baseURL: baseURL)
var body: some View {
EditorView("Placeholder")
.editorToolbar()
.environment(editor)
.onChange(of: editor.document) {
print(editor.document)
}
}
}
#Preview("Content View") {
ContentView()
}
private let baseURL: URL? = URL(string: "https://toddheasley.github.io")
private let document: Downer.Document = """
# [Think different](https://apple.com)
## Here’s to the crazy ones.
1. The misfits
2. The rebels
3. The troublemakers
The round pegs in the square holes. The ones who see things differently. They’re not fond of rules. And they have no respect for the status quo. You can `quote` them, disagree with them, glorify or vilify them. About the only thing you can’t do is ignore them. Because they change things.
__They push the human race forward__.
And while some may see them as the crazy ones, we see genius. Because the people who are crazy enough to think they can change the world, are the ones who do.
![](favicon.ico)
"""
EditorView
is my own design, so far, but I cribbed a lot of the "how" from the super complete, functionally adjacent MarkupEditor by Steve Harris.
File-based processing for Mac workflows
Downer
package includes downer-cli
, an executable target for processing individual Markdown files. Given a path to any text file, downer-cli
creates both HTML and formatted Markdown versions, preserving the source file:
toddheasley % ls
README.md
toddheasley % ./downer-cli README.md
Saved: README~.md
Saved: README.html
toddheasley % ls
README.html README.md README~.md
Use --convert
to convert HTML in source file to Markdown syntax when possible:
toddheasley % ./downer-cli README.md -c
Use --replace
to overwrite the source file:
toddheasley % ls
README.md
toddheasley % ./downer-cli README.md -r
Saved: README.md
Saved: README.html
toddheasley % ls
README.html README.md
Use --format
to generate only one format or the other:
toddheasley % ls
README.md
toddheasley % ./downer-cli README.md -f hypertext
Saved: README.html
toddheasley % ls
README.html README.md
Downer
parses GitHub Flavored Markdown using cmark-gfm. It mostly behaves like you'd expect, but it's weird and opinionated about a handful of things that I'm weird and opinionated about. Specifically:
- List items are leaf blocks. When list items contain multiple child blocks, only the inline content from the first child block is selected. Additional blocks are discarded.
- Link reference definitions are ignored and discarded during parsing.
- Hypertext format renders strong emphasis with HTML
<b>
tags, instead of<strong>
. - Basic support for Flavored autolinks is included but disabled by default.
Other Flavored extensions for strikethrough, tables and task lists are fully supported.
Downer
adopts the same document structure and element naming conventions as its underlying parser, Swift Markdown. Elements are re-parsed into self-rendering, concrete types:
import Downer
let document: Document = """
[Think different](https://apple.com)
===
Here's to the crazy ones.
-------------------------
1. The misfits
1. The rebels
1. The troublemakers
The round pegs in the square holes. The ones who see things differently. They're not fond of rules. And they have no respect for the status quo. You can `quote` them, disagree with them, glorify or vilify them. About the only thing you can't do is ignore them. Because they change things.
**They push the human race forward**.
And while some may see them as the crazy ones, we see genius. Because the people who are crazy enough to think they can change the world, are the ones who do.
"""
print(document.elements.first) // # [Think different](https://www.apple.com)
print(document.elements.count) // 6
Document
has two rendering modes, Format.hypertext
and .markdown
:
print(document.description(.hypertext))
<h1><a href="https://apple.com">Think different</a></h1>
<h2>Here’s to the crazy ones.</h2>
<ol>
<li>The misfits</li>
<li>The rebels</li>
<li>The troublemakers</li>
</ol>
<p>The round pegs in the square holes. The ones who see things differently. They’re not fond of rules. And they have no respect for the status quo. You can <code>quote</code> them, disagree with them, glorify or vilify them. About the only thing you can’t do is ignore them. Because they change things.</p>
<p><b>They push the human race forward</b>.</p>
<p>And while some may see them as the crazy ones, we see genius. Because the people who are crazy enough to think they can change the world, are the ones who do.</p>
Markdown renders by default:
print(document)
# [Think different](https://apple.com)
## Here’s to the crazy ones.
1. The misfits
2. The rebels
3. The troublemakers
The round pegs in the square holes. The ones who see things differently. They’re not fond of rules. And they have no respect for the status quo. You can `quote` them, disagree with them, glorify or vilify them. About the only thing you can’t do is ignore them. Because they change things.
__They push the human race forward__.
And while some may see them as the crazy ones, we see genius. Because the people who are crazy enough to think they can change the world, are the ones who do.
Autolink.link
and .email
are the two included autolinking rules. They cover only the least fuzzy cases where plain-text web and email addresses are prefixed with http:
, https:
or mailto:
protocols.
Autolink at your own risk:
extension Autolink: CaseIterable {
static let fileLink: Self = Self("File", "(?<!\")(file:\\/\\/\\/)([\\w\\-\\.!~?&+\\*'\"(),\\/]+)", "<a href=\"$1$2\">$2</a>")
static let allCases: [Self] = [.link, .fileLink]
}
let html: String = document.description(.hypertext(Autolink.allCases + [
Autolink("Path", "(^|\\s)/([\\w\\-\\.!~#?&=+\\*'\"(),\\/]+)", "$1<a href=\"$2\">$2</a>")
]))