import Foundation import Markdown // MARK: - Custom Parsers // // Regex / scan-based passes for inline constructs that swift-markdown does not // model. Each appends to the span list built by the AST walker (see parse()): // // - parseHighlight ==text== // - parseDisplayMath $$\u{2026}$$ (block pre-merged by BlockParser) // - parseMath $\u{2026}$ (Pandoc-style disambiguation) // - parseLineBreak trailing backslash hard break // - parseIndentedListItem 4+ space list items swift-markdown treats as code extension SyntaxHighlighter { private static let footnoteDefRegex = try! NSRegularExpression(pattern: #"^\[\^([^\]\s]+)\]:"#) private static let footnoteRefRegex = try! NSRegularExpression(pattern: #"\[\^([^\]\s]+)\]"#) /// Parses footnotes (not supported by swift-markdown): /// - `[^id]:` at the start of a block → a `.footnoteDefinition` marker. /// - `[^id]` elsewhere → a `.footnoteReference`. static func parseFootnotes(_ text: String, into spans: inout [Span]) { let ns = text as NSString let whole = NSRange(location: 0, length: ns.length) // Definition marker at the very start of the block: `[^id]:`. if let m = footnoteDefRegex.firstMatch(in: text, range: whole) { let marker = m.range(at: 0) // includes the trailing ":" spans.append(Span( kind: .footnoteDefinition(id: ns.substring(with: m.range(at: 1))), fullRange: marker, contentRange: m.range(at: 1), delimiterRanges: [marker])) } // References `[^id]` anywhere — except the definition marker (followed by // ":") and anything overlapping a code span or the definition above. for m in footnoteRefRegex.matches(in: text, range: whole) { let full = m.range(at: 0) if full.upperBound < ns.length && ns.character(at: full.upperBound) == 0x3A { continue } let overlaps = spans.contains { existing in switch existing.kind { case .code, .codeBlock, .footnoteDefinition: break default: return false } return existing.fullRange.location <= full.location && existing.fullRange.upperBound >= full.upperBound } guard !overlaps else { continue } spans.append(Span( kind: .footnoteReference(id: ns.substring(with: m.range(at: 1))), fullRange: full, contentRange: m.range(at: 1), // the id delimiterRanges: [NSRange(location: full.location, length: 2), // "[^" NSRange(location: full.upperBound - 1, length: 1)])) // "]" } } private static let commentRegex = try! NSRegularExpression(pattern: "%%([\\s\\S]*?)%%", options: []) /// Parses Obsidian-style `%%comment%%` spans (not supported by /// swift-markdown). Matches across newlines within a block; skips `%%` /// inside code spans / code blocks. static func parseComments(_ text: String, into spans: inout [Span]) { let ns = text as NSString for m in commentRegex.matches(in: text, range: NSRange(location: 0, length: ns.length)) { let full = m.range(at: 0) let overlaps = spans.contains { existing in switch existing.kind { case .code, .codeBlock: break default: return false } return existing.fullRange.location <= full.location && existing.fullRange.upperBound >= full.upperBound } guard !overlaps else { continue } spans.append(Span( kind: .comment, fullRange: full, contentRange: m.range(at: 1), delimiterRanges: [NSRange(location: full.location, length: 2), NSRange(location: full.upperBound - 2, length: 2)])) } } private static let htmlCommentRegex = try! NSRegularExpression(pattern: "") /// Parses HTML `` spans into the same `.comment` kind as /// `%%…%%` (dimmed in edit mode, hidden in reading view; inner spans are /// dropped by the opaque-range pass). Skips comments inside code / math. static func parseHTMLComments(_ text: String, into spans: inout [Span]) { let ns = text as NSString for m in htmlCommentRegex.matches(in: text, range: NSRange(location: 0, length: ns.length)) { let full = m.range(at: 0) let overlaps = spans.contains { existing in switch existing.kind { case .code, .codeBlock, .math: break default: return false } return existing.fullRange.location <= full.location && existing.fullRange.upperBound >= full.upperBound } guard !overlaps else { continue } spans.append(Span( kind: .comment, fullRange: full, contentRange: NSRange(location: full.location + 4, length: full.length - 7), delimiterRanges: [NSRange(location: full.location, length: 4), NSRange(location: full.upperBound - 3, length: 3)])) } } private static let wikiLinkRegex = try! NSRegularExpression(pattern: #"\[\[([^\[\]\n]+?)\]\]"#) /// Parses Obsidian-style `[[target]]`, `[[target#heading]]`, and /// `[[target|alias]]` internal links. The span's `contentRange` is the /// visible display text (the alias when present, else the target); the /// `[[`, an optional `target|`, and the `]]` are delimiter ranges hidden /// when rendered. Skips `[[` inside code spans / code blocks. static func parseWikiLinks(_ text: String, into spans: inout [Span]) { let ns = text as NSString for m in wikiLinkRegex.matches(in: text, range: NSRange(location: 0, length: ns.length)) { let full = m.range(at: 0) let inner = m.range(at: 1) let innerNS = ns.substring(with: inner) as NSString guard innerNS.length > 0 else { continue } let overlaps = spans.contains { existing in switch existing.kind { case .code, .codeBlock: break default: return false } return existing.fullRange.location <= full.location && existing.fullRange.upperBound >= full.upperBound } guard !overlaps else { continue } // Split target | alias on the first "|". let pipe = innerNS.range(of: "|") let targetRel = pipe.location == NSNotFound ? NSRange(location: 0, length: innerNS.length) : NSRange(location: 0, length: pipe.location) var displayRel = pipe.location == NSNotFound ? targetRel : NSRange(location: pipe.upperBound, length: innerNS.length - pipe.upperBound) if displayRel.length == 0 { displayRel = targetRel } // "[[Note|]]" → show target let target = innerNS.substring(with: targetRel).trimmingCharacters(in: .whitespaces) guard !target.isEmpty || pipe.location != NSNotFound else { continue } let content = NSRange(location: inner.location + displayRel.location, length: displayRel.length) let leading = NSRange(location: full.location, length: content.location - full.location) let trailing = NSRange(location: content.upperBound, length: full.upperBound - content.upperBound) spans.append(Span( kind: .wikilink(target: target), fullRange: full, contentRange: content, delimiterRanges: [leading, trailing])) } } /// Parses ==highlight== spans using regex (not supported by swift-markdown). /// GFM-style flanking: the content must not begin or end with whitespace /// (`== spaced ==` stays literal), matching how cmark treats `**`/`~~`. static func parseHighlight(_ text: String, into spans: inout [Span]) { let nsText = text as NSString guard let regex = try? NSRegularExpression(pattern: "==(?!\\s)(.+?)(?= full.upperBound } guard !overlaps else { continue } let openDelim = NSRange(location: full.location, length: 2) let closeDelim = NSRange(location: full.upperBound - 2, length: 2) spans.append(Span( kind: .highlight, fullRange: full, contentRange: content, delimiterRanges: [openDelim, closeDelim] )) } } /// Scans for `$$…$$` display math runs. A run can own its whole block /// (`BlockParser` merges a multi-line `$$ … $$` into one block, so content /// may span newlines) or sit inline within a prose line (`text $$x$$ more`). /// /// Tightness (space/tab, NOT newline) guards against prose false positives /// like "pay $$5 and $$6": a `$$` delimiter must abut non-space on the inner /// side — mirrors the Pandoc rule in `parseMath`. Newlines are allowed so a /// block-merged `$$\n … \n$$` still matches. Runs before `parseMath`, which /// skips ranges inside a `.math(display: true)` span. static func parseDisplayMath(_ text: String, into spans: inout [Span]) { let ns = text as NSString let n = ns.length let dollar: unichar = 0x24, backslash: unichar = 0x5C // Same-line whitespace only; newlines are legal inside a display block. func isSpace(_ c: unichar) -> Bool { c == 0x20 || c == 0x09 } var i = 0 while i < n { let c = ns.character(at: i) if c == backslash { i += 2; continue } // skip escaped char // Opening `$$`, abutting a non-space on its inner side. guard c == dollar, i + 1 < n, ns.character(at: i + 1) == dollar else { i += 1; continue } let afterOpen = i + 2 guard afterOpen < n, !isSpace(ns.character(at: afterOpen)) else { i += 1; continue } // Find the closing `$$`, abutting a non-space on its inner side. var j = afterOpen var closeLoc = -1 while j + 1 < n { let cj = ns.character(at: j) if cj == backslash { j += 2; continue } if cj == dollar && ns.character(at: j + 1) == dollar { if !isSpace(ns.character(at: j - 1)) { closeLoc = j; break } j += 2; continue // `$$` preceded by space isn't a valid close } j += 1 } guard closeLoc > afterOpen else { i += 2; continue } // no close / empty content spans.append(Span( kind: .math(display: true), fullRange: NSRange(location: i, length: closeLoc + 2 - i), contentRange: NSRange(location: afterOpen, length: closeLoc - afterOpen), delimiterRanges: [NSRange(location: i, length: 2), NSRange(location: closeLoc, length: 2)] )) i = closeLoc + 2 } } /// Scans for inline `$…$` math. Uses Pandoc-style disambiguation so prose /// like "it cost $5 to $10" is left alone: /// - the opening `$` is immediately followed by a non-space, non-`$` char, /// - the closing `$` is immediately preceded by a non-space char and is /// not followed by a digit, /// - `\$` is a literal escape, `$$` is skipped (display math, later phase), /// - inline math never spans a newline. static func parseMath(_ text: String, into spans: inout [Span]) { let ns = text as NSString let n = ns.length let dollar: unichar = 0x24, backslash: unichar = 0x5C, newline: unichar = 0x0A func isSpace(_ c: unichar) -> Bool { c == 0x20 || c == 0x09 } func isDigit(_ c: unichar) -> Bool { c >= 0x30 && c <= 0x39 } var i = 0 while i < n { let c = ns.character(at: i) if c == backslash { i += 2; continue } // skip escaped char if c != dollar { i += 1; continue } // Skip display `$$` (handled per-block in a later phase). if i + 1 < n && ns.character(at: i + 1) == dollar { i += 2; continue } // Opening `$`: must be followed by a non-space, non-`$` character. guard i + 1 < n else { break } let next = ns.character(at: i + 1) if isSpace(next) || next == dollar || next == newline { i += 1; continue } // Find the closing `$`. var j = i + 1 var close = -1 while j < n { let cj = ns.character(at: j) if cj == backslash { j += 2; continue } if cj == newline { break } // inline math stays on one line if cj == dollar { let prev = ns.character(at: j - 1) let isDouble = j + 1 < n && ns.character(at: j + 1) == dollar let nextIsDigit = j + 1 < n && isDigit(ns.character(at: j + 1)) if !isDouble && !isSpace(prev) && !nextIsDigit { close = j; break } } j += 1 } guard close > i + 1 else { i += 1; continue } let full = NSRange(location: i, length: close - i + 1) // Don't match inside code spans or a display-math block. let overlaps = spans.contains { existing in switch existing.kind { case .code, .codeBlock, .math(display: true): return existing.fullRange.location <= full.location && existing.fullRange.upperBound >= full.upperBound default: return false } } if !overlaps { spans.append(Span( kind: .math(display: false), fullRange: full, contentRange: NSRange(location: i + 1, length: close - i - 1), delimiterRanges: [NSRange(location: i, length: 1), NSRange(location: close, length: 1)] )) } i = close + 1 } } /// The set of ASCII-punctuation characters CommonMark allows a backslash to /// escape (§2.4). A `\` before any other character is a literal backslash. private static let escapableChars: Set = { let punct = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" return Set((punct as NSString).description.utf16) }() /// Parses CommonMark backslash escapes: a `\` followed by an escapable /// punctuation char. The backslash becomes the span's hidden/dimmed /// delimiter; the escaped char renders literally (swift-markdown already /// strips the escape from the AST text, so no inline span double-styles it). /// Skips escapes inside code / math / a trailing-`\` line break so those keep /// their raw source (e.g. `\,` inside `$…$` stays a LaTeX command). static func parseEscapes(_ text: String, into spans: inout [Span]) { let ns = text as NSString let n = ns.length let backslash: unichar = 0x5C var i = 0 while i < n - 1 { guard ns.character(at: i) == backslash, escapableChars.contains(ns.character(at: i + 1)) else { i += 1; continue } let full = NSRange(location: i, length: 2) let overlaps = spans.contains { existing in switch existing.kind { case .code, .codeBlock, .math, .lineBreak: return existing.fullRange.location <= full.location && existing.fullRange.upperBound >= full.upperBound default: return false } } if !overlaps { spans.append(Span( kind: .escape, fullRange: full, contentRange: NSRange(location: i + 1, length: 1), delimiterRanges: [NSRange(location: i, length: 1)])) } // Consume both chars so `\\` is one escape (and the 2nd `\` can't // start another escape or be read as a trailing line break). i += 2 } } /// Whitelisted HTML formatting tags rendered (not just colored). The inner /// content keeps its own markdown styling. Built from `htmlFormatTags` so the /// Edit and Read whitelists share one source of truth. /// Known ceiling: the open tag's attr swallow `(?:\s[^>]*)?` breaks on a `>` /// inside a quoted attribute of a whitelist pair open tag — the pair then /// falls back to two colored `.htmlTag` tokens (acceptable). private static let htmlPairRegex: NSRegularExpression = { let names = htmlFormatTags.sorted().joined(separator: "|") return try! NSRegularExpression( pattern: "<(\(names))(?:\\s[^>]*)?>(.*?)", options: [.caseInsensitive, .dotMatchesLineSeparators]) }() /// Any single inline HTML tag per GFM §6.10: an open tag (group 1 = name, /// full attribute grammar — names may contain hyphens, attribute values may /// be double-quoted, single-quoted, or unquoted, and quoted values may /// contain `>`), or a closing tag (group 2 = name; no attributes allowed). private static let htmlTagRegex = try! NSRegularExpression(pattern: #"<(?:([A-Za-z][A-Za-z0-9-]*)(?:\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\s*=\s*(?:[^\s"'=<>`]+|'[^']*'|"[^"]*"))?)*\s*/?|/([A-Za-z][A-Za-z0-9-]*)\s*)>"#) /// §6.10 processing instructions ``, declarations ``, and /// CDATA `` — shown as dimmed source. HTML comments are handled /// (more laxly than spec — interior `--` allowed, deliberate divergence) by /// parseHTMLComments, which runs first. private static let htmlOtherRegex = try! NSRegularExpression( pattern: #"<\?[\s\S]*?\?>|]*>|"#) // `` attribute extractors — double-, single-, and unquoted values // (§6.10). Exactly one of groups 1–3 participates per match. Shared with the // read-mode renderer so both back-ends accept the same tags. static let imgSrcRegex = try! NSRegularExpression( pattern: #"\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))"#, options: [.caseInsensitive]) static let imgAltRegex = try! NSRegularExpression( pattern: #"\salt\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))"#, options: [.caseInsensitive]) static let imgWidthRegex = try! NSRegularExpression( pattern: #"\swidth\s*=\s*(?:"(\d+)"|'(\d+)'|(\d+))"#, options: [.caseInsensitive]) static let imgHeightRegex = try! NSRegularExpression( pattern: #"\sheight\s*=\s*(?:"(\d+)"|'(\d+)'|(\d+))"#, options: [.caseInsensitive]) /// The matched value range: whichever of groups 1–3 participated. static func attrValueRange(_ m: NSTextCheckingResult) -> NSRange { for i in 1...3 where m.range(at: i).location != NSNotFound { return m.range(at: i) } return m.range(at: 0) } /// Parses inline HTML tags. Two tiers: /// - a whitelisted pair (`…`, ``, ``, ``, ``) /// becomes a `.htmlFormat` span whose tags hide and whose content takes a /// rendered attribute; /// - any other recognized tag becomes a `.htmlTag` span shown as colored /// source (the open/close tags of a pair are not re-emitted). /// Skips tags inside code / math, and a `\<`-escaped `<` (escapes run first). static func parseHTMLTags(_ text: String, into spans: inout [Span]) { let ns = text as NSString let whole = NSRange(location: 0, length: ns.length) // True if `r` sits inside a code/math span, or its `<` is an escaped `\<`. func guarded(_ r: NSRange) -> Bool { for span in spans { switch span.kind { case .code, .codeBlock, .math: if span.fullRange.location <= r.location && span.fullRange.upperBound >= r.upperBound { return true } case .escape: // The escape covers `\` + the escaped char; reject if it // covers this tag's opening `<`. if span.fullRange.location <= r.location && span.fullRange.upperBound > r.location { return true } default: break } } return false } // Pass 1: whitelist pairs render. Remember each pair's tag ranges so the // generic pass doesn't re-emit them (inner tags are still colored). var pairTagRanges: [NSRange] = [] for m in htmlPairRegex.matches(in: text, range: whole) { let full = m.range(at: 0) guard !guarded(full) else { continue } let name = ns.substring(with: m.range(at: 1)).lowercased() let content = m.range(at: 2) let openTag = NSRange(location: full.location, length: content.location - full.location) let closeTag = NSRange(location: content.upperBound, length: full.upperBound - content.upperBound) spans.append(Span(kind: .htmlFormat(tag: name), fullRange: full, contentRange: content, delimiterRanges: [openTag, closeTag])) pairTagRanges.append(openTag) pairTagRanges.append(closeTag) } // Pass 2: any other recognized tag → colored source. for m in htmlTagRegex.matches(in: text, range: whole) { let full = m.range(at: 0) guard !guarded(full) else { continue } if pairTagRanges.contains(where: { $0.location <= full.location && $0.upperBound >= full.upperBound }) { continue } // Group 1 = open-tag name, group 2 = closing-tag name. let nameR = m.range(at: 1).location != NSNotFound ? m.range(at: 1) : m.range(at: 2) // `` renders as an inline image (like `![](…)`), // optionally at declared pixel dimensions. Without a src the tag // stays colored source. if ns.substring(with: nameR).lowercased() == "img", let srcM = imgSrcRegex.firstMatch(in: text, range: full) { func intAttr(_ regex: NSRegularExpression) -> Int? { regex.firstMatch(in: text, range: full) .map { ns.substring(with: attrValueRange($0)) }.flatMap(Int.init) } spans.append(Span( kind: .image(destination: ns.substring(with: attrValueRange(srcM)), width: intAttr(imgWidthRegex), height: intAttr(imgHeightRegex)), fullRange: full, contentRange: attrValueRange(srcM), delimiterRanges: [])) continue } let pre = NSRange(location: full.location, length: nameR.location - full.location) let post = NSRange(location: nameR.upperBound, length: full.upperBound - nameR.upperBound) spans.append(Span(kind: .htmlTag, fullRange: full, contentRange: nameR, delimiterRanges: [pre, post])) } // Pass 3: PI / declaration / CDATA → dimmed source. Zero-length content + // full-range delimiter ⇒ the whole token dims (like a comment); tokens // inside a real are dropped by the opaque-range pass. for m in htmlOtherRegex.matches(in: text, range: whole) { let full = m.range(at: 0) guard !guarded(full) else { continue } spans.append(Span(kind: .htmlTag, fullRange: full, contentRange: NSRange(location: full.location, length: 0), delimiterRanges: [full])) } } // GFM autolinks extension. Group 1 is the allowed preceding character // (start of text, whitespace, or `*`/`_`/`~`/`(`); group 2 the candidate: // a scheme/www URL run or an email. Trailing punctuation, unbalanced `)`, // and `&entity;` suffixes are trimmed in code afterwards, then the domain // is validated (≥1 dot, no `_` in the last two labels). private static let autolinkRegex = try! NSRegularExpression( pattern: #"(^|[\s*_~(])((?:https?://|www\.)[^\s<]+|[A-Za-z0-9._+-]+@[A-Za-z0-9._-]+)"#, options: [.caseInsensitive]) /// Parses bare `www.…`/`http(s)://…`/`user@host` autolinks per the GFM /// autolinks extension (swift-markdown doesn't attach cmark's). Emits /// `.link` spans with no delimiters (the whole match is content). Skips /// candidates inside code, math, comments, wikilinks, real links/images, /// and HTML tags. Must run after every other pass. static func parseAutolinks(_ text: String, into spans: inout [Span]) { let ns = text as NSString func isTrimPunct(_ c: unichar) -> Bool { // ? ! . , : * _ ~ ' " switch c { case 0x3F, 0x21, 0x2E, 0x2C, 0x3A, 0x2A, 0x5F, 0x7E, 0x27, 0x22: return true default: return false } } func isAlnum(_ c: unichar) -> Bool { (c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A) || (c >= 0x30 && c <= 0x39) } // GFM trailing trim: strip punctuation, a `)` only while the match's // parens are unbalanced, and a trailing `&entity;`. func trimmedEnd(from start: Int, to initialEnd: Int) -> Int { var end = initialEnd loop: while end > start { let c = ns.character(at: end - 1) if isTrimPunct(c) { end -= 1; continue } if c == 0x29 { // ")" var opens = 0, closes = 0 for i in start.. opens { end -= 1; continue } break } if c == 0x3B { // ";" — strip a `&word;` entity-like suffix var i = end - 2 while i >= start, isAlnum(ns.character(at: i)) { i -= 1 } if i >= start, ns.character(at: i) == 0x26, i < end - 2 { // "&" + 1+ alnum + ";" end = i continue loop } break } break } return end } /// GFM valid domain: `.`-separated labels of alphanumerics/`-`/`_`, /// at least two labels, no `_` in the last two. func isValidDomain(_ domain: Substring) -> Bool { let labels = domain.split(separator: ".", omittingEmptySubsequences: false) guard labels.count >= 2 else { return false } for label in labels { guard !label.isEmpty, label.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }) else { return false } } return !labels.suffix(2).contains { $0.contains("_") } } for m in autolinkRegex.matches(in: text, range: NSRange(location: 0, length: ns.length)) { let candidate = m.range(at: 2) let end = trimmedEnd(from: candidate.location, to: candidate.upperBound) guard end > candidate.location else { continue } let full = NSRange(location: candidate.location, length: end - candidate.location) let match = ns.substring(with: full) let destination: String if match.range(of: "^https?://", options: [.regularExpression, .caseInsensitive]) != nil { let afterScheme = match[match.range(of: "://")!.upperBound...] guard isValidDomain(afterScheme.prefix { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == "." }) else { continue } destination = match } else if match.lowercased().hasPrefix("www.") { guard isValidDomain(match.prefix(while: { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == "." })) else { continue } destination = "http://" + match } else { // Email: needs text before the `@`, a valid domain after it, // and the last character can't be `-` or `_`. guard let at = match.firstIndex(of: "@"), at != match.startIndex, isValidDomain(match[match.index(after: at)...]), match.last != "-", match.last != "_" else { continue } destination = "mailto:" + match } let overlapsExisting = spans.contains { existing in switch existing.kind { case .code, .codeBlock, .math, .comment, .wikilink, .link, .image, .htmlTag, .htmlFormat: return existing.fullRange.location < full.upperBound && existing.fullRange.upperBound > full.location default: return false } } guard !overlapsExisting else { continue } spans.append(Span( kind: .link(destination: destination), fullRange: full, contentRange: full, delimiterRanges: [])) } } /// Parses trailing `\` as a line break indicator. static func parseLineBreak(_ text: String, into spans: inout [Span]) { let nsText = text as NSString let len = nsText.length guard len > 0 else { return } // Must not contain \n (only applies to single-line blocks) guard !text.contains("\n") else { return } let lastChar = nsText.character(at: len - 1) guard lastChar == 0x5C else { return } // backslash // Not an escaped backslash (\\) if len >= 2 && nsText.character(at: len - 2) == 0x5C { return } let range = NSRange(location: len - 1, length: 1) spans.append(Span( kind: .lineBreak, fullRange: range, contentRange: NSRange(location: len - 1, length: 0), delimiterRanges: [range] )) } /// Detects list items with deep indentation (4+ spaces or tabs) that /// swift-markdown parses as indented code instead of list items. Group 2 is /// the marker — an unordered bullet (`-`/`*`/`+`) or an ordered number /// (`1.`/`1)`), so nested ordered lists are rescued too. static let indentedListRegex = try! NSRegularExpression( pattern: #"^([\t ]*\t[\t ]*|[ ]{4,})([-*+]|\d{1,9}[.)])\s"# ) /// Matches a GFM task-list checkbox at the start of list-item content: /// "[ ] ", "[x] ", or "[X] ". Capture group 1 is the state character. static let checkboxRegex = try! NSRegularExpression( pattern: #"^\[([ xX])\]\s"# ) static func parseIndentedListItem(_ text: String, into spans: inout [Span]) { // Only single-line blocks (no \n) guard !text.contains("\n") else { return } let nsText = text as NSString let match = indentedListRegex.firstMatch( in: text, range: NSRange(location: 0, length: nsText.length) ) guard let match = match else { return } // Don't duplicate if swift-markdown already found a listItem let alreadyHasListItem = spans.contains { if case .listItem = $0.kind { return true } return false } guard !alreadyHasListItem else { return } let full = NSRange(location: 0, length: nsText.length) let markerEnd = match.range(at: 0).upperBound // end of " - " // An ordered marker (1./1)) starts with a digit; a bullet (-/*/+) doesn't. let marker = nsText.substring(with: match.range(at: 2)) let ordered = marker.first?.isNumber ?? false // Detect a GFM task-list checkbox following the marker ("[ ] "/"[x] "). // swift-markdown skips these on deeply-indented lines (it treats the // whole line as code), so we parse the checkbox ourselves — otherwise // task items nested beyond level 2 render without a circle. Only the // unordered `- [ ]` form is supported. var checkbox: Span.Kind.CheckboxState? = nil var delimEnd = markerEnd if !ordered { let afterMarker = nsText.substring(from: markerEnd) as NSString if let cb = checkboxRegex.firstMatch( in: afterMarker as String, range: NSRange(location: 0, length: afterMarker.length) ) { let stateChar = afterMarker.substring(with: cb.range(at: 1)) checkbox = (stateChar == "x" || stateChar == "X") ? .checked : .unchecked delimEnd = markerEnd + cb.range(at: 0).length } } let delim = NSRange(location: 0, length: delimEnd) let content = NSRange(location: delimEnd, length: nsText.length - delimEnd) // Remove any codeBlock span swift-markdown created for this indented line spans.removeAll { span in if case .codeBlock = span.kind { return true } return false } spans.append(Span( kind: .listItem(ordered: ordered, checkbox: checkbox), fullRange: full, contentRange: content, delimiterRanges: [delim] )) // Re-parse the content for inline formatting (bold, italic, code, etc.) // since swift-markdown treated the whole line as code and skipped them. let contentStr = nsText.substring(with: content) let inlineSpans = parse(contentStr) for s in inlineSpans { // Skip any listItem spans from the recursive parse if case .listItem = s.kind { continue } // Offset ranges by the content start position let offsetFull = NSRange(location: s.fullRange.location + content.location, length: s.fullRange.length) let offsetContent = NSRange(location: s.contentRange.location + content.location, length: s.contentRange.length) let offsetDelims = s.delimiterRanges.map { NSRange(location: $0.location + content.location, length: $0.length) } spans.append(Span(kind: s.kind, fullRange: offsetFull, contentRange: offsetContent, delimiterRanges: offsetDelims)) } } }