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

html library org-mode swift

Sources/OrgSwift/AST/OrgDocument.swift

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

258 lines · 9101 bytes

  1import Foundation
  2
  3// The org element tree  the document model, deliberately mirroring orgo's `model.rs`
  4// (`Element`/`Object`, `TableRow::{Cells, Rule}`, `ListKind`, `Checkbox`) so the two can be
  5// compared tree-to-tree later, instead of only through rendered HTML.
  6//
  7// Why a tree at all: the shipped renderer is a single pass from source straight to an HTML
  8// string, so every new output format would mean re-deriving the parse. With a tree, parsing
  9// happens once and each renderer is a walk  the HTML renderer stays the one the conformance
 10// corpus measures, and a native renderer rides on the same proven parse.
 11
 12/// A parsed org document: metadata keywords plus the block elements of the body.
 13public struct OrgDocument: Sendable, Equatable {
 14    /// `#+TITLE:`, `#+AUTHOR:`, `#+DATE:` and any other `#+KEY: value`, in source order.
 15    public var keywords: [(key: String, value: String)]
 16    public var elements: [OrgElement]
 17
 18    public init(keywords: [(key: String, value: String)] = [], elements: [OrgElement] = []) {
 19        self.keywords = keywords
 20        self.elements = elements
 21    }
 22
 23    public func keyword(_ name: String) -> String? {
 24        keywords.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value
 25    }
 26
 27    public static func == (lhs: OrgDocument, rhs: OrgDocument) -> Bool {
 28        lhs.elements == rhs.elements
 29            && lhs.keywords.count == rhs.keywords.count
 30            && zip(lhs.keywords, rhs.keywords).allSatisfy { $0.key == $1.key && $0.value == $1.value }
 31    }
 32}
 33
 34/// A block-level element.
 35public indirect enum OrgElement: Sendable, Equatable {
 36    case heading(OrgHeading)
 37    case paragraph([OrgObject])
 38    case list(OrgList)
 39    case table(OrgTable)
 40    case srcBlock(language: String?, code: String)
 41    case exampleBlock(String)
 42    case quoteBlock([OrgElement])
 43    case centerBlock([OrgElement])
 44    case verseBlock([[OrgObject]])
 45    case specialBlock(name: String, content: [OrgElement])
 46    case exportBlock(backend: String, raw: String)
 47    case figure(OrgFigure)
 48    /// A block carrying an affiliated `#+CAPTION:` / `#+NAME:`, which org's exporter wraps in
 49    /// a `<figure>`. Images take the `.figure` case instead; this is for everything else
 50    /// (a captioned source block, example, or table).
 51    case captioned(name: String?, caption: [OrgObject]?, content: OrgElement)
 52    case horizontalRule
 53    case footnoteDefinition(label: String, content: [OrgObject])
 54}
 55
 56public struct OrgHeading: Sendable, Equatable {
 57    /// Star count, before any render-time level offset.
 58    public var level: Int
 59    public var todo: String?
 60    public var priority: Character?
 61    public var title: [OrgObject]
 62    public var tags: [String]
 63
 64    public init(level: Int, todo: String? = nil, priority: Character? = nil,
 65                title: [OrgObject], tags: [String] = []) {
 66        self.level = level
 67        self.todo = todo
 68        self.priority = priority
 69        self.title = title
 70        self.tags = tags
 71    }
 72}
 73
 74// MARK: - Lists
 75
 76public struct OrgList: Sendable, Equatable {
 77    public var kind: OrgListKind
 78    public var items: [OrgListItem]
 79
 80    public init(kind: OrgListKind, items: [OrgListItem]) {
 81        self.kind = kind
 82        self.items = items
 83    }
 84}
 85
 86public enum OrgListKind: Sendable, Equatable {
 87    case unordered
 88    case ordered
 89    case description
 90}
 91
 92public struct OrgListItem: Sendable, Equatable {
 93    public var checkbox: OrgCheckbox?
 94    /// The term of a description-list item (`term :: definition`).
 95    public var term: [OrgObject]?
 96    /// The item's own content: one paragraph normally, several for a multi-paragraph item.
 97    public var content: [[OrgObject]]
 98    /// A nested list, when the item has one.
 99    public var sublist: OrgList?
100
101    public init(checkbox: OrgCheckbox? = nil, term: [OrgObject]? = nil,
102                content: [[OrgObject]], sublist: OrgList? = nil) {
103        self.checkbox = checkbox
104        self.term = term
105        self.content = content
106        self.sublist = sublist
107    }
108}
109
110public enum OrgCheckbox: Sendable, Equatable {
111    case off
112    case on
113    case partial
114}
115
116// MARK: - Tables
117
118public struct OrgTable: Sendable, Equatable {
119    public var rows: [OrgTableRow]
120    /// Per-column alignment from the separator row, when it carries `:` markers.
121    public var alignments: [OrgAlignment?]
122
123    public init(rows: [OrgTableRow], alignments: [OrgAlignment?] = []) {
124        self.rows = rows
125        self.alignments = alignments
126    }
127
128    /// A rule row separates the header band from the body, as in org.
129    public var headerRowCount: Int {
130        guard let ruleIndex = rows.firstIndex(where: { if case .rule = $0 { return true } else { return false } })
131        else { return 0 }
132        return ruleIndex
133    }
134}
135
136public enum OrgTableRow: Sendable, Equatable {
137    case cells([[OrgObject]])
138    case rule
139}
140
141public enum OrgAlignment: String, Sendable, Equatable {
142    case left, center, right
143}
144
145// MARK: - Figures
146
147public struct OrgFigure: Sendable, Equatable {
148    public var source: String
149    public var caption: [OrgObject]?
150    /// `#+ATTR_HTML:` pairs, kept as parsed so an HTML renderer can emit them and a native
151    /// renderer can read the ones it understands (`:alt`, `:width`).
152    public var attributes: [(key: String, value: String)]
153
154    public init(source: String, caption: [OrgObject]? = nil, attributes: [(key: String, value: String)] = []) {
155        self.source = source
156        self.caption = caption
157        self.attributes = attributes
158    }
159
160    public var alt: String? {
161        attributes.first { $0.key == "alt" }?.value
162    }
163
164    public static func == (lhs: OrgFigure, rhs: OrgFigure) -> Bool {
165        lhs.source == rhs.source && lhs.caption == rhs.caption
166            && lhs.attributes.count == rhs.attributes.count
167            && zip(lhs.attributes, rhs.attributes).allSatisfy { $0.key == $1.key && $0.value == $1.value }
168    }
169}
170
171// MARK: - Inline objects
172
173/// An inline object. Text-bearing cases carry their own children so a renderer can nest
174/// styling (`*bold /and italic/*`) rather than receiving pre-formatted markup.
175public indirect enum OrgObject: Sendable, Equatable {
176    case text(String)
177    case bold([OrgObject])
178    case italic([OrgObject])
179    case underline([OrgObject])
180    case strikeThrough([OrgObject])
181    /// Non-nesting by definition in org: `=verbatim=` and `~code~` hold literal text.
182    case verbatim(String)
183    case code(String)
184    case link(OrgLink)
185    case image(OrgFigure)
186    /// A footnote reference. `inline` carries the text of an inline footnote
187    /// (`[fn:label:text]`), which defines the note at the point of use; nil for a plain
188    /// reference whose definition appears elsewhere. Numbering is document-wide, so it is the
189    /// renderer's job, not the parser's.
190    case footnoteRef(label: String, inline: [OrgObject]?)
191    case timestamp(OrgTimestamp)
192    case superscript([OrgObject])
193    case lineBreak
194}
195
196public struct OrgLink: Sendable, Equatable {
197    public var target: OrgLinkTarget
198    /// Nil description means the link shows its target.
199    public var description: [OrgObject]?
200
201    public init(target: OrgLinkTarget, description: [OrgObject]? = nil) {
202        self.target = target
203        self.description = description
204    }
205}
206
207public enum OrgLinkTarget: Sendable, Equatable {
208    /// An absolute URL (`https:`, `mailto:`) or a bare autolinked URL.
209    case external(String)
210    /// A repository-relative path, from `[[file:]]` or a bare relative target.
211    case file(String)
212    /// `[[id:]]`  an in-page fragment.
213    case id(String)
214}
215
216public struct OrgTimestamp: Sendable, Equatable {
217    public var date: String
218    public var time: String?
219    /// End of a same-day time range (`< 10:00-11:45>`).
220    public var endTime: String?
221    /// End of a multi-day range (`<a>--<b>`), which org models as one timestamp with an end.
222    public var endDate: String?
223    public var active: Bool
224
225    public init(date: String, time: String? = nil, endTime: String? = nil,
226                endDate: String? = nil, active: Bool) {
227        self.date = date
228        self.time = time
229        self.endTime = endTime
230        self.endDate = endDate
231        self.active = active
232    }
233
234    /// True when this stamp spans a range, in either form.
235    public var isRange: Bool { endTime != nil || endDate != nil }
236
237    /// The machine value and display text of the range's end, when there is one. A multi-day
238    /// end shows its whole date; a same-day end shows only the time, since the date is already
239    /// on the start.
240    public var end: (machineValue: String, displayValue: String)? {
241        if let endDate {
242            return (endDate, endDate)
243        }
244        if let endTime {
245            return ("\(date)T\(endTime)", endTime)
246        }
247        return nil
248    }
249
250    /// The `datetime` attribute value / sort key: `2024-01-15` or `2024-01-15T10:30`.
251    public var machineValue: String {
252        time.map { "\(date)T\($0)" } ?? date
253    }
254
255    public var displayValue: String {
256        time.map { "\(date) \($0)" } ?? date
257    }
258}