feat(renderer): expand markdown fundamentals
This commit is contained in:
parent
e6bbf78d79
commit
a0407f9704
3 changed files with 634 additions and 17 deletions
|
|
@ -666,6 +666,9 @@ enum MarkdownTextStyler {
|
|||
let lines = invalidationPlan.isFullRender
|
||||
? lineIndex.editorLines(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
|
||||
for line in lines {
|
||||
resetAttributes(in: textStorage, line: line, textColor: textColor)
|
||||
|
|
@ -681,7 +684,9 @@ enum MarkdownTextStyler {
|
|||
styleRenderedLine(
|
||||
in: textStorage,
|
||||
line: line,
|
||||
renderPlan: renderer.renderPlan(for: line),
|
||||
renderPlan: renderPlansByLine[line.index] ?? renderer.renderPlan(for: line),
|
||||
textColor: textColor,
|
||||
backgroundColor: backgroundColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: accentColor
|
||||
)
|
||||
|
|
@ -705,19 +710,108 @@ enum MarkdownTextStyler {
|
|||
in textStorage: NSTextStorage,
|
||||
line: EditorLine,
|
||||
renderPlan: HybridMarkdownLineRenderPlan,
|
||||
textColor: PlatformColor,
|
||||
backgroundColor: PlatformColor,
|
||||
secondaryTextColor: PlatformColor,
|
||||
accentColor: PlatformColor
|
||||
) {
|
||||
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([
|
||||
.foregroundColor: secondaryTextColor,
|
||||
.font: monospacedFont(size: 13, weight: .regular)
|
||||
], range: markerRange)
|
||||
textStorage.addAttributes([
|
||||
.font: systemFont(size: headingFontSize(level: level), weight: .semibold)
|
||||
.font: systemFont(size: headingFontSize(level: level), weight: .semibold),
|
||||
.paragraphStyle: headingParagraphStyle(level: level)
|
||||
], 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(
|
||||
|
|
@ -745,6 +839,11 @@ enum MarkdownTextStyler {
|
|||
.font: monospacedFont(size: 15, weight: .regular),
|
||||
.backgroundColor: accentColor.withAlphaComponent(0.12)
|
||||
], range: span.range)
|
||||
case .link, .automaticLink:
|
||||
textStorage.addAttributes([
|
||||
.foregroundColor: accentColor,
|
||||
.underlineStyle: NSUnderlineStyle.single.rawValue
|
||||
], range: span.range)
|
||||
case .markdownDelimiter:
|
||||
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: span.range)
|
||||
}
|
||||
|
|
@ -756,7 +855,7 @@ enum MarkdownTextStyler {
|
|||
line: EditorLine,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
private static func systemFont(size: CGFloat, weight: NSFont.Weight) -> NSFont {
|
||||
NSFont.systemFont(ofSize: size, weight: weight)
|
||||
|
|
|
|||
|
|
@ -3,12 +3,28 @@ import Foundation
|
|||
public enum HybridMarkdownLineKind: Hashable, Sendable {
|
||||
case paragraph
|
||||
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 {
|
||||
case bold
|
||||
case italic
|
||||
case inlineCode
|
||||
case link
|
||||
case automaticLink
|
||||
case markdownDelimiter
|
||||
}
|
||||
|
||||
|
|
@ -39,22 +55,89 @@ public struct HybridMarkdownLineRenderer: Sendable {
|
|||
|
||||
public func renderPlan(for line: EditorLine) -> HybridMarkdownLineRenderPlan {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private func lineKind(for line: EditorLine) -> HybridMarkdownLineKind {
|
||||
let markerCount = line.source.prefix { $0 == "#" }.count
|
||||
guard (1...6).contains(markerCount),
|
||||
line.source.dropFirst(markerCount).first == " "
|
||||
else {
|
||||
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
|
||||
}
|
||||
|
||||
let textOffset = markerCount + 1
|
||||
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(
|
||||
level: markerCount,
|
||||
markerRange: NSRange(location: line.range.location, length: markerCount),
|
||||
markerRange: NSRange(location: line.range.location + leadingWhitespace, length: markerCount),
|
||||
textRange: NSRange(
|
||||
location: line.range.location + 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 [] }
|
||||
if case .horizontalRule = kind { return [] }
|
||||
if case .fencedCodeFence = kind { return [] }
|
||||
if case .codeBlockContent = kind { return [] }
|
||||
|
||||
var spans: [HybridMarkdownSpan] = []
|
||||
var excludedRanges: [NSRange] = []
|
||||
|
|
@ -80,12 +284,38 @@ public struct HybridMarkdownLineRenderer: Sendable {
|
|||
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
|
||||
spans.append(HybridMarkdownSpan(range: match.contentRange, kind: .italic))
|
||||
spans.append(contentsOf: delimiterRanges(match.fullRange, leading: 1, trailing: 1))
|
||||
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 {
|
||||
if $0.range.location == $1.range.location {
|
||||
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(
|
||||
_ fullRange: NSRange,
|
||||
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 {
|
||||
|
|
@ -138,6 +463,13 @@ private struct InlineMatch {
|
|||
var contentRange: NSRange
|
||||
}
|
||||
|
||||
private struct LinkMatch {
|
||||
var fullRange: NSRange
|
||||
var titleRange: NSRange
|
||||
var urlRange: NSRange
|
||||
var delimiterRanges: [NSRange]
|
||||
}
|
||||
|
||||
private extension NSRange {
|
||||
var upperBound: Int {
|
||||
location + length
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ final class EditorStateTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testHybridRendererDoesNotPromoteLinksOrTasksInMilestoneTwo() {
|
||||
func testHybridRendererSupportsTaskListsAndLinks() {
|
||||
let source = "- [ ] Task with [link](https://example.com)"
|
||||
let line = EditorLine(
|
||||
index: 0,
|
||||
|
|
@ -131,8 +131,117 @@ final class EditorStateTests: XCTestCase {
|
|||
|
||||
let plan = HybridMarkdownLineRenderer().renderPlan(for: line)
|
||||
|
||||
XCTAssertEqual(plan.kind, .paragraph)
|
||||
XCTAssertTrue(plan.spans.isEmpty)
|
||||
XCTAssertEqual(
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue