Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 1 addition & 10 deletions Demo/MarkdownEngineDemo/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,9 @@ import MarkdownEngine

struct ContentView: View {
@State private var text: String = sampleMarkdown
@State private var isWikiLinkActive: Bool = false
@State private var pendingReplacement: InlineReplacementRequest?

var body: some View {
NativeTextViewWrapper(
text: $text,
isWikiLinkActive: $isWikiLinkActive,
pendingInlineReplacement: $pendingReplacement,
configuration: .default,
fontName: "SF Pro",
documentId: "demo"
)
NativeTextViewWrapper(text: $text)
}
}

Expand Down
30 changes: 29 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,42 @@ import PackageDescription
// conform to the engine's service protocols (`WikiLinkResolver`,
// `EmbeddedImageProvider`, `SyntaxHighlighter`, `LatexRenderer`). The engine
// itself has zero external dependencies.
//
// Users who want turnkey adapters for the two highest-friction protocols
// (code-block styling/highlighting, LaTeX rendering) can additionally
// depend on the `MarkdownEngineCodeBlocks` and/or `MarkdownEngineLatex`
// products, which ship pre-built bridges backed by HighlighterSwift and
// SwiftMath respectively. Both products are opt-in: the core
// `MarkdownEngine` library stays free of those transitive dependencies
// at link time.
let package = Package(
name: "MarkdownEngine",
platforms: [.macOS(.v14)],
products: [
.library(name: "MarkdownEngine", targets: ["MarkdownEngine"])
.library(name: "MarkdownEngine", targets: ["MarkdownEngine"]),
.library(name: "MarkdownEngineCodeBlocks", targets: ["MarkdownEngineCodeBlocks"]),
.library(name: "MarkdownEngineLatex", targets: ["MarkdownEngineLatex"]),
],
dependencies: [
.package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.0.0"),
.package(url: "https://github.com/mgriebling/SwiftMath", from: "1.7.0"),
],
targets: [
.target(name: "MarkdownEngine"),
.target(
name: "MarkdownEngineCodeBlocks",
dependencies: [
"MarkdownEngine",
.product(name: "Highlighter", package: "HighlighterSwift"),
]
),
.target(
name: "MarkdownEngineLatex",
dependencies: [
"MarkdownEngine",
.product(name: "SwiftMath", package: "SwiftMath"),
]
),
.testTarget(
name: "MarkdownEngineTests",
dependencies: ["MarkdownEngine"]
Expand Down
209 changes: 131 additions & 78 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,40 +40,32 @@ When we started building **[Nodes](https://nodes-web.com/#/)** a minimal, beauti
- **Drag-select autoscroll boost** for long documents
- **Spelling & grammar** with code/LaTeX/wiki-link suppression

## Architecture

The engine is built around four small service protocols you implement in
your app:

| Protocol | What you supply | 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 | [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) |
| `LatexRenderer` | Render a LaTeX string to an `NSImage` | [SwiftMath](https://github.com/mgriebling/SwiftMath) |

All four ship with no-op default implementations so the editor renders
plain Markdown out of the box. Drop in real implementations as you need
them — the engine itself stays free of any of those transitive dependencies.

## Installation

Add MarkdownEngine to your `Package.swift`:

```swift
dependencies: [
.package(url: "https://github.com/nodes-app/swift-markdown-engine", from: "0.1.0")
],
targets: [
.target(
name: "YourApp",
dependencies: ["MarkdownEngine"]
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. Pulls in [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) transitively. 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
Expand All @@ -82,83 +74,118 @@ import MarkdownEngine

struct EditorScreen: View {
@State private var text: String = "# Hello, *world*"
@State private var isLinkActive: Bool = false
@State private var pendingReplacement: InlineReplacementRequest?

var body: some View {
NativeTextViewWrapper(
text: $text,
isWikiLinkActive: $isLinkActive,
pendingInlineReplacement: $pendingReplacement,
configuration: .default,
fontName: "SF Pro",
documentId: "doc-1"
)
NativeTextViewWrapper(text: $text)
}
}
```

That's it. The default configuration ships with no-op services, so the
editor renders Markdown and accepts edits immediately.
That's it. See [Customization](#customization) below for syntax
highlighting, themes, wiki-link state, and more.

## Demo
> **Displaying multiple editors?** Pass a stable, unique
> `documentId: "your-doc-id"` so undo history and pending replacements
> stay scoped to each editor instance.

A runnable SwiftUI demo lives in [`Demo/`](Demo/MarkdownEngineDemo.xcodeproj).
Open [`Demo/MarkdownEngineDemo.xcodeproj`](Demo/MarkdownEngineDemo.xcodeproj)
in Xcode and hit **Run** — the demo references the package via a local
path, so Xcode resolves it on first open and any engine edit rebuilds
into the demo on the next run.
## Customization

> If you're seeing a "missing package product" error, it's almost always
> stale package cache from a previous Xcode session. Use **File →
> Packages → Reset Package Caches** once and rebuild.
### Service Protocols

## Customizing the Theme
The engine talks to your app through four service protocols, each with
a no-op default so you only implement what you actually need:

Every color the editor puts on screen is read from `MarkdownEditorTheme`:
| 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 | **`HighlighterSwiftBridge`** ([recommended](#code-blocks)) — built on [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) |
| `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
var theme = MarkdownEditorTheme.default
theme.bodyText = .labelColor
theme.headingMarker = .secondaryLabelColor
theme.findMatchHighlight = NSColor(named: "MyAccent")!
struct MyResolver: WikiLinkResolver {
func resolve(displayName: String, range: NSRange) -> WikiLinkResolution? {
myIndex[displayName].map { WikiLinkResolution(id: $0, exists: true) }
}
}

var configuration = MarkdownEditorConfiguration.default
configuration.theme = theme
configuration.services = MarkdownEditorServices(
wikiLinks: MyResolver()
// images, syntaxHighlighter, latex omitted → no-op defaults
)
```

Defaults map to `NSColor` dynamic system colors so light / dark mode
switching keeps working without extra code.
Each protocol and its no-op default are documented in DocC.

## Wiring Up Services
### Code Blocks

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

```swift
struct MyResolver: WikiLinkResolver {
func resolve(displayName: String, range: NSRange) -> WikiLinkResolution? {
guard let id = myIndex[displayName] else { return nil }
return WikiLinkResolution(id: id, exists: true)
}
}
import MarkdownEngineCodeBlocks

struct MyImages: EmbeddedImageProvider {
func image(for ref: EmbeddedImageRequest) -> NSImage? {
myImageStore.load(name: ref.name)
}
func fingerprint() -> AnyHashable { myImageStore.version }
}
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](#service-protocols)
above for the declaration) and reference the bundled bridge in
`Sources/MarkdownEngineCodeBlocks/` as a working example.

### LaTeX Rendering

let services = MarkdownEditorServices(
wikiLinks: MyResolver(),
images: MyImages(),
syntaxHighlighter: MyHighlighter(),
latex: MyLatexRenderer()
**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.services = services
configuration.theme = theme
```

## Tuning Behavior
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:
Expand All @@ -168,9 +195,39 @@ 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 // disable list editing helpers
configuration.lists.helpersEnabled = false
```

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

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

> 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 documentation is available via DocC:
Expand All @@ -184,14 +241,10 @@ In Xcode: **Product → Build Documentation** (`⇧⌃⌘D`).
Once the package is hosted on Swift Package Index, the docs will live at
`https://swiftpackageindex.com/nodes-app/swift-markdown-engine/documentation`.

## Requirements

- macOS 14 or later
- Swift 5.9 or later
- Xcode 15 or later
- macOS 15.1+ for Apple Writing Tools integration
## Requirements & Status

## 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
Expand Down
21 changes: 18 additions & 3 deletions Sources/MarkdownEngine/TextView/CodeBlockButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,26 @@ public struct CodeBlockSelection: Identifiable, Sendable {
public struct CodeBlockButton: View {
/// The code block this button is attached to.
public let selection: CodeBlockSelection
/// Vertical inset from the code block's top edge, in points. Positive
/// values push the button down into the code block.
public let topInset: CGFloat
/// Horizontal inset from the code block's trailing edge, in points.
/// Positive values keep the button inside the code block; negative
/// values let it overflow into a parent's gutter (the legacy Nodes
/// look).
public let trailingInset: CGFloat
/// Closure invoked when the user clicks the button.
public let onCopy: () -> Void

public init(selection: CodeBlockSelection, onCopy: @escaping () -> Void) {
public init(
selection: CodeBlockSelection,
topInset: CGFloat = 6,
trailingInset: CGFloat = 8,
onCopy: @escaping () -> Void
) {
self.selection = selection
self.topInset = topInset
self.trailingInset = trailingInset
self.onCopy = onCopy
}

Expand All @@ -72,8 +87,8 @@ public struct CodeBlockButton: View {
.cornerRadius(6)
}
.buttonStyle(.plain)
.padding(.top, 6)
.padding(.trailing, -25)
.padding(.top, topInset)
.padding(.trailing, trailingInset)
}
.position(
x: selection.rect.midX,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ extension NativeTextViewCoordinator {
let nsText = textView.string as NSString
let scrollOffset = textView.enclosingScrollView?.contentView.bounds.origin ?? .zero

// One-shot full-document layout per document; fixes stale Y from TextKit 2's lazy layout without per-update cost.
if !didEnsureLayoutForCurrentDocument, let tlm = textView.textLayoutManager {
tlm.ensureLayout(for: tlm.documentRange)
didEnsureLayoutForCurrentDocument = true
}

let selections: [CodeBlockSelection] = cachedCodeBlockTokens.compactMap { originalIndex, token in
guard !activeTokenIndices.contains(originalIndex) else { return nil }
guard var boundingRect = textView.viewRect(forCharacterRange: token.range, using: layoutBridge) else { return nil }
Expand Down
Loading
Loading