A native Markdown editor for macOS (AppKit) and iOS / iPadOS (UIKit), built on TextKit 2 and bridged to SwiftUI. Formatting renders in place and the markers come back on the line you're editing, so the document reads like a rich-text page and stays plain CommonMark + GFM on disk. The bytes are never rewritten behind your back.
MarkdownKit is the editor of Notes my Way. It started as a fork of swift-markdown-engine and has since grown into a separate engine, with iOS support, editable tables with formulas and a formatting API (see Origins).
- Blend editing: headings, emphasis, lists, quotes, code, links and rules are styled in place; the markers fold away off the caret's line and come back on it
- Markdown source mode: every character visible, monospaced, with syntax colours (
renderingMode = .source) - WYSIWYG tables on both platforms: a grid with cell editing, Tab / Return navigation, hover markers to add
rows and columns, row and column handle menus, drag-to-resize columns (
{cols="…"}after the table) and "Edit as Markdown"; the file stays a pipe table - Table formulas:
[?](#SUM(B1:B4))cells computed live, with the syntax and functions of vitepress-plugin-markdownformula - Frontmatter shown as chips in formatted mode
- Images with
{…}attributes for size and alignment ({.small},{.icon},{width=320 .center}); the embedder supplies the bytes - Link preview cards:
[text](url){.preview}in four sizes, from metadata the embedder supplies - Checklists: tappable boxes (round or square, sized for touch on iOS), check / uncheck all, move checked items to the bottom
- Formatting API:
MarkdownEditorControllertoggles inline styles, sets block styles, indents, inserts tables and edits table cells, for toolbars, menus and keyboard bars - Find and replace: the macOS find bar and the iOS find interaction
- HTML export (
MarkdownHTML): the same parse as the editor, for printing and PDF - Theming: colours for text, headings, links, code, quotes, rules, tables, selection, caret and chips, heading fonts, and the source view's colours
- Code blocks with pluggable syntax highlighting, and LaTeX (block and inline) with a pluggable renderer
- File drops: dropped files go to an
onDropFileshook that stores them and returns the link to insert - Reading column, scroll-away header, comfortable bottom overscroll, spelling and grammar with code / LaTeX / link suppression, and Writing Tools on macOS 15.1+
dependencies: [
.package(url: "https://github.com/emdzej/markdownkit", branch: "main")
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "MarkdownEngine", package: "markdownkit"),
]
)
]Or in Xcode: File → Add Package Dependencies… and paste the repo URL.
The package ships three library products (they keep their MarkdownEngine names, so they don't clash
with other packages called MarkdownKit). Add only what you need:
| Product | Use when |
|---|---|
MarkdownEngine |
You want the editor only. No external dependencies. |
MarkdownEngineCodeBlocks |
You want code blocks with a background, a monospaced font and syntax highlighting without writing your own bridge. Pulls in HighlighterSwift. See Code Blocks. |
MarkdownEngineLatex |
You want LaTeX rendering without writing your own bridge. Pulls in SwiftMath. See LaTeX Rendering. |
import SwiftUI
import MarkdownEngine
struct EditorScreen: View {
@State private var text: String = "# Hello, *world*"
@State private var controller = MarkdownEditorController()
var body: some View {
NativeTextViewWrapper(text: $text, controller: controller)
.toolbar {
Button("Bold") { controller.toggle(.bold) }
Button("Checklist") { controller.setBlockStyle(.task) }
}
}
}The same NativeTextViewWrapper works on macOS and iOS. When several editors are on screen, pass a
stable documentId: to each so undo history stays scoped to its document.
The engine talks to your app through three service protocols, each with a no-op default so you only implement what you actually need:
| Protocol | What you supply | Ready-made bridge / suggested library |
|---|---|---|
ImageProvider |
Load the image for  |
(your asset store) |
LinkPreviewProvider |
Metadata for [text](url){.preview} cards |
LinkPresentation / QuickLookThumbnailing |
SyntaxHighlighter |
Highlight code blocks for a given language | HighlighterSwiftBridge (recommended) — built on HighlighterSwift |
LatexRenderer |
Render a LaTeX string to an NSImage |
SwiftMathBridge (recommended) — built on SwiftMath |
Implement what you need and pass it through MarkdownEditorServices:
struct VaultImages: ImageProvider {
let root: URL
func image(for request: ImageRequest) -> PlatformImage? {
PlatformImage(contentsOfFile: root.appending(path: request.source).path)
}
func fingerprint() -> AnyHashable { root }
}
configuration.services = MarkdownEditorServices(
images: VaultImages(root: vaultURL)
// syntaxHighlighter, latex omitted → no-op defaults
)Each protocol and its no-op default are documented in DocC.
Recommended path: depend on the MarkdownEngineCodeBlocks product
and use the bundled HighlighterSwiftBridge. Rolling your own
SyntaxHighlighter has subtle footguns the bridge already handles —
line-height metrics across light/dark themes, appearance-change
observation, layout-pass timing, font name extraction from the theme,
and CSS-theme-derived background colors. Use the bundle unless you
specifically need a non-HighlighterSwift library.
import MarkdownEngineCodeBlocks
var configuration = MarkdownEditorConfiguration.default
configuration.services = MarkdownEditorServices(
syntaxHighlighter: HighlighterSwiftBridge()
)The bridge auto-switches between atom-one-light and atom-one-dark
with system appearance. Different theme names or a pinned single theme
are configurable via init params — see DocC.
Need a different highlighter library entirely? Implement
SyntaxHighlighter yourself (see Service Protocols
above for the declaration) and reference the bundled bridge in
Sources/MarkdownEngineCodeBlocks/ as a working example.
Recommended path: depend on the MarkdownEngineLatex product and use
the bundled SwiftMathBridge. Hand-rolling a LatexRenderer has
real footguns the bridge already handles — appearance-aware text color,
zero-sized output guards (lockFocus crashes on 0×0 images),
window-vs-NSApp appearance distinction, single-letter padding, and an
internal cache keyed by (latex, font size, appearance, theme color).
import MarkdownEngineLatex
var configuration = MarkdownEditorConfiguration.default
configuration.services = MarkdownEditorServices(
latex: SwiftMathBridge()
)The bridge uses the Latin Modern math font and tints formulas with
MarkdownEditorTheme.latexLightModeText / latexDarkModeText. Pass
singleLetterPaddingBottom: to override the engine's matching default.
Every color the editor puts on screen reads from MarkdownEditorTheme:
var theme = MarkdownEditorTheme.default
theme.bodyText = .labelColor
theme.findMatchHighlight = NSColor(named: "MyAccent")!
var configuration = MarkdownEditorConfiguration.default
configuration.theme = themeDefaults map to NSColor dynamic system colors, so light/dark mode
keeps working without extra code.
MarkdownEditorConfiguration exposes every spacing / sizing / behavior
knob the engine has, grouped by concern:
var configuration = MarkdownEditorConfiguration.default
configuration.codeBlock.fontSizeScale = 0.9
configuration.headings.fontMultipliers = [2.4, 1.8, 1.4, 1.1, 0.9, 0.75]
configuration.overscroll.percent = 0.4
configuration.lists.helpersEnabled = false
configuration.safeAreaInsets = SafeAreaInsets(top: 56) // headroom under a translucent toolbarA {…} block written directly after an image or link (Pandoc syntax:
.class, #id, key=value) is parsed into MarkdownAttributes, hidden
while the caret is elsewhere, and shown dimmed when the caret is on it.
{.small} 160 pt max width (.medium 320, .large 640)
{.full} full column width
{width=320 .center} explicit points; .left / .center / .right
Built with {.icon} inline, at line height
 Obsidian width hint, also honoured
