SwiftMarkdownEngine
A native AppKit Markdown editor for macOS, built on TextKit 2 and bridged to SwiftUI. It is the editor inside **[Nodes](https://apps.apple.com/app/apple-store/id6745401961?pt=127809373&ct=github&mt=8)**, a macOS notes app. Live styling, wiki-link support, fenced code blocks with syntax highlighting, LaTeX rendering, embedded images, and GitHub-style task
checkboxes.
## Features
- **Live Markdown styling** — bold, italic, headings, lists, blockquotes, GFM tables, code, links, task checkboxes, horizontal rules
- **Wiki-style linking** with two-form storage / display roundtripping
(`[[Name|]]` ↔ `[[Name]]`)
- **Image embeds** — both `![[Name]]` (Obsidian-style, embedder supplies the
bytes) and standard Markdown ``
- **LaTeX** — both block (`$$ … $$`) and inline (`$…$`), embedder supplies
the renderer
- **Code blocks** with embedder-supplied syntax highlighting and overlayable
copy buttons
- **Reading column** — opt-in fixed-width centered column, wide tables
break out to the full window width (`readingWidth`)
- **Scroll-away header** — host your own SwiftUI view above the document;
it scrolls with the content and collapses to a pinned top row
- **TextKit 2** layout for accurate, modern text rendering
- **Writing Tools** integration on macOS 15.1+, inline
- **Comfortable bottom overscroll** so the caret never pins to the viewport
edge while typing
- **Drag-select autoscroll boost** for long documents
- **Spelling & grammar** with code/LaTeX/wiki-link suppression
- **Extensions** — opt-in constructs defined by a *delimiter pair* (`==highlight==`, `~~strikethrough~~`, …); add your own via [`MarkdownExtension`](#extensions)
- **Directives** — opt-in constructs defined by a *name and typed arguments*, for what a delimiter pair can't express (`@font(size: 18){text}`); add your own via [`MarkdownDirective`](#directives)
## Installation
```swift
dependencies: [
.package(url: "https://github.com/nodes-app/swift-markdown-engine", from: "0.1.0")
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "MarkdownEngine", package: "swift-markdown-engine"),
]
)
]
```
Or in Xcode: **File → Add Package Dependencies…** and paste the repo URL.
The package ships three library products — add only what you need:
| Product | Use when |
|---|---|
| `MarkdownEngine` | You want the editor only. Zero external dependencies. |
| `MarkdownEngineCodeBlocks` | You want the full visual code-block experience — background fill, monospace font, and syntax highlighting — without writing your own bridge. Bundles [highlight.js](https://highlightjs.org) (192 languages); no package dependencies. See [Customization → Code Blocks](#code-blocks). |
| `MarkdownEngineLatex` | You want LaTeX formula rendering without writing your own bridge. Pulls in [SwiftMath](https://github.com/mgriebling/SwiftMath) transitively. See [Customization → LaTeX Rendering](#latex-rendering). |
## Quick Start
```swift
import SwiftUI
import MarkdownEngine
struct EditorScreen: View {
@State private var text: String = "# Hello, *world*"
var body: some View {
NativeTextViewWrapper(text: $text)
}
}
```
That's it. See [Customization](#customization) below for syntax
highlighting, themes, wiki-link state, and more.
> **Displaying multiple editors?** Pass a stable, unique
> `documentId: "your-doc-id"` so undo history and pending replacements
> stay scoped to each editor instance.
## Customization
### Service Protocols
The engine talks to your app through four 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 |
|---|---|---|
| `WikiLinkResolver` | Resolve a `[[Name]]` to a stable opaque id | (your data model) |
| `EmbeddedImageProvider` | Look up an `NSImage` for `![[Name]]` | (your asset store) |
| `SyntaxHighlighter` | Highlight code blocks for a given language | **`HighlightJSBridge`** ([recommended](#code-blocks)) — runs a bundled [highlight.js](https://highlightjs.org) |
| `LatexRenderer` | Render a LaTeX string to an `NSImage` | **`SwiftMathBridge`** ([recommended](#latex-rendering)) — built on [SwiftMath](https://github.com/mgriebling/SwiftMath) |
Implement what you need and pass it through `MarkdownEditorServices`:
```swift
struct MyResolver: WikiLinkResolver {
func resolve(displayName: String, range: NSRange) -> WikiLinkResolution? {
myIndex[displayName].map { WikiLinkResolution(id: $0, exists: true) }
}
}
configuration.services = MarkdownEditorServices(
wikiLinks: MyResolver()
// images, syntaxHighlighter, latex omitted → no-op defaults
)
```
Each protocol and its no-op default are documented in DocC.
### Code Blocks
**Recommended path: depend on the `MarkdownEngineCodeBlocks` product
and use the bundled `HighlightJSBridge`.** Rolling your own
`SyntaxHighlighter` has subtle footguns the bridge already handles —
appearance-change observation, caching, colours that land on exactly
the characters highlight.js scoped (UTF-16 offsets, no HTML entities),
and nested scopes (`title.function` is not `title`). Use the bundle
unless you specifically need a different highlighting library.
```swift
import MarkdownEngineCodeBlocks
var configuration = MarkdownEditorConfiguration.default
configuration.services = MarkdownEditorServices(
syntaxHighlighter: HighlightJSBridge()
)
```
The bridge colours with `CodeHighlightTheme.githubLight` and
`.githubDark` (highlight.js's GitHub themes) and switches with the
appearance. A `CodeHighlightTheme` is a colour per highlight.js scope
name, so your own palette is a dictionary, not a CSS file; a pinned
single theme is an init param — see DocC. In code languages the bridge
also scopes what highlight.js leaves plain — names (`identifier`), calls,
members (`property`), operators and punctuation — so a theme can colour
every name the way CodeMirror-based editors do.
highlight.js is BSD-3-Clause: its licence ships in the product's
resource bundle (`highlight.js-LICENSE.txt`), and an app distributing a
binary should reproduce it in its acknowledgements.
Need a different highlighter library entirely? Implement
`SyntaxHighlighter` yourself (see [Service Protocols](#service-protocols)
above for the declaration) and reference the bundled bridge in
`Sources/MarkdownEngineCodeBlocks/` as a working example.
### LaTeX Rendering
**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).
```swift
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.
### Theming
Every color the editor puts on screen reads from `MarkdownEditorTheme`:
```swift
var theme = MarkdownEditorTheme.default
theme.bodyText = .labelColor
theme.findMatchHighlight = NSColor(named: "MyAccent")!
var configuration = MarkdownEditorConfiguration.default
configuration.theme = theme
```
Defaults map to `NSColor` dynamic system colors, so light/dark mode
keeps working without extra code.
### Tuning
`MarkdownEditorConfiguration` exposes every spacing / sizing / behavior
knob the engine has, grouped by concern:
```swift
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 toolbar
```
### Wiki-Links & Replacement State
Two optional bindings on `NativeTextViewWrapper` let you observe
wiki-link state and push inline replacements programmatically. Pass
only what you need — each is independent and defaults to a no-op:
```swift
NativeTextViewWrapper(
text: $text,
isWikiLinkActive: $isWikiLinkActive,
pendingInlineReplacement: $pendingReplacement
)
```
- `isWikiLinkActive` — the wrapper sets this to `true` while the caret
sits inside a `[[Name]]` link, so you can present a contextual UI.
- `pendingInlineReplacement` — assign a non-nil value to push a
replacement (e.g. an autocomplete result); the engine consumes it
and clears the binding.
### Height Behavior
By default the editor scrolls internally. Set `heightBehavior` to
`.fitsContent` to make it grow to fit its content and report that height to
SwiftUI, so an enclosing `ScrollView` scrolls the page instead:
```swift
ScrollView {
NativeTextViewWrapper(text: $text, configuration: .init(heightBehavior: .fitsContent))
}
```
Composes with `readingWidth` and the scrolling header, and is switchable at
runtime. `.fitsContent` lays out the whole document (no viewport
virtualization), so prefer it for small-to-medium content. See
``HeightBehavior`` in DocC for the full behavior.
### Reading Column
Give long documents a fixed-width centered column; wide GFM tables break out
to the full window width, Google-Docs-style:
```swift
configuration.readingWidth = 650
```
Text wraps at `readingWidth` and never re-wraps on resize (only the column's
position moves), keeping live resize smooth. Leave it `nil` (default) to fill
the container edge-to-edge.
### Scrolling Header
Host a SwiftUI view above the document body that scrolls away with it —
metadata, a property table, a contextual toolbar:
```swift
NativeTextViewWrapper(
text: $text,
header: AnyView(MyDocumentHeader(document: document)),
headerCollapsedHeight: 40,
headerExpanded: isHeaderExpanded
)
```
The engine hosts it in an `NSHostingView`, reserves its intrinsic height, and
keeps it fully interactive. `headerExpanded: false` collapses to
`headerCollapsedHeight` (top row stays, rows below clip away, animated). Inject
any required environment *before* wrapping in `AnyView`, and give wrapping
content an explicit height so it doesn't clip at the band's bottom. Composes
with `readingWidth`; an optional `placeholder:` shows ghost text while empty;
`header: nil` (default) adds nothing. The demo's **Header** toggle shows it.
### Extensions
An extension is **a pair of delimiters** plus how to style what sits between
them — that is the whole shape, and what distinguishes it from a
[directive](#directives). The core engine parses pure markdown; constructs like
`==highlight==`, `~~strikethrough~~`, and `::: … :::` container blocks are
opt-in extensions:
```swift
var config = MarkdownEditorConfiguration()
config.extensions = [HighlightExtension(), StrikethroughExtension(), ContainerExtension()]
```
Unregistered syntax stays literal text. An extension contributes an inline
form (`InlineSyntax`), a fenced block form (`BlockSyntax`), or both — plus the
attributes for its content and an HTML wrapper for rich copy. The parser owns
all geometry, marker/fence hiding, caret reveal, and incremental restyling, so
extensions behave identically to built-ins and cannot affect neighboring
constructs. Conform to `MarkdownExtension` to add your own.
### Directives
The second opt-in seam, for constructs that need a NAME and TYPED ARGUMENTS
rather than delimiters:
```swift
var config = MarkdownEditorConfiguration()
config.directives = [FontDirective(), ColorDirective()]
```
```markdown
@font(size: 18){eighteen point}, @font(size: 1.5em){half again}, @color(red){tinted}
```
Two forms: **container** (`@font(size: 18){text}`) and **self-contained**
(`@pagebreak`). A container's font transform composes over the font inherited
at that point in the tree, so `@font(size: 18){**bold**}` is bold *and* 18pt,
and the same call inside a heading keeps the heading's weight. There is no
"applies to everything after me" form — a directive's effect is scoped to its
own node, which is what keeps per-keystroke restyling block-local.
A self-contained call draws a GLYPH in place of its own source, sized to the
surrounding text: an SF Symbol, replacement text, or an image, chosen by the
directive's `presentation`. The source is never removed — it collapses to zero
width, the same mechanism inline LaTeX uses — so selection, find, copy, and
undo still see the real characters, and the caret entering the call reveals
them.
The marker defaults to `@` and is configurable per registry
(`config.directiveSettings`) and per directive, and several markers can be
registered at once. An unregistered name stays literal text, and a directive
only opens at a non-word character — so `name@example.com` is never a
directive. If your app already uses `@` to trigger mentions, give directives
their own marker instead of disambiguating at the keystroke; the
registered-names-only rule keeps `@alice` literal, but the trigger itself is
still shared.
Two limits worth knowing before you author one. A body holding a span claimed
by an *earlier* parse pass — an inline code span, or a backslash escape —
leaves the whole construct literal rather than producing a directive around it:
```markdown
@font(size: 18){this has `code` in it} ← not a directive, stays as typed
@font(size: 18){this has *emphasis*} ← fine, composes normally
```
Constructs claimed in the same pass or later (`$…$`, links, emphasis, nesting)
work inside a body.
**Autocomplete** covers both directive names and argument values. The engine
detects the trigger, ranks the candidates, and routes ↑/↓/↵/Esc; you draw the
list (the demo's picker is ~60 lines):
```swift
NativeTextViewWrapper(
text: $text,
configuration: config,
onCaretRectChange: { anchor = $0 }, // where to put the list
onInlinePreviewKey: handleKey, // ↑/↓/↵/Esc → your list
onDirectiveCompletion: { completion = $0 }, // what to offer, or nil
pendingDirectiveCompletion: $pick // commit a choice
)
```
Value candidates come from `MarkdownDirective.valueCompletions(for:prefix:)`,
whose default already answers anything the schema declares (closed keyword
sets, booleans). Implement it only when the domain is dynamic or too large to
declare. The demo's `@flag` offers every ISO region that way, matching on code
or localised country name, with no shipped dataset.
Conform to `MarkdownDirective` to add your own; a typical one is about 30
lines, including its argument schema and HTML. `FontDirective` and
`ColorDirective` are reference implementations meant to be read — they are not
registered unless you register them. Directives carrying curated data or
document policy belong to your app; `Demo/` has `@icon`, `@flag`, `@emoji`,
and `@pagebreak` as worked examples, 30–60 lines each.
## Demo
A runnable SwiftUI demo lives in [`Demo/`](Demo/MarkdownEngineDemo.xcodeproj).
Open it in Xcode and hit **Run** — the demo references the package via
a local path, so any engine edit rebuilds into the demo on the next run.
Its sample document is ordered by where each construct comes from rather than
by feature: core markdown first, then the optional bridge products, then the
two opt-in seams. The toolbar's **Opt-in seams** toggle unregisters the
extensions and directives at runtime, so that last part collapses into literal
text while the rest doesn't move a pixel — the fastest way to see what the core
grammar actually knows.
> If you're seeing a "missing package product" error, it's almost always
> stale package cache. Use **File → Packages → Reset Package Caches**
> once and rebuild.
## Documentation
Full API docs ship as DocC. In Xcode: **Product → Build Documentation**
(`⇧⌃⌘D`); for local CLI preview see [CONTRIBUTING.md](CONTRIBUTING.md). Once
hosted on Swift Package Index, docs will live at
`https://swiftpackageindex.com/nodes-app/swift-markdown-engine/documentation`.
## Requirements & Status
- macOS 14 or later (15.1+ for Apple Writing Tools integration)
- Swift 5.9 / Xcode 15 or later
MarkdownEngine is currently **pre-1.0**. The public API may change between
minor releases as it stabilizes. Production use is fine — pin a specific
version (`0.x.y`) in your `Package.swift`.
## Who makes it
MarkdownEngine is the editor inside **[Nodes](https://apps.apple.com/app/apple-store/id6745401961?pt=127809373&ct=github&mt=8)**,
a macOS app for writing, linking and exploring notes. This is not a side project
we open-sourced and walked away from — it is the editor our own users type in
every day, and every fix here ships in a real app first.
If it is useful to you, telling someone about it is all we would ask for.
## Contributing
Bug reports, ideas, and pull requests are welcome.
- [ARCHITECTURE.md](ARCHITECTURE.md) — codemap and pipeline guide for
contributors
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, PR process, and design
constraints
## License
MarkdownEngine is released under the Apache 2.0 License. See [LICENSE](LICENSE)
for the full text.
---
Built by a small team in Munich and Zurich. Day-to-day on [Instagram](https://www.instagram.com/nodes.app).