Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- `onLinkActivation` lets hosts handle link destinations and source ranges before default navigation.

### Fixed
- Inline links retain escaped brackets in their labels, including their full destination and UTF-16 source range.
- Smart paste keeps tables, lists and headings that arrive wrapped in elements the converter doesn't know (Gemini's `<response-element>`/`<table-block>` web components, `<section>`, a Google Docs `<b>` wrapper); they used to collapse into one line of text.

## [0.14.0] - 2026-10-04
Expand Down
3 changes: 2 additions & 1 deletion Sources/MarkdownEngine/Parser/BlockScopedTokenizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,8 @@ private extension MarkdownToken {
kind: kind,
range: move(range),
contentRange: move(contentRange),
markerRanges: markerRanges.map(move)
markerRanges: markerRanges.map(move),
destinationRange: destinationRange.map(move)
)
}
}
20 changes: 16 additions & 4 deletions Sources/MarkdownEngine/Parser/InlineASTAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,27 @@ enum InlineASTAdapter {
contentRange: between(markers), markerRanges: markers))
children.forEach { append($0, to: &result) }

case .link(let range, let textRange, _, let markers, let children):
result.append(MarkdownToken(kind: .link, range: range, contentRange: textRange, markerRanges: markers))
case .link(let range, let textRange, let urlRange, let markers, let children):
result.append(MarkdownToken(
kind: .link,
range: range,
contentRange: textRange,
markerRanges: markers,
destinationRange: urlRange
))
children.forEach { append($0, to: &result) }

case .image(let range, let alt, _, let markers):
result.append(MarkdownToken(kind: .imageLink, range: range, contentRange: alt, markerRanges: markers))

case .wikiLink(let range, let name, _, let markers):
result.append(MarkdownToken(kind: .wikiLink, range: range, contentRange: name, markerRanges: markers))
case .wikiLink(let range, let name, let id, let markers):
result.append(MarkdownToken(
kind: .wikiLink,
range: range,
contentRange: name,
markerRanges: markers,
destinationRange: id ?? name
))

