krz/org-swift

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

Sources/OrgSwift/AST/OrgDocument.swift

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

263 lines · 9428 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 body, in document order. Usually one paragraph, but an item may hold any
 97    /// block element — a nested list, a source block, a table — so this is a block sequence
 98    /// rather than a list of paragraphs.
 99    public var content: [OrgElement]
100
101    public init(checkbox: OrgCheckbox? = nil, term: [OrgObject]? = nil,
102                content: [OrgElement]) {
103        self.checkbox = checkbox
104        self.term = term
105        self.content = content
106    }
107
108    /// The item's paragraphs, ignoring any other block it carries. Used where only prose is
109    /// meaningful — the `<dd>` of a description list, an item's plain-text reduction.
110    public var paragraphs: [[OrgObject]] {
111        content.compactMap { if case .paragraph(let objects) = $0 { objects } else { nil } }
112    }
113}
114
115public enum OrgCheckbox: Sendable, Equatable {
116    case off
117    case on
118    case partial
119}
120
121// MARK: - Tables
122
123public struct OrgTable: Sendable, Equatable {
124    public var rows: [OrgTableRow]
125    /// Per-column alignment from the separator row, when it carries `:` markers.
126    public var alignments: [OrgAlignment?]
127
128    public init(rows: [OrgTableRow], alignments: [OrgAlignment?] = []) {
129        self.rows = rows
130        self.alignments = alignments
131    }
132
133    /// A rule row separates the header band from the body, as in org.
134    public var headerRowCount: Int {
135        guard let ruleIndex = rows.firstIndex(where: { if case .rule = $0 { return true } else { return false } })
136        else { return 0 }
137        return ruleIndex
138    }
139}
140
141public enum OrgTableRow: Sendable, Equatable {
142    case cells([[OrgObject]])
143    case rule
144}
145
146public enum OrgAlignment: String, Sendable, Equatable {
147    case left, center, right
148}
149
150// MARK: - Figures
151
152public struct OrgFigure: Sendable, Equatable {
153    public var source: String
154    public var caption: [OrgObject]?
155    /// `#+ATTR_HTML:` pairs, kept as parsed so an HTML renderer can emit them and a native
156    /// renderer can read the ones it understands (`:alt`, `:width`).
157    public var attributes: [(key: String, value: String)]
158
159    public init(source: String, caption: [OrgObject]? = nil, attributes: [(key: String, value: String)] = []) {
160        self.source = source
161        self.caption = caption
162        self.attributes = attributes
163    }
164
165    public var alt: String? {
166        attributes.first { $0.key == "alt" }?.value
167    }
168
169    public static func == (lhs: OrgFigure, rhs: OrgFigure) -> Bool {
170        lhs.source == rhs.source && lhs.caption == rhs.caption
171            && lhs.attributes.count == rhs.attributes.count
172            && zip(lhs.attributes, rhs.attributes).allSatisfy { $0.key == $1.key && $0.value == $1.value }
173    }
174}
175
176// MARK: - Inline objects
177
178/// An inline object. Text-bearing cases carry their own children so a renderer can nest
179/// styling (`*bold /and italic/*`) rather than receiving pre-formatted markup.
180public indirect enum OrgObject: Sendable, Equatable {
181    case text(String)
182    case bold([OrgObject])
183    case italic([OrgObject])
184    case underline([OrgObject])
185    case strikeThrough([OrgObject])
186    /// Non-nesting by definition in org: `=verbatim=` and `~code~` hold literal text.
187    case verbatim(String)
188    case code(String)
189    case link(OrgLink)
190    case image(OrgFigure)
191    /// A footnote reference. `inline` carries the text of an inline footnote
192    /// (`[fn:label:text]`), which defines the note at the point of use; nil for a plain
193    /// reference whose definition appears elsewhere. Numbering is document-wide, so it is the
194    /// renderer's job, not the parser's.
195    case footnoteRef(label: String, inline: [OrgObject]?)
196    case timestamp(OrgTimestamp)
197    case superscript([OrgObject])
198    case lineBreak
199}
200
201public struct OrgLink: Sendable, Equatable {
202    public var target: OrgLinkTarget
203    /// Nil description means the link shows its target.
204    public var description: [OrgObject]?
205
206    public init(target: OrgLinkTarget, description: [OrgObject]? = nil) {
207        self.target = target
208        self.description = description
209    }
210}
211
212public enum OrgLinkTarget: Sendable, Equatable {
213    /// An absolute URL (`https:`, `mailto:`) or a bare autolinked URL.
214    case external(String)
215    /// A repository-relative path, from `[[file:…]]` or a bare relative target.
216    case file(String)
217    /// `[[id:…]]` — an in-page fragment.
218    case id(String)
219}
220
221public struct OrgTimestamp: Sendable, Equatable {
222    public var date: String
223    public var time: String?
224    /// End of a same-day time range (`<… 10:00-11:45>`).
225    public var endTime: String?
226    /// End of a multi-day range (`<a>--<b>`), which org models as one timestamp with an end.
227    public var endDate: String?
228    public var active: Bool
229
230    public init(date: String, time: String? = nil, endTime: String? = nil,
231                endDate: String? = nil, active: Bool) {
232        self.date = date
233        self.time = time
234        self.endTime = endTime
235        self.endDate = endDate
236        self.active = active
237    }
238
239    /// True when this stamp spans a range, in either form.
240    public var isRange: Bool { endTime != nil || endDate != nil }
241
242    /// The machine value and display text of the range's end, when there is one. A multi-day
243    /// end shows its whole date; a same-day end shows only the time, since the date is already
244    /// on the start.
245    public var end: (machineValue: String, displayValue: String)? {
246        if let endDate {
247            return (endDate, endDate)
248        }
249        if let endTime {
250            return ("\(date)T\(endTime)", endTime)
251        }
252        return nil
253    }
254
255    /// The `datetime` attribute value / sort key: `2024-01-15` or `2024-01-15T10:30`.
256    public var machineValue: String {
257        time.map { "\(date)T\($0)" } ?? date
258    }
259
260    public var displayValue: String {
261        time.map { "\(date) \($0)" } ?? date
262    }
263}