A dependency-free Swift library that renders org-mode to sanitized HTML.

html library org-mode swift

Sources/OrgSwift/AST/OrgInlineParser.swift

main
org-swift/Sources/OrgSwift/AST/OrgInlineParser.swift history · blame · raw

321 lines · 13651 bytes

  1import Foundation
  2
  3// Inline parsing into `[OrgObject]`.
  4//
  5// The shipped renderer does inline work by regex-substituting HTML into an escaped string,
  6// using placeholder tokens to protect what must not be re-scanned. That works for one output
  7// format but bakes HTML into the parse. Here the same constructs become a tree, so `*bold
  8// /italic/*` nests properly and every renderer decides its own representation.
  9
 10extension OrgParser {
 11
 12    /// Inline objects reduced to their plain text  for alt text, previews, or assertions.
 13    public static func plain(_ objects: [OrgObject]) -> String {
 14        objects.map { object in
 15            switch object {
 16            case .text(let text): return text
 17            case .verbatim(let text), .code(let text): return text
 18            case .bold(let c), .italic(let c), .underline(let c), .strikeThrough(let c), .superscript(let c):
 19                return plain(c)
 20            case .link(let link):
 21                if let description = link.description { return plain(description) }
 22                switch link.target {
 23                case .external(let value), .file(let value), .id(let value): return value
 24                }
 25            case .image(let figure): return figure.alt ?? ""
 26            case .timestamp(let stamp): return stamp.displayValue
 27            case .footnoteRef, .lineBreak: return ""
 28            }
 29        }.joined()
 30    }
 31
 32    /// Parse a run of inline org text into objects.
 33    public static func parseInline(_ text: String) -> [OrgObject] {
 34        var objects: [OrgObject] = []
 35        var plain = ""
 36        let chars = Array(text)
 37        var i = 0
 38
 39        func flushPlain() {
 40            if !plain.isEmpty { objects.append(.text(plain)); plain = "" }
 41        }
 42
 43        while i < chars.count {
 44            // Bracket links: [[target]] or [[target][description]]
 45            if chars[i] == "[", i + 1 < chars.count, chars[i + 1] == "[",
 46               let link = scanLink(chars, from: i) {
 47                flushPlain()
 48                objects.append(link.object)
 49                i = link.next
 50                continue
 51            }
 52
 53            // Footnote reference: [fn:label] or [fn:label:inline]
 54            if chars[i] == "[", let note = scanFootnote(chars, from: i) {
 55                flushPlain()
 56                objects.append(note.object)
 57                i = note.next
 58                continue
 59            }
 60
 61            // Timestamps: <2024-01-15 Mon 10:30> or [2024-01-15 Mon]
 62            if chars[i] == "<" || chars[i] == "[", let stamp = scanTimestamp(chars, from: i) {
 63                flushPlain()
 64                objects.append(stamp.object)
 65                i = stamp.next
 66                continue
 67            }
 68
 69            // Bare URL autolink.
 70            if chars[i] == "h", let url = scanBareURL(chars, from: i) {
 71                flushPlain()
 72                objects.append(url.object)
 73                i = url.next
 74                continue
 75            }
 76
 77            // Bare email autolink, at a word boundary so `a.b@c.d` is not matched mid-token.
 78            if isWordCharacter(chars[i]), i == 0 || !isEmailBoundaryCharacter(chars[i - 1]),
 79               let email = scanEmail(chars, from: i) {
 80                flushPlain()
 81                objects.append(email.object)
 82                i = email.next
 83                continue
 84            }
 85
 86            // Emphasis: *bold* /italic/ _underline_ +strike+ =verbatim= ~code~
 87            if let marker = emphasisMarker(chars[i]), boundaryBefore(chars, i),
 88               let span = scanEmphasis(chars, from: i, marker: chars[i]) {
 89                flushPlain()
 90                switch marker {
 91                case .bold: objects.append(.bold(parseInline(span.body)))
 92                case .italic: objects.append(.italic(parseInline(span.body)))
 93                case .underline: objects.append(.underline(parseInline(span.body)))
 94                case .strike: objects.append(.strikeThrough(parseInline(span.body)))
 95                case .verbatim: objects.append(.verbatim(span.body))
 96                case .code: objects.append(.code(span.body))
 97                }
 98                i = span.next
 99                continue
100            }
101
102            // Superscript: x^2 or x^{group}
103            if chars[i] == "^", i > 0, isWordCharacter(chars[i - 1]),
104               let sup = scanSuperscript(chars, from: i) {
105                flushPlain()
106                objects.append(.superscript(parseInline(sup.body)))
107                i = sup.next
108                continue
109            }
110
111            plain.append(chars[i])
112            i += 1
113        }
114        flushPlain()
115        return objects
116    }
117
118    // MARK: - Scanners
119
120    private enum Emphasis { case bold, italic, underline, strike, verbatim, code }
121
122    private static func emphasisMarker(_ c: Character) -> Emphasis? {
123        switch c {
124        case "*": return .bold
125        case "/": return .italic
126        case "_": return .underline
127        case "+": return .strike
128        case "=": return .verbatim
129        case "~": return .code
130        default: return nil
131        }
132    }
133
134    /// org requires the opening marker to follow whitespace or start the run.
135    private static func boundaryBefore(_ chars: [Character], _ i: Int) -> Bool {
136        i == 0 || chars[i - 1].isWhitespace || "([{'\"".contains(chars[i - 1])
137    }
138
139    private static func isWordCharacter(_ c: Character) -> Bool {
140        c.isLetter || c.isNumber
141    }
142
143    private static func scanEmphasis(_ chars: [Character], from start: Int, marker: Character)
144        -> (body: String, next: Int)? {
145        var j = start + 1
146        var body = ""
147        while j < chars.count {
148            if chars[j] == marker {
149                // The closer must end the run or be followed by space/punctuation.
150                let after = j + 1 < chars.count ? chars[j + 1] : " "
151                if !body.isEmpty, after.isWhitespace || ".,;:!?)]}'\"".contains(after) || j + 1 == chars.count {
152                    return (body, j + 1)
153                }
154            }
155            if chars[j] == "\n" { return nil }
156            body.append(chars[j])
157            j += 1
158        }
159        return nil
160    }
161
162    private static func scanLink(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
163        var j = start + 2
164        var target = ""
165        while j < chars.count, !(chars[j] == "]" && j + 1 < chars.count && (chars[j + 1] == "]" || chars[j + 1] == "[")) {
166            target.append(chars[j]); j += 1
167        }
168        guard j < chars.count else { return nil }
169
170        var description: String?
171        if chars[j + 1] == "[" {
172            j += 2
173            var text = ""
174            var depth = 0
175            while j < chars.count {
176                if chars[j] == "[" { depth += 1 }
177                if chars[j] == "]" {
178                    if depth == 0 { break }
179                    depth -= 1
180                }
181                text.append(chars[j]); j += 1
182            }
183            description = text
184        }
185        // Consume the closing ]]
186        while j < chars.count, chars[j] == "]" { j += 1 }
187
188        let object = makeLinkObject(target: target, description: description)
189        return (object, j)
190    }
191
192    private static func makeLinkObject(target rawTarget: String, description: String?) -> OrgObject {
193        let target = rawTarget.hasPrefix("file:") ? String(rawTarget.dropFirst(5)) : rawTarget
194
195        // A description that is itself an image makes the image the link's content  the
196        // build-badge form. It arrives bracket-wrapped from `[[dest][[img]]]`, so unwrap any
197        // balanced brackets before deciding.
198        if let description {
199            var inner = description
200            while inner.hasPrefix("["), inner.hasSuffix("]"), inner.count > 2 {
201                inner = String(inner.dropFirst().dropLast())
202            }
203            let wasWrapped = inner != description
204            let imageSource = inner.hasPrefix("file:") ? String(inner.dropFirst(5)) : inner
205            if isImagePath(imageSource),
206               wasWrapped || inner.hasPrefix("file:") || inner.hasPrefix("http://") || inner.hasPrefix("https://") {
207                return .link(OrgLink(target: linkTarget(target),
208                                     description: [.image(OrgFigure(source: imageSource))]))
209            }
210            return .link(OrgLink(target: linkTarget(target), description: parseInline(description)))
211        }
212
213        if isImagePath(target) { return .image(OrgFigure(source: target)) }
214        return .link(OrgLink(target: linkTarget(target), description: nil))
215    }
216
217    private static func linkTarget(_ target: String) -> OrgLinkTarget {
218        if target.hasPrefix("id:") { return .id(String(target.dropFirst(3))) }
219        if target.hasPrefix("http://") || target.hasPrefix("https://")
220            || target.hasPrefix("mailto:") || target.hasPrefix("#") {
221            return .external(target)
222        }
223        return .file(target)
224    }
225
226    private static func isImagePath(_ path: String) -> Bool {
227        let lower = path.lowercased()
228        return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]
229            .contains { lower.hasSuffix($0) }
230    }
231
232    private static func scanFootnote(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
233        let rest = String(chars[start...])
234        guard let match = rest.firstMatch(of: /^\[fn:([A-Za-z0-9_-]+)(?::([^\]]*))?\]/) else { return nil }
235        let label = String(match.1)
236        // An inline footnote defines its note where it is used; parse that text as content.
237        let inline = match.2.map { parseInline(String($0)) }
238        let consumed = rest.distance(from: rest.startIndex, to: match.range.upperBound)
239        return (.footnoteRef(label: label, inline: inline), start + consumed)
240    }
241
242    private static func scanTimestamp(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
243        let active = chars[start] == "<"
244        let closing: Character = active ? ">" : "]"
245        var j = start + 1
246        var body = ""
247        while j < chars.count, chars[j] != closing {
248            if chars[j] == "\n" { return nil }
249            body.append(chars[j]); j += 1
250        }
251        guard j < chars.count else { return nil }
252        guard let dateMatch = body.firstMatch(of: /(\d{4}-\d{2}-\d{2})/) else { return nil }
253
254        let date = String(dateMatch.1)
255        var time: String?
256        var endTime: String?
257        if let timeMatch = body.firstMatch(of: /(\d{2}:\d{2})(?:-(\d{2}:\d{2}))?/) {
258            time = String(timeMatch.1)
259            if let end = timeMatch.2 { endTime = String(end) }
260        }
261        var next = j + 1
262
263        // A multi-day range joins two stamps with `--`; org models that as one timestamp
264        // carrying an end, so consume the second stamp here rather than leaving `--` as text.
265        var endDate: String?
266        let opening: Character = active ? "<" : "["
267        if next + 2 < chars.count, chars[next] == "-", chars[next + 1] == "-", chars[next + 2] == opening {
268            var k = next + 3
269            var second = ""
270            while k < chars.count, chars[k] != closing { second.append(chars[k]); k += 1 }
271            if k < chars.count, let endMatch = second.firstMatch(of: /(\d{4}-\d{2}-\d{2})/) {
272                endDate = String(endMatch.1)
273                next = k + 1
274            }
275        }
276
277        return (.timestamp(OrgTimestamp(date: date, time: time, endTime: endTime,
278                                        endDate: endDate, active: active)), next)
279    }
280
281    private static func scanBareURL(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
282        let rest = String(chars[start...])
283        guard let match = rest.firstMatch(of: /^https?:\/\/[^\s<>()\[\]]+/) else { return nil }
284        var url = String(rest[match.range])
285        while let last = url.last, ".,;:!?".contains(last) { url.removeLast() }
286        return (.link(OrgLink(target: .external(url), description: nil)), start + url.count)
287    }
288
289    /// Characters that keep an email from starting here, mirroring the shipped renderer's
290    /// `(?<![\w.%+\-])` guard.
291    private static func isEmailBoundaryCharacter(_ c: Character) -> Bool {
292        c.isLetter || c.isNumber || c == "_" || c == "." || c == "%" || c == "+" || c == "-"
293    }
294
295    private static func scanEmail(_ chars: [Character], from start: Int) -> (object: OrgObject, next: Int)? {
296        let rest = String(chars[start...])
297        guard let match = rest.firstMatch(of: /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/) else {
298            return nil
299        }
300        let address = String(rest[match.range])
301        // A trailing character that cannot end an address belongs to the sentence, not the link.
302        guard !address.hasSuffix("."), !address.hasSuffix("-") else { return nil }
303        return (.link(OrgLink(target: .external("mailto:\(address)"),
304                              description: [.text(address)])), start + address.count)
305    }
306
307    private static func scanSuperscript(_ chars: [Character], from start: Int) -> (body: String, next: Int)? {
308        var j = start + 1
309        guard j < chars.count else { return nil }
310        if chars[j] == "{" {
311            j += 1
312            var body = ""
313            while j < chars.count, chars[j] != "}" { body.append(chars[j]); j += 1 }
314            guard j < chars.count else { return nil }
315            return (body, j + 1)
316        }
317        var body = ""
318        while j < chars.count, isWordCharacter(chars[j]) { body.append(chars[j]); j += 1 }
319        return body.isEmpty ? nil : (body, j)
320    }
321}