SwiftMarkdownEngine logo

SwiftMarkdownEngine

Swift 5.9+ Platforms macOS 14+ License: Apache 2.0 CI

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 `![alt](url)` - **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 Nodes 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).