import AppKit /// A pragmatic Markdown → NSAttributedString renderer for the live preview. /// Handles the constructs people actually type: ATX headings, bold/italic/ /// bold-italic, inline code, fenced & indented code, blockquotes, ordered and /// unordered lists, horizontal rules, and links. Colors come from the theme. enum MarkdownRenderer { static func render(_ markdown: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString { let out = NSMutableAttributedString() let lines = markdown.components(separatedBy: "\n") var i = 0 var inFence = false var fenceBuffer: [String] = [] func flushFence() { let code = fenceBuffer.joined(separator: "\n") out.append(codeBlock(code, theme: theme, baseSize: baseSize)) fenceBuffer = [] } while i < lines.count { let line = lines[i] let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("```") || trimmed.hasPrefix("~~~") { if inFence { flushFence(); inFence = false } else { inFence = true } i += 1 continue } if inFence { fenceBuffer.append(line); i += 1; continue } // Horizontal rule if trimmed == "---" || trimmed == "***" || trimmed == "___" { out.append(horizontalRule(theme: theme, baseSize: baseSize)) i += 1 continue } // ATX heading if let (level, content) = heading(trimmed) { out.append(headingLine(content, level: level, theme: theme, baseSize: baseSize)) i += 1 continue } // Blockquote if trimmed.hasPrefix(">") { let content = String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces) out.append(blockquote(content, theme: theme, baseSize: baseSize)) i += 1 continue } // Unordered list item if let marker = unorderedMarker(trimmed) { let content = String(trimmed.dropFirst(marker.count)).trimmingCharacters(in: .whitespaces) out.append(listItem(bullet: "• ", content: content, theme: theme, baseSize: baseSize)) i += 1 continue } // Ordered list item if let (number, content) = orderedItem(trimmed) { out.append(listItem(bullet: "\(number). ", content: content, theme: theme, baseSize: baseSize)) i += 1 continue } // Blank line → paragraph spacing if trimmed.isEmpty { out.append(NSAttributedString(string: "\n", attributes: [.font: bodyFont(baseSize)])) i += 1 continue } // Paragraph let para = inline(trimmed, theme: theme, baseSize: baseSize, font: bodyFont(baseSize)) out.append(para) out.append(NSAttributedString(string: "\n", attributes: [.font: bodyFont(baseSize)])) i += 1 } if inFence { flushFence() } // Every builder already sets its own foreground color, so there's no // blanket recolor here — that would clobber links, code, and accents. return out } // MARK: - Block builders private static func bodyFont(_ size: CGFloat) -> NSFont { NSFont.systemFont(ofSize: size) } private static func monoFont(_ size: CGFloat) -> NSFont { NSFont.monospacedSystemFont(ofSize: size, weight: .regular) } private static func heading(_ s: String) -> (Int, String)? { var level = 0 for ch in s { if ch == "#" { level += 1 } else { break } } guard level >= 1, level <= 6 else { return nil } let rest = s.dropFirst(level) guard rest.first == " " || rest.isEmpty else { return nil } return (level, rest.trimmingCharacters(in: .whitespaces)) } private static func unorderedMarker(_ s: String) -> String? { for m in ["- ", "* ", "+ "] where s.hasPrefix(m) { return String(m.dropLast()) } return nil } private static func orderedItem(_ s: String) -> (Int, String)? { var digits = "" for ch in s { if ch.isNumber { digits.append(ch) } else { break } } guard let n = Int(digits) else { return nil } let rest = s.dropFirst(digits.count) guard rest.first == "." || rest.first == ")" else { return nil } let content = rest.dropFirst().trimmingCharacters(in: .whitespaces) return (n, content) } private static func headingLine(_ content: String, level: Int, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString { let scale: CGFloat = [1.9, 1.55, 1.3, 1.15, 1.05, 1.0][min(level - 1, 5)] let weight: NSFont.Weight = level <= 2 ? .bold : .semibold let font = NSFont.systemFont(ofSize: round(baseSize * scale), weight: weight) let para = NSMutableParagraphStyle() para.paragraphSpacingBefore = baseSize * 0.6 para.paragraphSpacing = baseSize * 0.3 let attr = inline(content, theme: theme, baseSize: baseSize, font: font) let m = NSMutableAttributedString(attributedString: attr) m.addAttributes([.paragraphStyle: para], range: NSRange(location: 0, length: m.length)) m.append(NSAttributedString(string: "\n")) return m } private static func codeBlock(_ code: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString { let para = NSMutableParagraphStyle() para.firstLineHeadIndent = 12 para.headIndent = 12 para.paragraphSpacingBefore = baseSize * 0.3 para.paragraphSpacing = baseSize * 0.3 let bg = theme.isDark ? NSColor.white.withAlphaComponent(0.06) : NSColor.black.withAlphaComponent(0.05) let attrs: [NSAttributedString.Key: Any] = [ .font: monoFont(baseSize - 1), .foregroundColor: theme.foreground, .backgroundColor: bg, .paragraphStyle: para, ] return NSAttributedString(string: code + "\n", attributes: attrs) } private static func blockquote(_ content: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString { let para = NSMutableParagraphStyle() para.firstLineHeadIndent = 14 para.headIndent = 14 para.paragraphSpacingBefore = 2 para.paragraphSpacing = 2 let attr = inline(content, theme: theme, baseSize: baseSize, font: NSFontManager.shared.convert(bodyFont(baseSize), toHaveTrait: .italicFontMask)) let m = NSMutableAttributedString(attributedString: attr) m.addAttributes([.paragraphStyle: para, .foregroundColor: theme.secondaryText], range: NSRange(location: 0, length: m.length)) m.append(NSAttributedString(string: "\n")) return m } private static func listItem(bullet: String, content: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString { let para = NSMutableParagraphStyle() para.firstLineHeadIndent = 16 para.headIndent = 16 + (bullet as NSString).size(withAttributes: [.font: bodyFont(baseSize)]).width let m = NSMutableAttributedString() m.append(NSAttributedString(string: bullet, attributes: [ .font: bodyFont(baseSize), .foregroundColor: theme.accent, .paragraphStyle: para, ])) let body = inline(content, theme: theme, baseSize: baseSize, font: bodyFont(baseSize)) let bodyM = NSMutableAttributedString(attributedString: body) bodyM.addAttribute(.paragraphStyle, value: para, range: NSRange(location: 0, length: bodyM.length)) m.append(bodyM) m.append(NSAttributedString(string: "\n")) return m } private static func horizontalRule(theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString { let para = NSMutableParagraphStyle() para.paragraphSpacingBefore = baseSize * 0.4 para.paragraphSpacing = baseSize * 0.4 return NSAttributedString(string: "________________________\n", attributes: [ .font: bodyFont(baseSize), .foregroundColor: theme.secondaryText.withAlphaComponent(0.4), .paragraphStyle: para, ]) } // MARK: - Inline parsing /// Parses bold/italic/code/links inside a single line. `font` is the base /// font for this block (so headings keep their size while going bold etc). private static func inline(_ text: String, theme: EditorTheme, baseSize: CGFloat, font: NSFont) -> NSAttributedString { let result = NSMutableAttributedString() let chars = Array(text) var i = 0 var plainStart = 0 func flushPlain(upTo end: Int) { if end > plainStart { let s = String(chars[plainStart.. NSFont { var f = font let mgr = NSFontManager.shared if bold { f = mgr.convert(f, toHaveTrait: .boldFontMask) } if italic { f = mgr.convert(f, toHaveTrait: .italicFontMask) } return f } func matchRun(_ marker: Character, count: Int, from start: Int) -> Int? { // Find a closing run of `count` markers after start. var j = start while j < chars.count { if chars[j] == marker { var run = 0 while j + run < chars.count && chars[j + run] == marker { run += 1 } if run >= count { return j } j += run } else { j += 1 } } return nil } while i < chars.count { let c = chars[i] // Inline code `...` if c == "`" { if let close = chars[(i + 1)...].firstIndex(of: "`") { flushPlain(upTo: i) let codeText = String(chars[(i + 1)..= 2 let italic = count == 1 || count == 3 let inner = inline(innerText, theme: theme, baseSize: baseSize, font: emphasisFont(bold: bold, italic: italic)) result.append(inner) i = close + count plainStart = i continue } } // Link [text](url) if c == "[" { if let closeBracket = chars[(i + 1)...].firstIndex(of: "]"), closeBracket + 1 < chars.count, chars[closeBracket + 1] == "(", let closeParen = chars[(closeBracket + 2)...].firstIndex(of: ")") { flushPlain(upTo: i) let label = String(chars[(i + 1)..