textplus/Sources/Markdown.swift
monster 09972ccde1 TextPlus: native macOS plaintext editor with sync, scraping, and a freeform board
A no-Xcode AppKit app (swiftc + build.sh):
- Plaintext editor: 5 themes, line numbers, soft wrap, font controls, find,
  live Markdown preview (split mode), selection-aware Markdown toolbar.
- Freeform Board mode: zoomable/pannable canvas of page cards (portrait/landscape,
  US Letter/A4/Legal/Square/Wide/Note), clean boxes, images (paste/drop/panel),
  freehand pen/shapes, screen-grab eyedropper, nested right-click menu, rich
  status bar. The Editor follows the selected page; layout persists to a
  foo.txt.board sidecar; the .txt stays pure plaintext.
- scp-on-save to a Tailscale Mac (key auth, in-flight/quit drain).
- HTML scraping pipeline: custom selector engine + Discogs profile, ingests to
  Postgres (psql) into a marketplace_snapshots schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:08:59 +10:00

293 lines
12 KiB
Swift

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..<end])
result.append(NSAttributedString(string: s, attributes: [.font: font, .foregroundColor: theme.foreground]))
}
}
func emphasisFont(bold: Bool, italic: Bool) -> 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)..<close])
let bg = theme.isDark ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06)
result.append(NSAttributedString(string: codeText, attributes: [
.font: monoFont(baseSize - 1), .foregroundColor: theme.accent, .backgroundColor: bg,
]))
i = close + 1
plainStart = i
continue
}
}
// Bold/italic ** * __ _
if c == "*" || c == "_" {
var run = 0
while i + run < chars.count && chars[i + run] == c { run += 1 }
let count = min(run, 3)
if let close = matchRun(c, count: count, from: i + count) {
flushPlain(upTo: i)
let innerText = String(chars[(i + count)..<close])
let bold = count >= 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)..<closeBracket])
let url = String(chars[(closeBracket + 2)..<closeParen])
var attrs: [NSAttributedString.Key: Any] = [
.font: font, .foregroundColor: theme.accent,
.underlineStyle: NSUnderlineStyle.single.rawValue,
]
if let u = URL(string: url) { attrs[.link] = u }
result.append(NSAttributedString(string: label, attributes: attrs))
i = closeParen + 1
plainStart = i
continue
}
}
i += 1
}
flushPlain(upTo: chars.count)
return result
}
}