[Spec](spec.pdf){.preview} preview card, see belowImages keep their aspect ratio and never grow past the column; .small,
.medium and .large never upscale. The widths are in ImageStyle, and
ImageRequest.attributes lets your ImageProvider pick a suitably sized
thumbnail. Text that isn't valid attribute syntax, or a brace after a
space, stays ordinary text. MarkdownAttributes.setSize(_:) and
markdown() rewrite the block for a size menu.
A link on its own line with {.preview} or a size renders as a card:
.small is one line (icon, title, host), .medium (the default) adds a
thumbnail and description, .large / .full put a hero image on top.
With the caret on the link the markdown shows, with the card below it.
{.icon} puts the target's icon before the link text, anywhere in a
paragraph. Clicking a card goes to onLinkClick like any link.
The engine only draws. Supply the metadata with a LinkPreviewProvider
(LinkPresentation for web pages, QuickLook thumbnails for files, ...):
struct Previews: LinkPreviewProvider {
let store: PreviewStore // your fetcher + cache
func preview(for request: LinkPreviewRequest) -> LinkPreview? {
store.cached(request.destination) ?? { store.fetch(request.destination); return .loading }()
}
func fingerprint() -> AnyHashable { store.version } // bump when a fetch finishes
}
configuration.services.linkPreviews = Previews(store: store)Return nil to keep a plain link, .loading while fetching (the card shows
the link text and host), or .unavailable for a muted badge after the link.
Card widths are in configuration.linkPreviews. Link destinations reach
onLinkClick as written: relative paths and mailto: are no longer turned
into https:// URLs.
Pass onDropFiles to accept files dragged onto the editor (Finder, Files,
Photos, Mail, …). Store them wherever your app keeps attachments and return
the markdown to insert at the drop point; DroppedFile.markdownLink(to:)
builds  for images and [name](…) for everything else, with the path
percent-encoded. The engine inserts the result as one undoable edit. Return
nil to ignore the drop.
NativeTextViewWrapper(text: $text, onDropFiles: { files in
var links: [String] = []
for file in files {
let target = attachmentsFolder.appending(path: file.name)
try? FileManager.default.copyItem(at: file.url, to: target)
links.append(file.markdownLink(to: "_attachments/\(file.name)"))
}
return links.joined(separator: "\n")
})On iOS the DroppedFile URLs are temporary copies, deleted after the
handler returns — copy them, don't keep the URLs. Dragged text keeps the
system behaviour.
Give long documents a fixed-width, centered column — and let wide GFM tables break out of it to the full window width, Google-Docs-style:
var configuration = MarkdownEditorConfiguration.default
configuration.readingWidth = 650- Text wraps at
readingWidthand never re-wraps on window resize — only the column's position moves, which keeps live resize smooth. - Tables wider than the column expand up to the full viewport width and scroll horizontally beyond that.
- Leave it
nil(the default) and the editor fills its container edge-to-edge, exactly as before.
Host a SwiftUI view above the document body that scrolls away with it — document metadata, a property table, a contextual toolbar:
NativeTextViewWrapper(
text: $text,
header: AnyView(MyDocumentHeader(document: document)),
headerCollapsedHeight: 40,
headerExpanded: isHeaderExpanded
)- The engine hosts the view in an
NSHostingView, reserves its intrinsic height at the top of the scrolled content, and shifts the body below it. The header is a sibling of the text view, so it stays fully interactive (buttons, text fields, menus). headerExpanded: falsecollapses the band toheaderCollapsedHeight: the top row stays visible while the rows below clip away. Toggling animates the reveal. Size your header so the content aboveheaderCollapsedHeightis the part you want to keep visible.- The hosted content refreshes on every SwiftUI update — bind your
model into the header view as usual. Inject any required environment
(
.environmentObject,.environment) into the view before wrapping it inAnyView; the hosting view does not inherit your hierarchy's environment. - The reserved band uses the view's intrinsic (ideal) height. Text
that relies on wrapping at the editor's width can render taller than
its ideal height and clip at the band's bottom — give wrapping
content an explicit height (
.frame/.fixedSize) or line limit. - Composes with
readingWidth: the header spans the full viewport width while the body keeps its centered column. - An optional
placeholder: NSAttributedString?renders ghost text at the first-line position while the document is empty — below the header band, tracking its reveal animation; the first keystroke hides it. Style it with the editor's body font so it lines up with the would-be first line. - Pass
header: nil(the default) and the editor renders exactly as before — the header path adds nothing to header-less editors.
The demo app's Header toolbar toggle shows the full behavior.
Runnable demos live in Demo/: MarkdownEngineDemo.xcodeproj for macOS, and Demo/iOS for
iPhone and iPad (run xcodegen there first). Both reference the package by a local path, so engine edits
rebuild into the demo on the next run.
- macOS 14 or later (15.1+ for Writing Tools), iOS / iPadOS 17 or later
- Swift 5.9 / Xcode 15 or later; CI builds with Xcode 26
The API is pre-1.0 and changes as Notes my Way needs it. Pin a commit if you depend on it.
Bug reports, ideas and pull requests are welcome.
- ARCHITECTURE.md: the codemap and the parse → style → render pipeline
- CONTRIBUTING.md: setup, tests and design constraints
MarkdownKit began as a fork of swift-markdown-engine
by Luca Chen and contributors, the editor of Nodes, at commit
faa7b04 (June 2026). It's a separate project now, with its own direction: iOS support, tables with
formulas, source mode and the formatting API are new, and wiki-links are gone. The original's TextKit 2
foundation (the parser, the styler and the layout fragments) is still at its heart. Thank you to its
authors for sharing it.
Fixes from the original are ported selectively. See NOTICE for attribution.
Apache 2.0, the same as the original. See LICENSE and NOTICE.