case .imageEmbed(let range, let target, let markers):
result.append(MarkdownToken(kind: .imageEmbed, range: range, contentRange: target, markerRanges: markers))
Expand Down
42 changes: 41 additions & 1 deletion Sources/MarkdownEngine/Parser/InlineParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,28 @@ struct ExtensionInlineNode: Equatable {

enum InlineParser {

/// Extract the destination from a parsed inline-link URL span, removing
/// optional angle brackets or a complete trailing quoted/parenthesized title.
/// Interior whitespace belongs to the destination unless it starts that title.
/// This preserves the source spelling and deliberately performs no
/// classification, decoding, normalization, or URL construction.
static func markdownLinkDestination(from raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("<"), let closingBracket = trimmed.firstIndex(of: ">") {
return String(trimmed[trimmed.index(after: trimmed.startIndex)..<closingBracket])
}
let range = NSRange(location: 0, length: (trimmed as NSString).length)
if let title = linkTitleSuffix.firstMatch(in: trimmed, range: range) {
return (trimmed as NSString).substring(to: title.range.location)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return trimmed
}

private static let linkTitleSuffix = try! NSRegularExpression(
pattern: #"\s+(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^()\\])*\))$"#
)

private static let backtick: unichar = 0x60
private static let asterisk: unichar = 0x2A
private static let underscore: unichar = 0x5F
Expand Down Expand Up @@ -498,7 +520,7 @@ enum InlineParser {
/// `[ text ]( url )`
private static func matchLink(_ ns: NSString, _ len: Int, start i: Int) -> Span? {
let textStart = i + 1
guard let closeBracket = findChar(ns, len, from: textStart, char: rbracket),
guard let closeBracket = closingLinkLabelBracket(ns, len, from: textStart),
closeBracket > textStart,
peek(ns, closeBracket + 1, len) == lparen,
let closeParen = balancedParen(ns, len, from: closeBracket + 2) else { return nil }
Expand Down Expand Up @@ -551,6 +573,24 @@ enum InlineParser {
return nil
}

/// An escaped closing bracket is label text. Skip escape pairs so odd
/// and even backslashes retain their existing source-coordinate meaning.
private static func closingLinkLabelBracket(_ ns: NSString, _ len: Int, from: Int) -> Int? {
var k = from
while k < len {
let ch = ns.character(at: k)
if ch == newline { return nil }
if ch == backslash, k + 1 < len,
isAsciiPunctuationChar(ns.character(at: k + 1)) {
k += 2
continue
}
if ch == rbracket { return k }
k += 1
}
return nil
}

private static func findChar(_ ns: NSString, _ len: Int, from: Int, char: unichar) -> Int? {
var k = from
while k < len {
Expand Down
16 changes: 16 additions & 0 deletions Sources/MarkdownEngine/Parser/MarkdownToken.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@ struct MarkdownToken {
let range: NSRange
let contentRange: NSRange
let markerRanges: [NSRange]
/// Raw destination range for inline and wiki links, when the parser has one.
let destinationRange: NSRange?

init(
kind: MarkdownTokenKind,
range: NSRange,
contentRange: NSRange,
markerRanges: [NSRange],
destinationRange: NSRange? = nil
) {
self.kind = kind
self.range = range
self.contentRange = contentRange
self.markerRanges = markerRanges
self.destinationRange = destinationRange
}
}

extension MarkdownToken {
Expand Down
38 changes: 38 additions & 0 deletions Sources/MarkdownEngine/Services/WikiLinkService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,44 @@ public enum WikiLinkService {
return (result, metadata)
}

/// Maps a selection from the editor's projected display string back to
/// its storage-form Markdown offsets. Boundaries inside a renamed or
/// shortened wiki-link label are ambiguous and return `nil`; boundaries
/// outside a link, or exactly around the full link, remain exact.
static func storageRange(
forDisplayRange displayRange: NSRange,
metadata: [RangeKey: LinkMetadata]
) -> NSRange? {
guard displayRange.location != NSNotFound,
displayRange.location >= 0,
displayRange.length >= 0 else { return nil }
let links = metadata.map { (display: NSRange(location: $0.key.location, length: $0.key.length), storage: $0.value.storageRange) }
.sorted { $0.display.location < $1.display.location }
guard let start = storageBoundary(for: displayRange.location, links: links),
let end = storageBoundary(for: NSMaxRange(displayRange), links: links),
end >= start else { return nil }
return NSRange(location: start, length: end - start)
}

private static func storageBoundary(
for location: Int,
links: [(display: NSRange, storage: NSRange)]
) -> Int? {
var delta = 0
for link in links {
let displayEnd = NSMaxRange(link.display)
if location == link.display.location { return link.storage.location }
if location == displayEnd { return NSMaxRange(link.storage) }
if location > link.display.location && location < displayEnd { return nil }
if displayEnd < location {
delta += link.storage.length - link.display.length
} else if link.display.location >= location {
break
}
}
return location + delta
}

/// Convert display `[[Name]]` back to storage `[[Name|<id>]]`, preferring the `.wikiLinkID` attribute.
public static func makeStorageState(
from displayText: String,
Expand Down
35 changes: 22 additions & 13 deletions Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -907,21 +907,30 @@ enum MarkdownASTStyler {
children: [InlineNode], font: NSFont, ctx: Ctx, into attrs: inout [StyledRange]
) {
attrs.append((range, [.spellingState: 0]))
var urlString = ctx.ns.substring(with: urlRange)
let rawTarget = ctx.ns.substring(with: urlRange)
var urlString = rawTarget
if !urlString.contains("://") { urlString = "https://\(urlString)" }
let isActive = ctx.isActive(range)
// Keep syntactically valid inline links clickable even when Foundation
// cannot form the legacy URL value (for example, a bracketed
// destination containing spaces). The delegate reparses the token
// before routing, so this string is only a hit-test value.
let linkValue: Any
if let url = URL(string: urlString) {
if isActive {
attrs.append((textRange, [
.foregroundColor: ctx.theme.link.withAlphaComponent(ctx.config.link.activeLinkAlpha),
]))
} else {
attrs.append((textRange, [
.link: url,
.underlineStyle: NSUnderlineStyle.single.rawValue,
.foregroundColor: ctx.theme.link,
]))
}
linkValue = url
} else {
linkValue = rawTarget
}
let isActive = ctx.isActive(range)
if isActive {
attrs.append((textRange, [
.foregroundColor: ctx.theme.link.withAlphaComponent(ctx.config.link.activeLinkAlpha),
]))
} else {
attrs.append((textRange, [
.link: linkValue,
.underlineStyle: NSUnderlineStyle.single.rawValue,
.foregroundColor: ctx.theme.link,
]))
}
for marker in markers { attrs.append((marker, [.foregroundColor: ctx.theme.mutedText])) }
// The target is syntax, revealed with its brackets and muted like them —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,112 @@ extension NativeTextViewCoordinator {
pattern: #"^[ \t]*(?:(?:\d+[.)])|[-•*+])(?:[ \t]+\[[ xX]\])?[ \t]+"#
)

private static let linkActivationDetector = try? NSDataDetector(
types: NSTextCheckingResult.CheckingType.link.rawValue
)

private func linkActivation(at displayLocation: Int, in textView: NSTextView) -> MarkdownLinkActivation? {
let displayText = textView.string
let displayNSString = displayText as NSString
guard displayLocation >= 0, displayLocation < displayNSString.length else { return nil }

let parsed = parsedDocument(for: displayText)
if let token = parsed.tokens
.filter({
($0.kind == .link || $0.kind == .wikiLink)
&& NSLocationInRange(displayLocation, $0.range)
})
.min(by: { $0.range.length < $1.range.length }),
let tokenRange = sourceRange(forDisplayRange: token.range),
token.destinationRange != nil {
let sourceNSString = (lastComputedStorage.isEmpty ? text : lastComputedStorage) as NSString
guard NSMaxRange(tokenRange) <= sourceNSString.length else { return nil }

if token.kind == .wikiLink,
let metadata = wikiLinkMetadata.first(where: {
displayLocation >= $0.key.location
&& displayLocation < $0.key.location + $0.key.length
})?.value {
let destination = metadata.id
?? sourceDestination(for: token, tokenRange: metadata.storageRange, source: sourceNSString)
guard let destination else { return nil }
return makeLinkActivation(
kind: .wikiLink,
destination: destination,
sourceRange: metadata.storageRange,
textView: textView
)
}

guard let displayDestinationRange = token.destinationRange,
let sourceDestinationRange = sourceRange(forDisplayRange: displayDestinationRange),
NSMaxRange(sourceDestinationRange) <= sourceNSString.length else { return nil }
let rawDestination = sourceNSString.substring(with: sourceDestinationRange)
let destination = token.kind == .link
? InlineParser.markdownLinkDestination(from: rawDestination)
: rawDestination
return makeLinkActivation(
kind: token.kind == .link ? .inlineLink : .wikiLink,
destination: destination,
sourceRange: tokenRange,
textView: textView
)
}

guard let detector = Self.linkActivationDetector else { return nil }
let clickRange = NSRange(location: displayLocation, length: 1)
let paragraphRange = displayNSString.paragraphRange(for: clickRange)
guard let match = detector.matches(in: displayText, options: [], range: paragraphRange)
.first(where: { NSIntersectionRange($0.range, clickRange).length > 0 }),
let sourceMatchRange = sourceRange(forDisplayRange: match.range) else { return nil }
let sourceNSString = (lastComputedStorage.isEmpty ? text : lastComputedStorage) as NSString
guard NSMaxRange(sourceMatchRange) <= sourceNSString.length else { return nil }
return makeLinkActivation(
kind: .autolink,
destination: sourceNSString.substring(with: sourceMatchRange),
sourceRange: sourceMatchRange,
textView: textView
)
}

private func sourceDestination(
for token: MarkdownToken,
tokenRange: NSRange,
source: NSString
) -> String? {
guard NSMaxRange(tokenRange) <= source.length else { return nil }
if let displayDestinationRange = token.destinationRange,
let mappedRange = sourceRange(forDisplayRange: displayDestinationRange),
NSMaxRange(mappedRange) <= source.length {
return source.substring(with: mappedRange)
}
let tokenSource = source.substring(with: tokenRange) as NSString
guard let parsedToken = InlineASTAdapter.tokens(from: InlineParser.parse(tokenSource as String))
.first(where: { $0.kind == .wikiLink }),
let destinationRange = parsedToken.destinationRange,
NSMaxRange(destinationRange) <= tokenSource.length else { return nil }
return tokenSource.substring(with: destinationRange)
}

private func makeLinkActivation(
kind: MarkdownLinkActivation.Kind,
destination: String,
sourceRange: NSRange,
textView: NSTextView
) -> MarkdownLinkActivation {
MarkdownLinkActivation(
kind: kind,
destination: destination,
sourceRange: sourceRange,
modifierFlags: NSApp.currentEvent?.modifierFlags.intersection(.deviceIndependentFlagsMask) ?? [],
isEditable: textView.isEditable
)
}

private func sourceRange(forDisplayRange range: NSRange) -> NSRange? {
WikiLinkService.storageRange(forDisplayRange: range, metadata: wikiLinkMetadata)
}

/// Supplies a per-document `UndoManager` to the text view.
///
/// AppKit reuses one `NSTextView` across every open document, so the built-in
Expand Down Expand Up @@ -1010,6 +1116,25 @@ extension NativeTextViewCoordinator {
}
}
}
let activation: MarkdownLinkActivation?
if onLinkActivation != nil || link is String {
activation = linkActivation(at: charIndex, in: textView)
} else {
activation = nil
}
if let activation, onLinkActivation?(activation) == true {
(textView as? NativeTextView)?.linkClickDidNavigate = true
isWikiLinkActive = false
return true
}
if activation?.kind == .inlineLink || activation?.kind == .autolink {
// Preserve the existing URL/AppKit route when the host declines.
// This also prevents a raw fallback string used to make an
// otherwise-invalid Markdown URL clickable from being mistaken
// for a wiki-link identifier.
(textView as? NativeTextView)?.linkClickDidNavigate = true
return false
}
guard let target = WikiLinkService.resolveIdentifier(link: link, textView: textView, at: charIndex) else {
// Web link (URL-valued): returning false lets AppKit open the URL
// (the mouseDown fallback mirrors that). Opening a link is navigation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
var headerController: ScrollingHeaderController?
var layoutBridge: LayoutBridge?
var layoutDelegate: MarkdownLayoutManagerDelegate?
var onLinkActivation: ((MarkdownLinkActivation) -> Bool)?
var onLinkClick: ((String) -> Void)?
var onCaretRectChange: ((CGRect) -> Void)?
var onTextMutation: ((MarkdownTextMutation) -> Void)?
Expand Down Expand Up @@ -296,12 +297,14 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
fontName: String,
fontSize: CGFloat,
isWikiLinkActive: Binding<Bool>,
onLinkActivation: ((MarkdownLinkActivation) -> Bool)? = nil,
onLinkClick: ((String) -> Void)?,
onInlineSelectionChange: ((InlineSelectionState?) -> Void)?) {
_text = text
self.fontName = fontName
self.fontSize = fontSize
_isWikiLinkActive = isWikiLinkActive
self.onLinkActivation = onLinkActivation
self.onLinkClick = onLinkClick
self.onCaretRectChange = nil
self.onInlineSelectionChange = onInlineSelectionChange
Expand Down
Loading