0: GFM markdown rendering — fenced code blocks, inline code, tables, lists, task lists, strikethrough, footnotes, horizontal rules

This commit is contained in:
jess 2026-04-04 22:30:51 -07:00
parent 94b0965be2
commit 6b5404f679
1 changed files with 342 additions and 2 deletions

View File

@ -160,20 +160,36 @@ func applySyntaxHighlighting(to textStorage: NSTextStorage) {
]
textStorage.setAttributes(baseAttrs, range: fullRange)
let fencedRanges = highlightFencedCodeBlocks(textStorage: textStorage, palette: palette, baseFont: baseFont)
let tableHeaderLines = findTableHeaderLines(textStorage: textStorage, fencedRanges: fencedRanges)
let nsText = text as NSString
var lineStart = 0
while lineStart < nsText.length {
let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0))
if isInsideFencedBlock(lineRange, fencedRanges: fencedRanges) {
lineStart = NSMaxRange(lineRange)
continue
}
let line = nsText.substring(with: lineRange)
let trimmed = line.trimmingCharacters(in: .whitespaces)
let isTableHeader = tableHeaderLines.contains(lineRange.location)
if highlightMarkdownLine(trimmed, lineRange: lineRange, textStorage: textStorage, baseFont: baseFont, palette: palette) {
if highlightMarkdownLine(trimmed, line: line, lineRange: lineRange, textStorage: textStorage, baseFont: baseFont, palette: palette, isTableHeader: isTableHeader) {
highlightInlineCode(textStorage: textStorage, lineRange: lineRange, palette: palette, baseFont: baseFont)
highlightStrikethrough(textStorage: textStorage, lineRange: lineRange, palette: palette)
highlightFootnoteRefs(textStorage: textStorage, lineRange: lineRange, palette: palette)
lineStart = NSMaxRange(lineRange)
continue
}
highlightCodeLine(line, lineRange: lineRange, textStorage: textStorage, syn: syn)
highlightInlineCode(textStorage: textStorage, lineRange: lineRange, palette: palette, baseFont: baseFont)
highlightStrikethrough(textStorage: textStorage, lineRange: lineRange, palette: palette)
highlightFootnoteRefs(textStorage: textStorage, lineRange: lineRange, palette: palette)
lineStart = NSMaxRange(lineRange)
}
@ -181,7 +197,7 @@ func applySyntaxHighlighting(to textStorage: NSTextStorage) {
highlightBlockComments(textStorage: textStorage, syn: syn, baseFont: baseFont)
}
private func highlightMarkdownLine(_ trimmed: String, lineRange: NSRange, textStorage: NSTextStorage, baseFont: NSFont, palette: CatppuccinPalette) -> Bool {
private func highlightMarkdownLine(_ trimmed: String, line: String, lineRange: NSRange, textStorage: NSTextStorage, baseFont: NSFont, palette: CatppuccinPalette, isTableHeader: Bool = false) -> Bool {
if trimmed.hasPrefix("## ") {
let hashRange = (textStorage.string as NSString).range(of: "##", range: lineRange)
if hashRange.location != NSNotFound {
@ -219,6 +235,48 @@ private func highlightMarkdownLine(_ trimmed: String, lineRange: NSRange, textSt
return true
}
// Horizontal rule
if isHorizontalRule(trimmed) {
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange)
textStorage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: lineRange)
return true
}
// Footnote definition
if trimmed.hasPrefix("[^") && trimmed.contains("]:") {
highlightFootnoteDefinition(textStorage: textStorage, lineRange: lineRange, palette: palette)
return true
}
// Task list (check before generic unordered list)
if trimmed.hasPrefix("- [ ] ") || trimmed.hasPrefix("- [x] ") || trimmed.hasPrefix("- [X] ") ||
trimmed.hasPrefix("* [ ] ") || trimmed.hasPrefix("* [x] ") || trimmed.hasPrefix("* [X] ") ||
trimmed.hasPrefix("+ [ ] ") || trimmed.hasPrefix("+ [x] ") || trimmed.hasPrefix("+ [X] ") {
highlightTaskList(trimmed, lineRange: lineRange, line: line, textStorage: textStorage, palette: palette)
return true
}
// Table rows
if trimmed.hasPrefix("|") {
let isSep = isTableSeparator(trimmed)
highlightTableLine(trimmed, lineRange: lineRange, textStorage: textStorage, palette: palette, baseFont: baseFont, isHeader: isTableHeader, isSeparator: isSep)
return true
}
// Unordered list
if let regex = try? NSRegularExpression(pattern: "^\\s*[-*+] "),
regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) != nil {
highlightUnorderedList(trimmed, lineRange: lineRange, line: line, textStorage: textStorage, palette: palette)
return true
}
// Ordered list
if let regex = try? NSRegularExpression(pattern: "^\\s*\\d+\\. "),
regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) != nil {
highlightOrderedList(trimmed, lineRange: lineRange, line: line, textStorage: textStorage, palette: palette)
return true
}
return false
}
@ -372,6 +430,288 @@ private func highlightBlockComments(textStorage: NSTextStorage, syn: Theme.Synta
}
}
// MARK: - Fenced Code Blocks
private func highlightFencedCodeBlocks(textStorage: NSTextStorage, palette: CatppuccinPalette, baseFont: NSFont) -> [NSRange] {
let text = textStorage.string
let nsText = text as NSString
let monoFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular)
var fencedRanges: [NSRange] = []
var lineStart = 0
var openFence: Int? = nil
while lineStart < nsText.length {
let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0))
let line = nsText.substring(with: lineRange)
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if openFence == nil {
if trimmed.hasPrefix("```") {
openFence = lineRange.location
// Mute the fence line
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange)
textStorage.addAttribute(.font, value: monoFont, range: lineRange)
// Language identifier after ```
if trimmed.count > 3 {
let langStart = (nsText as NSString).range(of: "```", range: lineRange)
if langStart.location != NSNotFound {
let after = langStart.location + langStart.length
let langRange = NSRange(location: after, length: NSMaxRange(lineRange) - after)
if langRange.length > 0 {
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: langRange)
}
}
}
}
} else {
if trimmed == "```" {
// Close fence
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange)
textStorage.addAttribute(.font, value: monoFont, range: lineRange)
let blockRange = NSRange(location: openFence!, length: NSMaxRange(lineRange) - openFence!)
fencedRanges.append(blockRange)
openFence = nil
} else {
// Content inside fenced block
textStorage.addAttribute(.font, value: monoFont, range: lineRange)
textStorage.addAttribute(.backgroundColor, value: palette.surface0, range: lineRange)
}
}
lineStart = NSMaxRange(lineRange)
}
return fencedRanges
}
private func isInsideFencedBlock(_ lineRange: NSRange, fencedRanges: [NSRange]) -> Bool {
for fenced in fencedRanges {
if lineRange.location >= fenced.location && NSMaxRange(lineRange) <= NSMaxRange(fenced) {
return true
}
}
return false
}
// MARK: - Inline Code
private func highlightInlineCode(textStorage: NSTextStorage, lineRange: NSRange, palette: CatppuccinPalette, baseFont: NSFont) {
guard let regex = try? NSRegularExpression(pattern: "`([^`]+)`") else { return }
let monoFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular)
let matches = regex.matches(in: textStorage.string, range: lineRange)
for match in matches {
let fullRange = match.range
let openTick = NSRange(location: fullRange.location, length: 1)
let closeTick = NSRange(location: fullRange.location + fullRange.length - 1, length: 1)
let contentRange = NSRange(location: fullRange.location + 1, length: fullRange.length - 2)
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: openTick)
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeTick)
if contentRange.length > 0 {
textStorage.addAttribute(.font, value: monoFont, range: contentRange)
textStorage.addAttribute(.backgroundColor, value: palette.surface0, range: contentRange)
}
}
}
// MARK: - Strikethrough
private func highlightStrikethrough(textStorage: NSTextStorage, lineRange: NSRange, palette: CatppuccinPalette) {
guard let regex = try? NSRegularExpression(pattern: "~~(.+?)~~") else { return }
let matches = regex.matches(in: textStorage.string, range: lineRange)
for match in matches {
let fullRange = match.range
let openMarker = NSRange(location: fullRange.location, length: 2)
let closeMarker = NSRange(location: fullRange.location + fullRange.length - 2, length: 2)
let contentRange = NSRange(location: fullRange.location + 2, length: fullRange.length - 4)
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: openMarker)
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeMarker)
if contentRange.length > 0 {
textStorage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: contentRange)
}
}
}
// MARK: - Footnotes
private func highlightFootnoteRefs(textStorage: NSTextStorage, lineRange: NSRange, palette: CatppuccinPalette) {
// Inline refs: [^label] (but not definitions which start line with [^label]:)
guard let regex = try? NSRegularExpression(pattern: "\\[\\^[^\\]]+\\](?!:)") else { return }
let matches = regex.matches(in: textStorage.string, range: lineRange)
for match in matches {
textStorage.addAttribute(.foregroundColor, value: palette.blue, range: match.range)
textStorage.addAttribute(.superscript, value: 1, range: match.range)
}
}
private func highlightFootnoteDefinition(textStorage: NSTextStorage, lineRange: NSRange, palette: CatppuccinPalette) {
guard let regex = try? NSRegularExpression(pattern: "^\\[\\^[^\\]]+\\]:") else { return }
let nsText = textStorage.string as NSString
let lineContent = nsText.substring(with: lineRange)
let localRange = NSRange(location: 0, length: (lineContent as NSString).length)
let matches = regex.matches(in: lineContent, range: localRange)
for match in matches {
let absRange = NSRange(location: lineRange.location + match.range.location, length: match.range.length)
textStorage.addAttribute(.foregroundColor, value: palette.lavender, range: absRange)
}
}
// MARK: - Tables
private func highlightTableLine(_ trimmed: String, lineRange: NSRange, textStorage: NSTextStorage, palette: CatppuccinPalette, baseFont: NSFont, isHeader: Bool, isSeparator: Bool) {
let monoFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular)
let boldMono = NSFontManager.shared.convert(monoFont, toHaveTrait: .boldFontMask)
textStorage.addAttribute(.font, value: monoFont, range: lineRange)
if isSeparator {
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange)
return
}
if isHeader {
textStorage.addAttribute(.font, value: boldMono, range: lineRange)
}
// Mute pipe delimiters
guard let pipeRegex = try? NSRegularExpression(pattern: "\\|") else { return }
let matches = pipeRegex.matches(in: textStorage.string, range: lineRange)
for match in matches {
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: match.range)
}
}
// MARK: - Lists and Horizontal Rules
private func highlightOrderedList(_ trimmed: String, lineRange: NSRange, line: String, textStorage: NSTextStorage, palette: CatppuccinPalette) {
guard let regex = try? NSRegularExpression(pattern: "^(\\s*)(\\d+\\.)( )") else { return }
let nsLine = line as NSString
let localRange = NSRange(location: 0, length: nsLine.length)
guard let match = regex.firstMatch(in: line, range: localRange) else { return }
let markerRange = match.range(at: 2)
let absMarker = NSRange(location: lineRange.location + markerRange.location, length: markerRange.length)
textStorage.addAttribute(.foregroundColor, value: palette.blue, range: absMarker)
applyListIndent(line: line, lineRange: lineRange, textStorage: textStorage)
}
private func highlightUnorderedList(_ trimmed: String, lineRange: NSRange, line: String, textStorage: NSTextStorage, palette: CatppuccinPalette) {
guard let regex = try? NSRegularExpression(pattern: "^(\\s*)([-*+])( )") else { return }
let nsLine = line as NSString
let localRange = NSRange(location: 0, length: nsLine.length)
guard let match = regex.firstMatch(in: line, range: localRange) else { return }
let bulletRange = match.range(at: 2)
let absBullet = NSRange(location: lineRange.location + bulletRange.location, length: bulletRange.length)
textStorage.addAttribute(.foregroundColor, value: palette.blue, range: absBullet)
applyListIndent(line: line, lineRange: lineRange, textStorage: textStorage)
}
private func highlightTaskList(_ trimmed: String, lineRange: NSRange, line: String, textStorage: NSTextStorage, palette: CatppuccinPalette) {
let checked: Bool
if trimmed.hasPrefix("- [ ] ") || trimmed.hasPrefix("* [ ] ") || trimmed.hasPrefix("+ [ ] ") {
checked = false
} else if trimmed.hasPrefix("- [x] ") || trimmed.hasPrefix("* [x] ") || trimmed.hasPrefix("+ [x] ") ||
trimmed.hasPrefix("- [X] ") || trimmed.hasPrefix("* [X] ") || trimmed.hasPrefix("+ [X] ") {
checked = true
} else {
return
}
// Find the checkbox in the actual line
guard let cbRegex = try? NSRegularExpression(pattern: "\\[[ xX]\\]") else { return }
guard let cbMatch = cbRegex.firstMatch(in: textStorage.string, range: lineRange) else { return }
let cbRange = cbMatch.range
if checked {
textStorage.addAttribute(.foregroundColor, value: palette.green, range: cbRange)
let afterCb = NSRange(location: cbRange.location + cbRange.length, length: NSMaxRange(lineRange) - (cbRange.location + cbRange.length))
if afterCb.length > 0 {
textStorage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: afterCb)
}
} else {
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: cbRange)
}
// Bullet coloring
guard let bulletRegex = try? NSRegularExpression(pattern: "^(\\s*)([-*+])") else { return }
let nsLine = line as NSString
guard let bMatch = bulletRegex.firstMatch(in: line, range: NSRange(location: 0, length: nsLine.length)) else { return }
let bulletRange = bMatch.range(at: 2)
let absBullet = NSRange(location: lineRange.location + bulletRange.location, length: bulletRange.length)
textStorage.addAttribute(.foregroundColor, value: palette.blue, range: absBullet)
applyListIndent(line: line, lineRange: lineRange, textStorage: textStorage)
}
private func applyListIndent(line: String, lineRange: NSRange, textStorage: NSTextStorage) {
let leading = line.prefix(while: { $0 == " " || $0 == "\t" })
let spaces = leading.filter { $0 == " " }.count
let tabs = leading.filter { $0 == "\t" }.count
let level = tabs + spaces / 2
if level > 0 {
let indent = NSMutableParagraphStyle()
let px = CGFloat(level) * 20.0
indent.headIndent = px
indent.firstLineHeadIndent = px
textStorage.addAttribute(.paragraphStyle, value: indent, range: lineRange)
}
}
private func isHorizontalRule(_ trimmed: String) -> Bool {
if trimmed.isEmpty { return false }
let stripped = trimmed.replacingOccurrences(of: " ", with: "")
if stripped.count < 3 { return false }
let allDash = stripped.allSatisfy { $0 == "-" }
let allStar = stripped.allSatisfy { $0 == "*" }
let allUnderscore = stripped.allSatisfy { $0 == "_" }
return allDash || allStar || allUnderscore
}
private func findTableHeaderLines(textStorage: NSTextStorage, fencedRanges: [NSRange]) -> Set<Int> {
var headerStarts = Set<Int>()
let nsText = textStorage.string as NSString
var lineStart = 0
var prevLineStart: Int? = nil
var prevTrimmed: String? = nil
while lineStart < nsText.length {
let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0))
if !isInsideFencedBlock(lineRange, fencedRanges: fencedRanges) {
let line = nsText.substring(with: lineRange)
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("|"), isTableSeparator(trimmed),
let pStart = prevLineStart, let pTrimmed = prevTrimmed,
pTrimmed.hasPrefix("|") {
headerStarts.insert(pStart)
}
prevLineStart = lineRange.location
prevTrimmed = trimmed
} else {
prevLineStart = nil
prevTrimmed = nil
}
lineStart = NSMaxRange(lineRange)
}
return headerStarts
}
private func isTableSeparator(_ trimmed: String) -> Bool {
guard trimmed.hasPrefix("|") else { return false }
let inner = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "| "))
guard !inner.isEmpty else { return false }
let cells = inner.components(separatedBy: "|")
return cells.allSatisfy { cell in
let c = cell.trimmingCharacters(in: .whitespaces)
guard let regex = try? NSRegularExpression(pattern: "^:?-{1,}:?$") else { return false }
return regex.firstMatch(in: c, range: NSRange(location: 0, length: (c as NSString).length)) != nil
}
}
// MARK: - LineNumberTextView
class LineNumberTextView: NSTextView {