feat(renderer): expand markdown fundamentals

This commit is contained in:
Feror 2026-05-31 20:47:34 +02:00
parent e6bbf78d79
commit a0407f9704
3 changed files with 634 additions and 17 deletions

View file

@ -666,6 +666,9 @@ enum MarkdownTextStyler {
let lines = invalidationPlan.isFullRender let lines = invalidationPlan.isFullRender
? lineIndex.editorLines(activeLineIndex: activeLineIndex) ? lineIndex.editorLines(activeLineIndex: activeLineIndex)
: lineIndex.editorLines(for: invalidationPlan.dirtyLineIndexes, activeLineIndex: activeLineIndex) : lineIndex.editorLines(for: invalidationPlan.dirtyLineIndexes, activeLineIndex: activeLineIndex)
let renderPlansByLine = Dictionary(
uniqueKeysWithValues: renderer.renderPlans(for: lines).map { ($0.line.index, $0) }
)
var styledLineCount = 0 var styledLineCount = 0
for line in lines { for line in lines {
resetAttributes(in: textStorage, line: line, textColor: textColor) resetAttributes(in: textStorage, line: line, textColor: textColor)
@ -681,7 +684,9 @@ enum MarkdownTextStyler {
styleRenderedLine( styleRenderedLine(
in: textStorage, in: textStorage,
line: line, line: line,
renderPlan: renderer.renderPlan(for: line), renderPlan: renderPlansByLine[line.index] ?? renderer.renderPlan(for: line),
textColor: textColor,
backgroundColor: backgroundColor,
secondaryTextColor: secondaryTextColor, secondaryTextColor: secondaryTextColor,
accentColor: accentColor accentColor: accentColor
) )
@ -705,19 +710,108 @@ enum MarkdownTextStyler {
in textStorage: NSTextStorage, in textStorage: NSTextStorage,
line: EditorLine, line: EditorLine,
renderPlan: HybridMarkdownLineRenderPlan, renderPlan: HybridMarkdownLineRenderPlan,
textColor: PlatformColor,
backgroundColor: PlatformColor,
secondaryTextColor: PlatformColor, secondaryTextColor: PlatformColor,
accentColor: PlatformColor accentColor: PlatformColor
) { ) {
guard line.range.length > 0 else { return } guard line.range.length > 0 else { return }
if case .heading(let level, let markerRange, let textRange) = renderPlan.kind { switch renderPlan.kind {
case .heading(let level, let markerRange, let textRange):
textStorage.addAttributes([ textStorage.addAttributes([
.foregroundColor: secondaryTextColor, .foregroundColor: secondaryTextColor,
.font: monospacedFont(size: 13, weight: .regular) .font: monospacedFont(size: 13, weight: .regular)
], range: markerRange) ], range: markerRange)
textStorage.addAttributes([ textStorage.addAttributes([
.font: systemFont(size: headingFontSize(level: level), weight: .semibold) .font: systemFont(size: headingFontSize(level: level), weight: .semibold),
.paragraphStyle: headingParagraphStyle(level: level)
], range: textRange) ], range: textRange)
case .blockquote(let markerRange, let contentRange):
textStorage.addAttributes([
.foregroundColor: accentColor,
.font: monospacedFont(size: 15, weight: .semibold)
], range: markerRange)
textStorage.addAttributes([
.foregroundColor: textColor,
.backgroundColor: accentColor.withAlphaComponent(0.08),
.paragraphStyle: blockquoteParagraphStyle()
], range: line.range)
textStorage.addAttributes([
.font: italicSystemFont(size: 16)
], range: contentRange)
case .horizontalRule(let range):
textStorage.addAttributes([
.foregroundColor: secondaryTextColor,
.strikethroughStyle: NSUnderlineStyle.thick.rawValue,
.paragraphStyle: horizontalRuleParagraphStyle()
], range: range)
case .unorderedList(let markerRange, let contentRange, let nestingLevel):
styleListLine(
in: textStorage,
lineRange: line.range,
markerRange: markerRange,
contentRange: contentRange,
nestingLevel: nestingLevel,
secondaryTextColor: secondaryTextColor
)
case .orderedList(let markerRange, let contentRange, let nestingLevel):
styleListLine(
in: textStorage,
lineRange: line.range,
markerRange: markerRange,
contentRange: contentRange,
nestingLevel: nestingLevel,
secondaryTextColor: secondaryTextColor
)
case .taskList(let markerRange, let checkboxRange, let contentRange, let checked, let nestingLevel):
styleListLine(
in: textStorage,
lineRange: line.range,
markerRange: markerRange,
contentRange: contentRange,
nestingLevel: nestingLevel,
secondaryTextColor: secondaryTextColor
)
textStorage.addAttributes([
.foregroundColor: checked ? accentColor : secondaryTextColor,
.font: monospacedFont(size: 15, weight: .semibold),
.backgroundColor: checked ? accentColor.withAlphaComponent(0.16) : backgroundColor
], range: checkboxRange)
if checked {
textStorage.addAttributes([
.strikethroughStyle: NSUnderlineStyle.single.rawValue,
.foregroundColor: secondaryTextColor
], range: contentRange)
}
case .fencedCodeFence(let markerRange, let languageRange):
textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: line.range)
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: markerRange)
if let languageRange {
textStorage.addAttributes([
.foregroundColor: accentColor,
.font: monospacedFont(size: 13, weight: .semibold)
], range: languageRange)
}
case .codeBlockContent:
textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: line.range)
case .tableRow(_, let separatorRanges, let isDivider):
textStorage.addAttributes([
.font: monospacedFont(size: 15, weight: .regular),
.backgroundColor: accentColor.withAlphaComponent(0.06),
.paragraphStyle: tableParagraphStyle()
], range: line.range)
for separatorRange in separatorRanges {
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: separatorRange)
}
if isDivider {
textStorage.addAttributes([
.foregroundColor: secondaryTextColor,
.font: monospacedFont(size: 15, weight: .semibold)
], range: line.range)
}
case .paragraph:
break
} }
styleInlineSpans( styleInlineSpans(
@ -745,6 +839,11 @@ enum MarkdownTextStyler {
.font: monospacedFont(size: 15, weight: .regular), .font: monospacedFont(size: 15, weight: .regular),
.backgroundColor: accentColor.withAlphaComponent(0.12) .backgroundColor: accentColor.withAlphaComponent(0.12)
], range: span.range) ], range: span.range)
case .link, .automaticLink:
textStorage.addAttributes([
.foregroundColor: accentColor,
.underlineStyle: NSUnderlineStyle.single.rawValue
], range: span.range)
case .markdownDelimiter: case .markdownDelimiter:
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: span.range) textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: span.range)
} }
@ -756,7 +855,7 @@ enum MarkdownTextStyler {
line: EditorLine, line: EditorLine,
secondaryTextColor: PlatformColor secondaryTextColor: PlatformColor
) { ) {
applyRegex("(#{1,6}|\\*\\*|\\*|`)", in: textStorage, line: line) { match in applyRegex("(#{1,6}|\\*\\*|__|\\*|_|`|\\[[ xX]\\]|\\[|\\]|\\(|\\)|\\||>|-{3,}|[-*]|\\d+[.)])", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: match.range) textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: match.range)
} }
} }
@ -794,6 +893,83 @@ enum MarkdownTextStyler {
} }
} }
private static func headingParagraphStyle(level: Int) -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
paragraph.paragraphSpacingBefore = level <= 2 ? 10 : 6
paragraph.paragraphSpacing = level <= 2 ? 9 : 6
return paragraph
}
private static func blockquoteParagraphStyle() -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
paragraph.paragraphSpacing = 6
paragraph.headIndent = 18
paragraph.firstLineHeadIndent = 0
return paragraph
}
private static func horizontalRuleParagraphStyle() -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.paragraphSpacingBefore = 10
paragraph.paragraphSpacing = 10
return paragraph
}
private static func listParagraphStyle(nestingLevel: Int) -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
let indent = CGFloat(20 + nestingLevel * 18)
paragraph.lineSpacing = 4
paragraph.paragraphSpacing = 4
paragraph.firstLineHeadIndent = 0
paragraph.headIndent = indent
return paragraph
}
private static func tableParagraphStyle() -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 3
paragraph.paragraphSpacing = 2
return paragraph
}
private static func codeBlockParagraphStyle() -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 3
paragraph.paragraphSpacing = 2
return paragraph
}
private static func codeBlockAttributes(accentColor: PlatformColor) -> [NSAttributedString.Key: Any] {
[
.font: monospacedFont(size: 15, weight: .regular),
.backgroundColor: accentColor.withAlphaComponent(0.08),
.paragraphStyle: codeBlockParagraphStyle()
]
}
private static func styleListLine(
in textStorage: NSTextStorage,
lineRange: NSRange,
markerRange: NSRange,
contentRange: NSRange,
nestingLevel: Int,
secondaryTextColor: PlatformColor
) {
textStorage.addAttributes([
.paragraphStyle: listParagraphStyle(nestingLevel: nestingLevel)
], range: lineRange)
textStorage.addAttributes([
.foregroundColor: secondaryTextColor,
.font: monospacedFont(size: 15, weight: .semibold)
], range: markerRange)
textStorage.addAttributes([
.font: systemFont(size: 16, weight: .regular)
], range: contentRange)
}
#if os(macOS) #if os(macOS)
private static func systemFont(size: CGFloat, weight: NSFont.Weight) -> NSFont { private static func systemFont(size: CGFloat, weight: NSFont.Weight) -> NSFont {
NSFont.systemFont(ofSize: size, weight: weight) NSFont.systemFont(ofSize: size, weight: weight)

View file

@ -3,12 +3,28 @@ import Foundation
public enum HybridMarkdownLineKind: Hashable, Sendable { public enum HybridMarkdownLineKind: Hashable, Sendable {
case paragraph case paragraph
case heading(level: Int, markerRange: NSRange, textRange: NSRange) case heading(level: Int, markerRange: NSRange, textRange: NSRange)
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)
} }
public enum HybridMarkdownSpanKind: Hashable, Sendable { public enum HybridMarkdownSpanKind: Hashable, Sendable {
case bold case bold
case italic case italic
case inlineCode case inlineCode
case link
case automaticLink
case markdownDelimiter case markdownDelimiter
} }
@ -39,22 +55,89 @@ public struct HybridMarkdownLineRenderer: Sendable {
public func renderPlan(for line: EditorLine) -> HybridMarkdownLineRenderPlan { public func renderPlan(for line: EditorLine) -> HybridMarkdownLineRenderPlan {
let kind = lineKind(for: line) let kind = lineKind(for: line)
let spans = inlineSpans(in: line) let spans = inlineSpans(in: line, kind: kind)
return HybridMarkdownLineRenderPlan(line: line, kind: kind, spans: spans) return HybridMarkdownLineRenderPlan(line: line, kind: kind, spans: spans)
} }
private func lineKind(for line: EditorLine) -> HybridMarkdownLineKind { public func renderPlans(for lines: [EditorLine]) -> [HybridMarkdownLineRenderPlan] {
let markerCount = line.source.prefix { $0 == "#" }.count var isInCodeBlock = false
guard (1...6).contains(markerCount), var plans: [HybridMarkdownLineRenderPlan] = []
line.source.dropFirst(markerCount).first == " "
else { for line in lines {
return .paragraph 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)
))
} }
let textOffset = markerCount + 1 return plans
}
private func lineKind(for line: EditorLine) -> HybridMarkdownLineKind {
if let fence = fencedCodeFence(in: line) {
return fence
}
if isHorizontalRule(line.source) {
return .horizontalRule(range: line.range)
}
if let heading = heading(in: line) {
return heading
}
if let task = taskList(in: line) {
return task
}
if let unorderedList = unorderedList(in: line) {
return unorderedList
}
if let orderedList = orderedList(in: line) {
return orderedList
}
if let blockquote = blockquote(in: line) {
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
guard (1...6).contains(markerCount),
headingStart.dropFirst(markerCount).first == " "
else {
return nil
}
let textOffset = leadingWhitespace + markerCount + 1
return .heading( return .heading(
level: markerCount, level: markerCount,
markerRange: NSRange(location: line.range.location, length: markerCount), markerRange: NSRange(location: line.range.location + leadingWhitespace, length: markerCount),
textRange: NSRange( textRange: NSRange(
location: line.range.location + textOffset, location: line.range.location + textOffset,
length: max(0, line.range.length - textOffset) length: max(0, line.range.length - textOffset)
@ -62,8 +145,129 @@ public struct HybridMarkdownLineRenderer: Sendable {
) )
} }
private func inlineSpans(in line: EditorLine) -> [HybridMarkdownSpan] { 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? {
guard let match = firstMatch("^([ \\t]*)([-*])\\s+(.*)$", in: line.source),
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? {
guard let match = firstMatch("^([ \\t]*)(\\d+[.)])\\s+(.*)$", in: line.source),
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? {
guard let match = firstMatch("^([ \\t]*)([-*])\\s+(\\[[ xX]\\])\\s+(.*)$", in: line.source),
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? {
guard let match = firstMatch("^([ \\t]*)(```|~~~)([^`~]*)$", in: line.source) else { return nil }
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 }
let separatorRanges = regexMatches("\\|", in: line.source).map {
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] {
guard line.range.length > 0 else { return [] } guard line.range.length > 0 else { return [] }
if case .horizontalRule = kind { return [] }
if case .fencedCodeFence = kind { return [] }
if case .codeBlockContent = kind { return [] }
var spans: [HybridMarkdownSpan] = [] var spans: [HybridMarkdownSpan] = []
var excludedRanges: [NSRange] = [] var excludedRanges: [NSRange] = []
@ -80,12 +284,38 @@ public struct HybridMarkdownLineRenderer: Sendable {
excludedRanges.append(match.fullRange) excludedRanges.append(match.fullRange)
} }
collectMatches("__([^_\\n]+)__", 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)
}
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)
}
collectMatches("(https?://[^\\s<>()]+)", in: line, excluding: excludedRanges).forEach { match in
spans.append(HybridMarkdownSpan(range: match.fullRange, kind: .automaticLink))
excludedRanges.append(match.fullRange)
}
collectMatches("(?<!\\*)\\*([^*\\n]+)\\*(?!\\*)", in: line, excluding: excludedRanges).forEach { match in collectMatches("(?<!\\*)\\*([^*\\n]+)\\*(?!\\*)", in: line, excluding: excludedRanges).forEach { match in
spans.append(HybridMarkdownSpan(range: match.contentRange, kind: .italic)) spans.append(HybridMarkdownSpan(range: match.contentRange, kind: .italic))
spans.append(contentsOf: delimiterRanges(match.fullRange, leading: 1, trailing: 1)) spans.append(contentsOf: delimiterRanges(match.fullRange, leading: 1, trailing: 1))
excludedRanges.append(match.fullRange) excludedRanges.append(match.fullRange)
} }
collectMatches("(?<!_)_([^_\\n]+)_(?!_)", 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)
}
return spans.sorted { return spans.sorted {
if $0.range.location == $1.range.location { if $0.range.location == $1.range.location {
return $0.range.length < $1.range.length return $0.range.length < $1.range.length
@ -118,6 +348,37 @@ public struct HybridMarkdownLineRenderer: Sendable {
} }
} }
private func collectLinkMatches(in line: EditorLine, excluding excludedRanges: [NSRange]) -> [LinkMatch] {
guard let regex = try? NSRegularExpression(pattern: "(?<!!)\\[([^\\]\\n]+)\\]\\(([^)\\s\\n]+)\\)") else {
return []
}
return regex.matches(in: line.source, range: NSRange(location: 0, length: line.source.utf16.count))
.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)
]
)
}
}
private func delimiterRanges( private func delimiterRanges(
_ fullRange: NSRange, _ fullRange: NSRange,
leading: Int, leading: Int,
@ -131,6 +392,70 @@ public struct HybridMarkdownLineRenderer: Sendable {
) )
] ]
} }
private func firstMatch(_ pattern: String, in source: String) -> NSTextCheckingResult? {
regexMatches(pattern, in: source).first
}
private func regexMatches(_ pattern: String, in source: String) -> [NSTextCheckingResult] {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
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
}
} }
private struct InlineMatch { private struct InlineMatch {
@ -138,6 +463,13 @@ private struct InlineMatch {
var contentRange: NSRange var contentRange: NSRange
} }
private struct LinkMatch {
var fullRange: NSRange
var titleRange: NSRange
var urlRange: NSRange
var delimiterRanges: [NSRange]
}
private extension NSRange { private extension NSRange {
var upperBound: Int { var upperBound: Int {
location + length location + length

View file

@ -120,7 +120,7 @@ final class EditorStateTests: XCTestCase {
) )
} }
func testHybridRendererDoesNotPromoteLinksOrTasksInMilestoneTwo() { func testHybridRendererSupportsTaskListsAndLinks() {
let source = "- [ ] Task with [link](https://example.com)" let source = "- [ ] Task with [link](https://example.com)"
let line = EditorLine( let line = EditorLine(
index: 0, index: 0,
@ -131,8 +131,117 @@ final class EditorStateTests: XCTestCase {
let plan = HybridMarkdownLineRenderer().renderPlan(for: line) let plan = HybridMarkdownLineRenderer().renderPlan(for: line)
XCTAssertEqual(plan.kind, .paragraph) XCTAssertEqual(
XCTAssertTrue(plan.spans.isEmpty) plan.kind,
.taskList(
markerRange: NSRange(location: 0, length: 1),
checkboxRange: NSRange(location: 2, length: 3),
contentRange: NSRange(location: 6, length: 37),
checked: false,
nestingLevel: 0
)
)
XCTAssertTrue(plan.spans.contains { $0.kind == .link && (source as NSString).substring(with: $0.range) == "link" })
XCTAssertEqual(plan.spans.filter { $0.kind == .markdownDelimiter }.count, 4)
}
func testHybridRendererSupportsBlockquotesHorizontalRulesAndLists() {
let blockquoteSource = "> quoted **text**"
let blockquote = EditorLine(
index: 0,
source: blockquoteSource,
range: NSRange(location: 10, length: blockquoteSource.utf16.count),
mode: .rendered
)
let horizontalRule = EditorLine(
index: 1,
source: "---",
range: NSRange(location: 28, length: 3),
mode: .rendered
)
let nestedList = EditorLine(
index: 2,
source: " 1. nested item",
range: NSRange(location: 32, length: 16),
mode: .rendered
)
let renderer = HybridMarkdownLineRenderer()
XCTAssertEqual(
renderer.renderPlan(for: blockquote).kind,
.blockquote(
markerRange: NSRange(location: 10, length: 2),
contentRange: NSRange(location: 12, length: 15)
)
)
XCTAssertEqual(renderer.renderPlan(for: horizontalRule).kind, .horizontalRule(range: horizontalRule.range))
XCTAssertEqual(
renderer.renderPlan(for: nestedList).kind,
.orderedList(
markerRange: NSRange(location: 34, length: 2),
contentRange: NSRange(location: 37, length: 11),
nestingLevel: 1
)
)
}
func testHybridRendererSupportsAutomaticLinks() {
let source = "See https://example.com/docs for details"
let line = EditorLine(
index: 0,
source: source,
range: NSRange(location: 0, length: source.utf16.count),
mode: .rendered
)
let plan = HybridMarkdownLineRenderer().renderPlan(for: line)
XCTAssertTrue(plan.spans.contains {
$0.kind == .automaticLink && (source as NSString).substring(with: $0.range) == "https://example.com/docs"
})
}
func testHybridRendererSupportsFencedCodeBlocksWithLanguage() {
let source = "```swift\nlet value = 42\n```"
let lines = EditorActiveLineTracker.lines(from: source, activeLineIndex: 99)
let plans = HybridMarkdownLineRenderer().renderPlans(for: lines)
XCTAssertEqual(
plans[0].kind,
.fencedCodeFence(
markerRange: NSRange(location: 0, length: 3),
languageRange: NSRange(location: 3, length: 5)
)
)
XCTAssertEqual(plans[1].kind, .codeBlockContent)
XCTAssertEqual(plans[2].kind, .fencedCodeFence(markerRange: NSRange(location: 24, length: 3), languageRange: nil))
}
func testHybridRendererSupportsMarkdownTables() {
let header = "| Name | Value |"
let divider = "| ---- | ----- |"
let row = "| A | B |"
let source = [header, divider, row].joined(separator: "\n")
let lines = EditorActiveLineTracker.lines(from: source, activeLineIndex: 99)
let plans = HybridMarkdownLineRenderer().renderPlans(for: lines)
guard case .tableRow(let headerCells, let headerSeparators, false) = plans[0].kind else {
return XCTFail("Expected header table row.")
}
guard case .tableRow(_, _, true) = plans[1].kind else {
return XCTFail("Expected divider table row.")
}
guard case .tableRow(let rowCells, _, false) = plans[2].kind else {
return XCTFail("Expected body table row.")
}
XCTAssertEqual(headerCells.count, 2)
XCTAssertEqual(headerSeparators.count, 3)
XCTAssertEqual((source as NSString).substring(with: rowCells[0]), "A")
XCTAssertEqual((source as NSString).substring(with: rowCells[1]), "B")
} }
@MainActor @MainActor