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.8.0
org-swift/Sources/OrgSwift/AST/OrgDocument.swift history · blame · raw

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