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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Changelog

> Modified in the NoFray fork on 2026-09-03; see `FORK_CHANGES.md`.

All notable changes to swift-markdown-engine are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
Expand All @@ -8,6 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- `NativeTextViewWrapper.isFocused` provides optional two-way first-responder
coordination for embedders without changing AppKit-owned focus by default.
- `NativeTextViewWrapper.onUnhandledCommand` lets embedders handle Escape,
Tab, and Shift-Tab after inline previews and list editing decline them.
- `MarkdownEditorBus.applyParagraphRequest` and `applyTaskListRequest` add
multiline, line-ending-preserving block-formatting commands for embedders.
- `NativeTextViewWrapper.allowsTaskCheckboxInteractionWhenReadOnly` optionally
keeps task checkboxes interactive without enabling ordinary text editing.
- **Directive seam (parsing)**: opt-in named inline commands with typed
arguments, for constructs that need a name and parameters rather than
delimiters. A `MarkdownDirective` declares a name, a form — self-contained
Expand Down
39 changes: 38 additions & 1 deletion Demo/MarkdownEngineDemo/ContentView.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//
// ContentView.swift
// MarkdownEngine
// Modified in the NoFray fork on 2026-09-03; see FORK_CHANGES.md.
//
// Created by Nicolas von Mallinckrodt on 29.04.26.
//
Expand All @@ -26,6 +27,8 @@ struct ContentView: View {
@State private var isReadOnly = false
@State private var showRawSource = false
@State private var useReadingColumn = false
@State private var editorIsFocused = false
@State private var lastHostCommand = "None"

/// Registers/unregisters BOTH opt-in seams at once. The document is written
/// so that flipping this off is the whole explanation of what is core
Expand All @@ -36,6 +39,9 @@ struct ContentView: View {
// Base font size; all relative sizing (headings, code, math) tracks it.
@State private var fontSize: CGFloat = 16

private let paragraphRequest = Notification.Name("demo.applyParagraph")
private let taskListRequest = Notification.Name("demo.applyTaskList")

// Scroll-away header demo.
@State private var showHeader = false
@State private var headerExpanded = true
Expand All @@ -46,6 +52,16 @@ struct ContentView: View {
configuration: configuration,
fontSize: fontSize,
isEditable: !isReadOnly,
isFocused: $editorIsFocused,
allowsTaskCheckboxInteractionWhenReadOnly: true,
onUnhandledCommand: { command in
switch command {
case .escape: lastHostCommand = "Escape"
case .tab: lastHostCommand = "Tab"
case .backtab: lastHostCommand = "Shift-Tab"
}
return command == .escape
},
placeholder: NSAttributedString(
string: "Empty document — start typing, markdown styles live…",
attributes: [
Expand All @@ -63,10 +79,17 @@ struct ContentView: View {
.id(useReadingColumn)
.toolbar {
ToolbarItemGroup {
Button {
editorIsFocused = true
} label: {
Label("Focus editor", systemImage: "text.cursor")
}
Text("Host command: \(lastHostCommand)")

Toggle(isOn: $isReadOnly) {
Label("Read-only", systemImage: isReadOnly ? "lock" : "lock.open")
}
.help("Read-only: the styled document stays scrollable and selectable, editing is off")
.help("Read-only: text editing is off, while task checkboxes remain interactive")

Toggle(isOn: $showRawSource) {
Label("Raw source", systemImage: "chevron.left.forwardslash.chevron.right")
Expand Down Expand Up @@ -100,6 +123,16 @@ struct ContentView: View {
}
.help("Base font size — headings, code, and math scale relative to it")

ControlGroup {
Button("Paragraph") {
NotificationCenter.default.post(name: paragraphRequest, object: nil)
}
Button("Task list") {
NotificationCenter.default.post(name: taskListRequest, object: nil)
}
}
.help("Apply block formatting through the embedder notification bus")

Menu {
Toggle("Show header", isOn: $showHeader)
Toggle("Expanded", isOn: $headerExpanded)
Expand Down Expand Up @@ -144,6 +177,10 @@ struct ContentView: View {
/// so you can see exactly what each one adds.
private var configuration: MarkdownEditorConfiguration {
var config = MarkdownEditorConfiguration.default
config.services.bus = MarkdownEditorBus(
applyParagraphRequest: paragraphRequest,
applyTaskListRequest: taskListRequest
)

#if canImport(MarkdownEngineCodeBlocks)
// Syntax highlighting for fenced code blocks. Auto-switches between
Expand Down
67 changes: 67 additions & 0 deletions FORK_CHANGES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# NoFray fork changes

This fork release is derived from `nodes-app/swift-markdown-engine` at the
immutable upstream baseline `08ff3c07b198ed639f595d0279ebac62c0410bc7`.
The latest published upstream tag when the baseline was frozen was `0.12.0` at
`e5f7607fc4021181056ef7a09dbb7573dc0237d9`. The fork baseline includes the
upstream commits after that tag through `08ff3c07b198ed639f595d0279ebac62c0410bc7`
and no commits after that baseline SHA.

## Governance

- `main` remains an unmodified mirror of the frozen upstream baseline.
- Each upstreamable change lives on its own topic branch and draft pull request.
- `nofray/integration` is the release branch that combines those reviewed topic
commits. It is not merged into `main`.
- The release tag `0.12.1-nofray.1` points to one integration commit and must
never be moved or reused.
- When upstream accepts an equivalent change, its fork commit is removed during
a deliberate rebase rather than maintained as a permanent parallel API.

## Changes from the upstream baseline

The exact topic and integration commit identities are:

| Change | Topic commit(s) | Integration commit(s) | Upstream PR |
| --- | --- | --- | --- |
| Focus binding | `d5a05413855ba7470a891f9385cbb5c340fae7c1` | `b19a6ebe92d632df50b01b93023a60978e9092ae` | [#175](https://github.com/nodes-app/swift-markdown-engine/pull/175) |
| Unhandled commands | `bc5e27a2769f50d547748c0fbad82e3fd0107103`, `b0ddb9fc1cdca9261b5617a23df5d4a7496cb6b3` | `f564a90b4bad80febadb657d898f5da9d125d932`, `a017c5a19ed306f839b3d6870d02451fb1b537df` | [#176](https://github.com/nodes-app/swift-markdown-engine/pull/176) |
| Block formatting | `8285c11ab23f5f29ee36358a5f07232d47e83922` | `eae29d89ce585c52153e8323b781034824623086` | [#177](https://github.com/nodes-app/swift-markdown-engine/pull/177) |
| Read-only checkbox | `098dc62883d42ff67de20e89fb6730b1501d043b`, `c66fb23d6ecbdd1909a307560f183ae8ed5c8390` | `fc0a97c7d9fb84504ba2d18c70d0684d0bae2b0e`, `f9f5bdc81247ce2540c56262414f7a88af50ec00` | [#178](https://github.com/nodes-app/swift-markdown-engine/pull/178) |

1. Optional two-way editor focus binding. Upstream draft PR
[#175](https://github.com/nodes-app/swift-markdown-engine/pull/175).
2. Synchronous host fallback for unhandled Escape, Tab, and Shift-Tab commands,
after preview and list behavior decline them. Upstream draft PR
[#176](https://github.com/nodes-app/swift-markdown-engine/pull/176).
3. Paragraph and task-list block-formatting actions that preserve line endings,
selections, attributes, and storage-form wiki-link metadata. Upstream draft
PR [#177](https://github.com/nodes-app/swift-markdown-engine/pull/177).
4. Opt-in task-checkbox interaction in read-only editors, while ordinary text
input remains disabled. The sanctioned mutation path reports exact edits and
supports document-scoped undo and redo. Upstream draft PR
[#178](https://github.com/nodes-app/swift-markdown-engine/pull/178).

These changes satisfy the reusable engine-side contract discussed in upstream
issue [#173](https://github.com/nodes-app/swift-markdown-engine/issues/173).
No NoFray application integration is included in this release.

## Release validation

Validated on macOS on 2026-09-03:

- `swift test --filter` over the five relevant suites: 43 tests in 5 suites
passed, including existing per-document undo coverage.
- Clean `swift test`: 510 tests in 72 suites passed.
- `swift build --product MarkdownEngine`: succeeded.
- SwiftPM graph inspection: the `MarkdownEngine` target has no dependencies and
the `MarkdownEngine` product contains only that target.
- Debug build of the `MarkdownEngineDemo` macOS scheme with code signing
disabled: succeeded.
- `git diff --check`: clean.

No package dependency, platform requirement, or license was changed. The
upstream Apache License 2.0 remains in `LICENSE`; the upstream baseline contains
no `NOTICE` file. Every modified pre-existing file carries a dated pointer to
this change record; newly added source and test files are documented here and
remain covered by the repository's Apache-2.0 license.
141 changes: 141 additions & 0 deletions Sources/MarkdownEngine/Input/MarkdownBlockFormatting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
//
// MarkdownBlockFormatting.swift
// MarkdownEngine
// Added to the NoFray fork on 2026-09-03 under Apache-2.0; see FORK_CHANGES.md.
//

import Foundation

struct MarkdownLinePrefixEdit {
let range: NSRange
let replacement: String
}

struct MarkdownBlockFormattingPlan {
let range: NSRange
let prefixEdits: [MarkdownLinePrefixEdit]
let selection: NSRange
}

enum MarkdownBlockFormatting {
private static let headingPrefix = try! NSRegularExpression(pattern: #"^([ \t]*)#{1,6}[ \t]+"#)
private static let indentation = try! NSRegularExpression(pattern: #"^[ \t]*"#)
private static let taskPrefix = try! NSRegularExpression(
pattern: #"^((?:\d+\.|[-*+]))[ \t]+\[[ xX]\][ \t]+"#
)
private static let listPrefix = try! NSRegularExpression(pattern: #"^((?:\d+\.|[-*+]))[ \t]+"#)

static func paragraphPlan(in text: String, selection: NSRange) -> MarkdownBlockFormattingPlan? {
let document = text as NSString
let lines = selectedLines(in: document, selection: selection)
let edits = lines.compactMap { line -> MarkdownLinePrefixEdit? in
let lineText = document.substring(with: line)
let localRange = NSRange(location: 0, length: (lineText as NSString).length)
guard let match = headingPrefix.firstMatch(in: lineText, range: localRange) else { return nil }
let indentationRange = match.range(at: 1)
let markerRange = NSRange(
location: line.location + NSMaxRange(indentationRange),
length: NSMaxRange(match.range) - NSMaxRange(indentationRange)
)
return MarkdownLinePrefixEdit(range: markerRange, replacement: "")
}
return plan(in: document, selection: selection, lines: lines, edits: edits)
}

static func taskListPlan(in text: String, selection: NSRange) -> MarkdownBlockFormattingPlan? {
let document = text as NSString
let lines = selectedLines(in: document, selection: selection)
let lineDetails = lines.map { line -> (range: NSRange, indentationLength: Int, taskRange: NSRange?, listRange: NSRange?, listMarker: String?) in
let lineText = document.substring(with: line) as NSString
let fullRange = NSRange(location: 0, length: lineText.length)
let indentationLength = indentation.firstMatch(in: lineText as String, range: fullRange)?.range.length ?? 0
let content = lineText.substring(from: indentationLength) as NSString
let contentRange = NSRange(location: 0, length: content.length)
let task = taskPrefix.firstMatch(in: content as String, range: contentRange).map {
NSRange(location: indentationLength + $0.range.location, length: $0.range.length)
}
let listMatch = listPrefix.firstMatch(in: content as String, range: contentRange)
let list = listMatch.map {
NSRange(location: indentationLength + $0.range.location, length: $0.range.length)
}
let marker = listMatch.map { content.substring(with: $0.range(at: 1)) }
return (line, indentationLength, task, list, marker)
}
let removeTaskPrefixes = lineDetails.allSatisfy { $0.taskRange != nil }
let edits = lineDetails.compactMap { line -> MarkdownLinePrefixEdit? in
if removeTaskPrefixes, let taskRange = line.taskRange {
return MarkdownLinePrefixEdit(
range: NSRange(location: line.range.location + taskRange.location, length: taskRange.length),
replacement: ""
)
}
if line.taskRange != nil { return nil }
if let listRange = line.listRange, let marker = line.listMarker {
return MarkdownLinePrefixEdit(
range: NSRange(location: line.range.location + listRange.location, length: listRange.length),
replacement: "\(marker) [ ] "
)
}
return MarkdownLinePrefixEdit(
range: NSRange(location: line.range.location + line.indentationLength, length: 0),
replacement: "- [ ] "
)
}
return plan(in: document, selection: selection, lines: lines, edits: edits)
}

private static func plan(
in document: NSString,
selection: NSRange,
lines: [NSRange],
edits: [MarkdownLinePrefixEdit]
) -> MarkdownBlockFormattingPlan? {
guard edits.isEmpty == false, let first = lines.first, let last = lines.last else { return nil }
let safeSelection = clamped(selection, length: document.length)
let sortedEdits = edits.sorted { $0.range.location < $1.range.location }
let start = transformedLocation(safeSelection.location, by: sortedEdits)
let end = transformedLocation(NSMaxRange(safeSelection), by: sortedEdits)
return MarkdownBlockFormattingPlan(
range: NSRange(location: first.location, length: NSMaxRange(last) - first.location),
prefixEdits: sortedEdits,
selection: NSRange(location: start, length: max(0, end - start))
)
}

private static func selectedLines(in document: NSString, selection: NSRange) -> [NSRange] {
let safe = clamped(selection, length: document.length)
let first = document.lineRange(for: NSRange(location: safe.location, length: 0))
let lastLocation = safe.length > 0 ? NSMaxRange(safe) - 1 : safe.location
let last = document.lineRange(for: NSRange(location: lastLocation, length: 0))
let selectedRange = NSRange(location: first.location, length: NSMaxRange(last) - first.location)
if selectedRange.length == 0 { return [selectedRange] }

var lines: [NSRange] = []
var location = selectedRange.location
while location < NSMaxRange(selectedRange) {
let line = document.lineRange(for: NSRange(location: location, length: 0))
lines.append(line)
let next = NSMaxRange(line)
guard next > location else { break }
location = next
}
return lines
}

private static func transformedLocation(_ location: Int, by edits: [MarkdownLinePrefixEdit]) -> Int {
var offset = 0
for edit in edits {
if location < edit.range.location { break }
if location <= NSMaxRange(edit.range) {
return edit.range.location + offset + edit.replacement.utf16.count
}
offset += edit.replacement.utf16.count - edit.range.length
}
return location + offset
}

private static func clamped(_ range: NSRange, length: Int) -> NSRange {
let location = min(max(0, range.location), length)
return NSRange(location: location, length: min(max(0, range.length), length - location))
}
}
12 changes: 12 additions & 0 deletions Sources/MarkdownEngine/Input/MarkdownInputHandler.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//
// MarkdownInputHandler.swift
// MarkdownEngine
// Modified in the NoFray fork on 2026-09-03; see FORK_CHANGES.md.
//
// Created by Luca Chen on 18.02.26.
//
Expand All @@ -22,6 +23,17 @@ enum MarkdownInputHandler {
replacementString: replacementString, isInsideCodeBlock: isInsideCodeBlock)
}

/// Handles an AppKit Tab command before it becomes a proposed text edit.
/// Returns `true` only when list editing consumed the command.
static func handleTabCommand(textView: NSTextView, codeTokens: [MarkdownToken]? = nil) -> Bool {
guard textView.isEditable else { return false }
let location = textView.selectedRange().location
let isInsideCodeBlock = codeTokens.map {
MarkdownDetection.isInsideCodeBlock(location: location, codeTokens: $0)
} ?? MarkdownDetection.isInsideCodeBlock(location: location, in: textView.string)
return MarkdownLists.handleTab(textView: textView, isInsideCodeBlock: isInsideCodeBlock)
}

// MARK: - Block LaTeX Auto-Wrap

private static func insertTextProgrammatically(_ textView: NSTextView, text: String, at range: NSRange, cursorAfter: Int) {
Expand Down
Loading
Loading