2026-05-29 20:08:46 +02:00
|
|
|
import Foundation
|
|
|
|
|
|
|
|
|
|
public enum HybridMarkdownLineKind: Hashable, Sendable {
|
|
|
|
|
case paragraph
|
|
|
|
|
case heading(level: Int, markerRange: NSRange, textRange: NSRange)
|
2026-05-31 20:47:34 +02:00
|
|
|
case blockquote(markerRange: NSRange, contentRange: NSRange)
|
|
|
|
|
case horizontalRule(range: NSRange)
|
|
|
|
|
case unorderedList(markerRange: NSRange, contentRange: NSRange, nestingLevel: Int)
|
|
|
|
|
case orderedList(markerRange: NSRange, contentRange: NSRange, nestingLevel: Int)
|
|
|
|
|
case taskList(
|
|
|
|
|
markerRange: NSRange,
|
|
|
|
|
checkboxRange: NSRange,
|
|
|
|
|
contentRange: NSRange,
|
|
|
|
|
checked: Bool,
|
|
|
|
|
nestingLevel: Int
|
|
|
|
|
)
|
|
|
|
|
case fencedCodeFence(markerRange: NSRange, languageRange: NSRange?)
|
|
|
|
|
case codeBlockContent
|
|
|
|
|
case tableRow(cellRanges: [NSRange], separatorRanges: [NSRange], isDivider: Bool)
|
2026-05-29 20:08:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public enum HybridMarkdownSpanKind: Hashable, Sendable {
|
|
|
|
|
case bold
|
|
|
|
|
case italic
|
|
|
|
|
case inlineCode
|
2026-05-31 20:47:34 +02:00
|
|
|
case link
|
|
|
|
|
case automaticLink
|
2026-05-29 20:08:46 +02:00
|
|
|
case markdownDelimiter
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public struct HybridMarkdownSpan: Hashable, Sendable {
|
|
|
|
|
public var range: NSRange
|
|
|
|
|
public var kind: HybridMarkdownSpanKind
|
|
|
|
|
|
|
|
|
|
public init(range: NSRange, kind: HybridMarkdownSpanKind) {
|
|
|
|
|
self.range = range
|
|
|
|
|
self.kind = kind
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public struct HybridMarkdownLineRenderPlan: Hashable, Sendable {
|
|
|
|
|
public var line: EditorLine
|
|
|
|
|
public var kind: HybridMarkdownLineKind
|
|
|
|
|
public var spans: [HybridMarkdownSpan]
|
|
|
|
|
|
|
|
|
|
public init(line: EditorLine, kind: HybridMarkdownLineKind, spans: [HybridMarkdownSpan]) {
|
|
|
|
|
self.line = line
|
|
|
|
|
self.kind = kind
|
|
|
|
|
self.spans = spans
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public struct HybridMarkdownLineRenderer: Sendable {
|
|
|
|
|
public init() {}
|
|
|
|
|
|
|
|
|
|
public func renderPlan(for line: EditorLine) -> HybridMarkdownLineRenderPlan {
|
|
|
|
|
let kind = lineKind(for: line)
|
2026-05-31 20:47:34 +02:00
|
|
|
let spans = inlineSpans(in: line, kind: kind)
|
2026-05-29 20:08:46 +02:00
|
|
|
return HybridMarkdownLineRenderPlan(line: line, kind: kind, spans: spans)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:47:34 +02:00
|
|
|
public func renderPlans(for lines: [EditorLine]) -> [HybridMarkdownLineRenderPlan] {
|
|
|
|
|
var isInCodeBlock = false
|
|
|
|
|
var plans: [HybridMarkdownLineRenderPlan] = []
|
|
|
|
|
|
|
|
|
|
for line in lines {
|
|
|
|
|
let nonCodeKind = lineKind(for: line)
|
|
|
|
|
let kind: HybridMarkdownLineKind
|
|
|
|
|
|
|
|
|
|
if case .fencedCodeFence = nonCodeKind {
|
|
|
|
|
kind = nonCodeKind
|
|
|
|
|
isInCodeBlock.toggle()
|
|
|
|
|
} else if isInCodeBlock {
|
|
|
|
|
kind = .codeBlockContent
|
|
|
|
|
} else {
|
|
|
|
|
kind = nonCodeKind
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
plans.append(HybridMarkdownLineRenderPlan(
|
|
|
|
|
line: line,
|
|
|
|
|
kind: kind,
|
|
|
|
|
spans: inlineSpans(in: line, kind: kind)
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return plans
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 20:08:46 +02:00
|
|
|
private func lineKind(for line: EditorLine) -> HybridMarkdownLineKind {
|
2026-05-31 20:53:20 +02:00
|
|
|
let firstToken = firstNonWhitespaceCharacter(in: line.source)
|
|
|
|
|
|
|
|
|
|
if (firstToken == "`" || firstToken == "~"), let fence = fencedCodeFence(in: line) {
|
2026-05-31 20:47:34 +02:00
|
|
|
return fence
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:53:20 +02:00
|
|
|
if (firstToken == "-" || firstToken == "*" || firstToken == "_"), isHorizontalRule(line.source) {
|
2026-05-31 20:47:34 +02:00
|
|
|
return .horizontalRule(range: line.range)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:53:20 +02:00
|
|
|
if firstToken == "#", let heading = heading(in: line) {
|
2026-05-31 20:47:34 +02:00
|
|
|
return heading
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:53:20 +02:00
|
|
|
if (firstToken == "-" || firstToken == "*"), let task = taskList(in: line) {
|
2026-05-31 20:47:34 +02:00
|
|
|
return task
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:53:20 +02:00
|
|
|
if (firstToken == "-" || firstToken == "*"), let unorderedList = unorderedList(in: line) {
|
2026-05-31 20:47:34 +02:00
|
|
|
return unorderedList
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:53:20 +02:00
|
|
|
if firstToken?.isNumber == true, let orderedList = orderedList(in: line) {
|
2026-05-31 20:47:34 +02:00
|
|
|
return orderedList
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:53:20 +02:00
|
|
|
if firstToken == ">", let blockquote = blockquote(in: line) {
|
2026-05-31 20:47:34 +02:00
|
|
|
return blockquote
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let table = tableRow(in: line) {
|
|
|
|
|
return table
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return .paragraph
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func heading(in line: EditorLine) -> HybridMarkdownLineKind? {
|
|
|
|
|
let leadingWhitespace = line.source.prefix { $0 == " " }.count
|
|
|
|
|
guard leadingWhitespace <= 3 else { return nil }
|
|
|
|
|
|
|
|
|
|
let headingStart = line.source.dropFirst(leadingWhitespace)
|
|
|
|
|
let markerCount = headingStart.prefix { $0 == "#" }.count
|
2026-05-29 20:08:46 +02:00
|
|
|
guard (1...6).contains(markerCount),
|
2026-05-31 20:47:34 +02:00
|
|
|
headingStart.dropFirst(markerCount).first == " "
|
2026-05-29 20:08:46 +02:00
|
|
|
else {
|
2026-05-31 20:47:34 +02:00
|
|
|
return nil
|
2026-05-29 20:08:46 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:47:34 +02:00
|
|
|
let textOffset = leadingWhitespace + markerCount + 1
|
2026-05-29 20:08:46 +02:00
|
|
|
return .heading(
|
|
|
|
|
level: markerCount,
|
2026-05-31 20:47:34 +02:00
|
|
|
markerRange: NSRange(location: line.range.location + leadingWhitespace, length: markerCount),
|
2026-05-29 20:08:46 +02:00
|
|
|
textRange: NSRange(
|
|
|
|
|
location: line.range.location + textOffset,
|
|
|
|
|
length: max(0, line.range.length - textOffset)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:47:34 +02:00
|
|
|
private func blockquote(in line: EditorLine) -> HybridMarkdownLineKind? {
|
|
|
|
|
let trimmedPrefix = line.source.prefix { $0 == " " }
|
|
|
|
|
guard trimmedPrefix.count <= 3 else { return nil }
|
|
|
|
|
let start = trimmedPrefix.count
|
|
|
|
|
guard line.source.dropFirst(start).first == ">" else { return nil }
|
|
|
|
|
|
|
|
|
|
var markerLength = start
|
|
|
|
|
var cursor = line.source.index(line.source.startIndex, offsetBy: start)
|
|
|
|
|
while cursor < line.source.endIndex, line.source[cursor] == ">" {
|
|
|
|
|
markerLength += 1
|
|
|
|
|
cursor = line.source.index(after: cursor)
|
|
|
|
|
}
|
|
|
|
|
if cursor < line.source.endIndex, line.source[cursor] == " " {
|
|
|
|
|
markerLength += 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return .blockquote(
|
|
|
|
|
markerRange: NSRange(location: line.range.location, length: markerLength),
|
|
|
|
|
contentRange: NSRange(
|
|
|
|
|
location: line.range.location + markerLength,
|
|
|
|
|
length: max(0, line.range.length - markerLength)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func unorderedList(in line: EditorLine) -> HybridMarkdownLineKind? {
|
2026-05-31 20:53:20 +02:00
|
|
|
guard let match = firstMatch(Patterns.unorderedList, in: line.source),
|
2026-05-31 20:47:34 +02:00
|
|
|
match.range(at: 3).length > 0
|
|
|
|
|
else { return nil }
|
|
|
|
|
|
|
|
|
|
let indentation = match.range(at: 1).length
|
|
|
|
|
let markerRange = NSRange(
|
|
|
|
|
location: line.range.location + match.range(at: 2).location,
|
|
|
|
|
length: match.range(at: 2).length
|
|
|
|
|
)
|
|
|
|
|
let contentRange = NSRange(
|
|
|
|
|
location: line.range.location + match.range(at: 3).location,
|
|
|
|
|
length: match.range(at: 3).length
|
|
|
|
|
)
|
|
|
|
|
return .unorderedList(markerRange: markerRange, contentRange: contentRange, nestingLevel: nestingLevel(indentation))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func orderedList(in line: EditorLine) -> HybridMarkdownLineKind? {
|
2026-05-31 20:53:20 +02:00
|
|
|
guard let match = firstMatch(Patterns.orderedList, in: line.source),
|
2026-05-31 20:47:34 +02:00
|
|
|
match.range(at: 3).length > 0
|
|
|
|
|
else { return nil }
|
|
|
|
|
|
|
|
|
|
let indentation = match.range(at: 1).length
|
|
|
|
|
let markerRange = NSRange(
|
|
|
|
|
location: line.range.location + match.range(at: 2).location,
|
|
|
|
|
length: match.range(at: 2).length
|
|
|
|
|
)
|
|
|
|
|
let contentRange = NSRange(
|
|
|
|
|
location: line.range.location + match.range(at: 3).location,
|
|
|
|
|
length: match.range(at: 3).length
|
|
|
|
|
)
|
|
|
|
|
return .orderedList(markerRange: markerRange, contentRange: contentRange, nestingLevel: nestingLevel(indentation))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func taskList(in line: EditorLine) -> HybridMarkdownLineKind? {
|
2026-05-31 20:53:20 +02:00
|
|
|
guard let match = firstMatch(Patterns.taskList, in: line.source),
|
2026-05-31 20:47:34 +02:00
|
|
|
match.range(at: 4).length > 0
|
|
|
|
|
else { return nil }
|
|
|
|
|
|
|
|
|
|
let indentation = match.range(at: 1).length
|
|
|
|
|
let checkbox = (line.source as NSString).substring(with: match.range(at: 3))
|
|
|
|
|
return .taskList(
|
|
|
|
|
markerRange: NSRange(
|
|
|
|
|
location: line.range.location + match.range(at: 2).location,
|
|
|
|
|
length: match.range(at: 2).length
|
|
|
|
|
),
|
|
|
|
|
checkboxRange: NSRange(
|
|
|
|
|
location: line.range.location + match.range(at: 3).location,
|
|
|
|
|
length: match.range(at: 3).length
|
|
|
|
|
),
|
|
|
|
|
contentRange: NSRange(
|
|
|
|
|
location: line.range.location + match.range(at: 4).location,
|
|
|
|
|
length: match.range(at: 4).length
|
|
|
|
|
),
|
|
|
|
|
checked: checkbox.lowercased() == "[x]",
|
|
|
|
|
nestingLevel: nestingLevel(indentation)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func fencedCodeFence(in line: EditorLine) -> HybridMarkdownLineKind? {
|
2026-05-31 20:53:20 +02:00
|
|
|
guard let match = firstMatch(Patterns.fencedCodeFence, in: line.source) else { return nil }
|
2026-05-31 20:47:34 +02:00
|
|
|
let languageRange = match.range(at: 3).length > 0
|
|
|
|
|
? NSRange(location: line.range.location + match.range(at: 3).location, length: match.range(at: 3).length)
|
|
|
|
|
: nil
|
|
|
|
|
return .fencedCodeFence(
|
|
|
|
|
markerRange: NSRange(
|
|
|
|
|
location: line.range.location + match.range(at: 2).location,
|
|
|
|
|
length: match.range(at: 2).length
|
|
|
|
|
),
|
|
|
|
|
languageRange: languageRange
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func tableRow(in line: EditorLine) -> HybridMarkdownLineKind? {
|
|
|
|
|
guard line.source.contains("|") else { return nil }
|
|
|
|
|
let trimmed = line.source.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
guard trimmed.contains("|"), trimmed.count > 2 else { return nil }
|
|
|
|
|
|
2026-05-31 20:53:20 +02:00
|
|
|
let separatorRanges = regexMatches(Patterns.tableSeparator, in: line.source).map {
|
2026-05-31 20:47:34 +02:00
|
|
|
NSRange(location: line.range.location + $0.range.location, length: $0.range.length)
|
|
|
|
|
}
|
|
|
|
|
guard separatorRanges.count >= 1 else { return nil }
|
|
|
|
|
|
|
|
|
|
let cellRanges = tableCellRanges(in: line.source).map {
|
|
|
|
|
NSRange(location: line.range.location + $0.location, length: $0.length)
|
|
|
|
|
}
|
|
|
|
|
return .tableRow(
|
|
|
|
|
cellRanges: cellRanges,
|
|
|
|
|
separatorRanges: separatorRanges,
|
|
|
|
|
isDivider: isTableDivider(trimmed)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func inlineSpans(in line: EditorLine, kind: HybridMarkdownLineKind) -> [HybridMarkdownSpan] {
|
2026-05-29 20:08:46 +02:00
|
|
|
guard line.range.length > 0 else { return [] }
|
2026-05-31 20:47:34 +02:00
|
|
|
if case .horizontalRule = kind { return [] }
|
|
|
|
|
if case .fencedCodeFence = kind { return [] }
|
|
|
|
|
if case .codeBlockContent = kind { return [] }
|
2026-05-29 20:08:46 +02:00
|
|
|
|
|
|
|
|
var spans: [HybridMarkdownSpan] = []
|
|
|
|
|
var excludedRanges: [NSRange] = []
|
|
|
|
|
|
2026-05-31 20:53:20 +02:00
|
|
|
if line.source.contains("`") {
|
|
|
|
|
collectMatches(Patterns.inlineCode, in: line, excluding: excludedRanges).forEach { match in
|
|
|
|
|
spans.append(HybridMarkdownSpan(range: match.contentRange, kind: .inlineCode))
|
|
|
|
|
spans.append(contentsOf: delimiterRanges(match.fullRange, leading: 1, trailing: 1))
|
|
|
|
|
excludedRanges.append(match.fullRange)
|
|
|
|
|
}
|
2026-05-29 20:08:46 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:53:20 +02:00
|
|
|
if line.source.contains("**") {
|
|
|
|
|
collectMatches(Patterns.boldAsterisk, in: line, excluding: excludedRanges).forEach { match in
|
|
|
|
|
spans.append(HybridMarkdownSpan(range: match.contentRange, kind: .bold))
|
|
|
|
|
spans.append(contentsOf: delimiterRanges(match.fullRange, leading: 2, trailing: 2))
|
|
|
|
|
excludedRanges.append(match.fullRange)
|
|
|
|
|
}
|
2026-05-29 20:08:46 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:53:20 +02:00
|
|
|
if line.source.contains("__") {
|
|
|
|
|
collectMatches(Patterns.boldUnderscore, in: line, excluding: excludedRanges).forEach { match in
|
|
|
|
|
spans.append(HybridMarkdownSpan(range: match.contentRange, kind: .bold))
|
|
|
|
|
spans.append(contentsOf: delimiterRanges(match.fullRange, leading: 2, trailing: 2))
|
|
|
|
|
excludedRanges.append(match.fullRange)
|
|
|
|
|
}
|
2026-05-31 20:47:34 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:53:20 +02:00
|
|
|
if line.source.contains("](") {
|
|
|
|
|
collectLinkMatches(in: line, excluding: excludedRanges).forEach { match in
|
|
|
|
|
spans.append(HybridMarkdownSpan(range: match.titleRange, kind: .link))
|
|
|
|
|
spans.append(HybridMarkdownSpan(range: match.urlRange, kind: .markdownDelimiter))
|
|
|
|
|
spans.append(contentsOf: match.delimiterRanges.map {
|
|
|
|
|
HybridMarkdownSpan(range: $0, kind: .markdownDelimiter)
|
|
|
|
|
})
|
|
|
|
|
excludedRanges.append(match.fullRange)
|
|
|
|
|
}
|
2026-05-31 20:47:34 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:53:20 +02:00
|
|
|
if !isReferenceStyleLinkDefinition(line.source),
|
|
|
|
|
line.source.contains("https://") || line.source.contains("http://") {
|
|
|
|
|
collectMatches(Patterns.automaticLink, in: line, excluding: excludedRanges).forEach { match in
|
|
|
|
|
spans.append(HybridMarkdownSpan(range: match.fullRange, kind: .automaticLink))
|
|
|
|
|
excludedRanges.append(match.fullRange)
|
|
|
|
|
}
|
2026-05-31 20:47:34 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:53:20 +02:00
|
|
|
if line.source.contains("*") {
|
|
|
|
|
collectMatches(Patterns.italicAsterisk, in: line, excluding: excludedRanges).forEach { match in
|
|
|
|
|
spans.append(HybridMarkdownSpan(range: match.contentRange, kind: .italic))
|
|
|
|
|
spans.append(contentsOf: delimiterRanges(match.fullRange, leading: 1, trailing: 1))
|
|
|
|
|
excludedRanges.append(match.fullRange)
|
|
|
|
|
}
|
2026-05-29 20:08:46 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:53:20 +02:00
|
|
|
if line.source.contains("_") {
|
|
|
|
|
collectMatches(Patterns.italicUnderscore, in: line, excluding: excludedRanges).forEach { match in
|
|
|
|
|
spans.append(HybridMarkdownSpan(range: match.contentRange, kind: .italic))
|
|
|
|
|
spans.append(contentsOf: delimiterRanges(match.fullRange, leading: 1, trailing: 1))
|
|
|
|
|
excludedRanges.append(match.fullRange)
|
|
|
|
|
}
|
2026-05-31 20:47:34 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 20:08:46 +02:00
|
|
|
return spans.sorted {
|
|
|
|
|
if $0.range.location == $1.range.location {
|
|
|
|
|
return $0.range.length < $1.range.length
|
|
|
|
|
}
|
|
|
|
|
return $0.range.location < $1.range.location
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func collectMatches(
|
2026-05-31 20:53:20 +02:00
|
|
|
_ regex: NSRegularExpression,
|
2026-05-29 20:08:46 +02:00
|
|
|
in line: EditorLine,
|
|
|
|
|
excluding excludedRanges: [NSRange]
|
|
|
|
|
) -> [InlineMatch] {
|
|
|
|
|
return regex.matches(in: line.source, range: NSRange(location: 0, length: line.source.utf16.count))
|
|
|
|
|
.compactMap { match in
|
|
|
|
|
guard match.numberOfRanges > 1 else { return nil }
|
|
|
|
|
let shiftedRange = NSRange(
|
|
|
|
|
location: line.range.location + match.range.location,
|
|
|
|
|
length: match.range.length
|
|
|
|
|
)
|
|
|
|
|
guard !excludedRanges.contains(where: { $0.intersects(shiftedRange) }) else { return nil }
|
|
|
|
|
return InlineMatch(
|
|
|
|
|
fullRange: shiftedRange,
|
|
|
|
|
contentRange: NSRange(
|
|
|
|
|
location: line.range.location + match.range(at: 1).location,
|
|
|
|
|
length: match.range(at: 1).length
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:47:34 +02:00
|
|
|
private func collectLinkMatches(in line: EditorLine, excluding excludedRanges: [NSRange]) -> [LinkMatch] {
|
2026-05-31 20:53:20 +02:00
|
|
|
Patterns.markdownLink.matches(in: line.source, range: NSRange(location: 0, length: line.source.utf16.count))
|
2026-05-31 20:47:34 +02:00
|
|
|
.compactMap { match in
|
|
|
|
|
guard match.numberOfRanges > 2 else { return nil }
|
|
|
|
|
let fullRange = NSRange(location: line.range.location + match.range.location, length: match.range.length)
|
|
|
|
|
guard !excludedRanges.contains(where: { $0.intersects(fullRange) }) else { return nil }
|
|
|
|
|
let titleRange = NSRange(
|
|
|
|
|
location: line.range.location + match.range(at: 1).location,
|
|
|
|
|
length: match.range(at: 1).length
|
|
|
|
|
)
|
|
|
|
|
let urlRange = NSRange(
|
|
|
|
|
location: line.range.location + match.range(at: 2).location,
|
|
|
|
|
length: match.range(at: 2).length
|
|
|
|
|
)
|
|
|
|
|
return LinkMatch(
|
|
|
|
|
fullRange: fullRange,
|
|
|
|
|
titleRange: titleRange,
|
|
|
|
|
urlRange: urlRange,
|
|
|
|
|
delimiterRanges: [
|
|
|
|
|
NSRange(location: fullRange.location, length: 1),
|
|
|
|
|
NSRange(location: titleRange.upperBound, length: 2),
|
|
|
|
|
NSRange(location: fullRange.upperBound - 1, length: 1)
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 20:08:46 +02:00
|
|
|
private func delimiterRanges(
|
|
|
|
|
_ fullRange: NSRange,
|
|
|
|
|
leading: Int,
|
|
|
|
|
trailing: Int
|
|
|
|
|
) -> [HybridMarkdownSpan] {
|
|
|
|
|
[
|
|
|
|
|
HybridMarkdownSpan(range: NSRange(location: fullRange.location, length: leading), kind: .markdownDelimiter),
|
|
|
|
|
HybridMarkdownSpan(
|
|
|
|
|
range: NSRange(location: fullRange.upperBound - trailing, length: trailing),
|
|
|
|
|
kind: .markdownDelimiter
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
}
|
2026-05-31 20:47:34 +02:00
|
|
|
|
2026-05-31 20:53:20 +02:00
|
|
|
private func firstMatch(_ regex: NSRegularExpression, in source: String) -> NSTextCheckingResult? {
|
|
|
|
|
regexMatches(regex, in: source).first
|
2026-05-31 20:47:34 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:53:20 +02:00
|
|
|
private func regexMatches(_ regex: NSRegularExpression, in source: String) -> [NSTextCheckingResult] {
|
2026-05-31 20:47:34 +02:00
|
|
|
return regex.matches(in: source, range: NSRange(location: 0, length: source.utf16.count))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func nestingLevel(_ indentationLength: Int) -> Int {
|
|
|
|
|
max(0, indentationLength / 2)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func isHorizontalRule(_ source: String) -> Bool {
|
|
|
|
|
let trimmed = source.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
guard trimmed.count >= 3 else { return false }
|
|
|
|
|
let compact = trimmed.filter { $0 != " " && $0 != "\t" }
|
|
|
|
|
guard compact.count >= 3, let first = compact.first, ["-", "*", "_"].contains(first) else {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return compact.allSatisfy { $0 == first }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func isTableDivider(_ source: String) -> Bool {
|
|
|
|
|
let cells = source.split(separator: "|", omittingEmptySubsequences: false)
|
|
|
|
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
|
|
|
|
.filter { !$0.isEmpty }
|
|
|
|
|
guard !cells.isEmpty else { return false }
|
|
|
|
|
return cells.allSatisfy { cell in
|
|
|
|
|
let core = cell.trimmingCharacters(in: CharacterSet(charactersIn: ":"))
|
|
|
|
|
return core.count >= 3 && core.allSatisfy { $0 == "-" }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func tableCellRanges(in source: String) -> [NSRange] {
|
|
|
|
|
let nsSource = source as NSString
|
|
|
|
|
var ranges: [NSRange] = []
|
|
|
|
|
var cellStart = 0
|
|
|
|
|
|
|
|
|
|
for location in 0..<nsSource.length where nsSource.character(at: location) == 124 {
|
|
|
|
|
appendTrimmedRange(from: cellStart, to: location, in: nsSource, ranges: &ranges)
|
|
|
|
|
cellStart = location + 1
|
|
|
|
|
}
|
|
|
|
|
appendTrimmedRange(from: cellStart, to: nsSource.length, in: nsSource, ranges: &ranges)
|
|
|
|
|
|
|
|
|
|
return ranges.filter { $0.length > 0 }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func appendTrimmedRange(from start: Int, to end: Int, in source: NSString, ranges: inout [NSRange]) {
|
|
|
|
|
var lower = start
|
|
|
|
|
var upper = end
|
|
|
|
|
while lower < upper, isWhitespace(source.character(at: lower)) {
|
|
|
|
|
lower += 1
|
|
|
|
|
}
|
|
|
|
|
while upper > lower, isWhitespace(source.character(at: upper - 1)) {
|
|
|
|
|
upper -= 1
|
|
|
|
|
}
|
|
|
|
|
ranges.append(NSRange(location: lower, length: upper - lower))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func isWhitespace(_ character: unichar) -> Bool {
|
|
|
|
|
character == 32 || character == 9
|
|
|
|
|
}
|
2026-05-31 20:53:20 +02:00
|
|
|
|
|
|
|
|
private func isReferenceStyleLinkDefinition(_ source: String) -> Bool {
|
|
|
|
|
guard firstNonWhitespaceCharacter(in: source) == "[",
|
|
|
|
|
let closingBracket = source.firstIndex(of: "]")
|
|
|
|
|
else { return false }
|
|
|
|
|
|
|
|
|
|
let nextIndex = source.index(after: closingBracket)
|
|
|
|
|
return nextIndex < source.endIndex && source[nextIndex] == ":"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func firstNonWhitespaceCharacter(in source: String) -> Character? {
|
|
|
|
|
source.first { $0 != " " && $0 != "\t" }
|
|
|
|
|
}
|
2026-05-29 20:08:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct InlineMatch {
|
|
|
|
|
var fullRange: NSRange
|
|
|
|
|
var contentRange: NSRange
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:47:34 +02:00
|
|
|
private struct LinkMatch {
|
|
|
|
|
var fullRange: NSRange
|
|
|
|
|
var titleRange: NSRange
|
|
|
|
|
var urlRange: NSRange
|
|
|
|
|
var delimiterRanges: [NSRange]
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 20:53:20 +02:00
|
|
|
private enum Patterns {
|
|
|
|
|
static let unorderedList = regex("^([ \\t]*)([-*])\\s+(.*)$")
|
|
|
|
|
static let orderedList = regex("^([ \\t]*)(\\d+[.)])\\s+(.*)$")
|
|
|
|
|
static let taskList = regex("^([ \\t]*)([-*])\\s+(\\[[ xX]\\])\\s+(.*)$")
|
|
|
|
|
static let fencedCodeFence = regex("^([ \\t]*)(```|~~~)([^`~]*)$")
|
|
|
|
|
static let tableSeparator = regex("\\|")
|
|
|
|
|
static let inlineCode = regex("`([^`\\n]+)`")
|
|
|
|
|
static let boldAsterisk = regex("\\*\\*([^*\\n]+)\\*\\*")
|
|
|
|
|
static let boldUnderscore = regex("__([^_\\n]+)__")
|
|
|
|
|
static let markdownLink = regex("(?<!!)\\[([^\\]\\n]+)\\]\\(([^)\\s\\n]+)\\)")
|
|
|
|
|
static let automaticLink = regex("(https?://[^\\s<>()]+)")
|
|
|
|
|
static let italicAsterisk = regex("(?<!\\*)\\*([^*\\n]+)\\*(?!\\*)")
|
|
|
|
|
static let italicUnderscore = regex("(?<!_)_([^_\\n]+)_(?!_)")
|
|
|
|
|
|
|
|
|
|
private static func regex(_ pattern: String) -> NSRegularExpression {
|
|
|
|
|
do {
|
|
|
|
|
return try NSRegularExpression(pattern: pattern)
|
|
|
|
|
} catch {
|
|
|
|
|
preconditionFailure("Invalid Markdown renderer regex: \(pattern)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 20:08:46 +02:00
|
|
|
private extension NSRange {
|
|
|
|
|
var upperBound: Int {
|
|
|
|
|
location + length
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func intersects(_ other: NSRange) -> Bool {
|
|
|
|
|
location < other.upperBound && other.location < upperBound
|
|
|
|
|
}
|
|
|
|
|
}
|