Parse org into an element tree, and render the tree !1
16 files changed, +1864 −1359
ARCHITECTURE.md added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | # Architecture | |
| 2 | ||
| 3 | OrgSwift parses org into an element tree and renders that tree. Parsing happens once; each | |
| 4 | output format is a walk over the result. | |
| 5 | ||
| 6 | ``` | |
| 7 | Sources/OrgSwift/ | |
| 8 | AST/ | |
| 9 | OrgDocument.swift the element tree | |
| 10 | OrgParser.swift source → OrgDocument (blocks) | |
| 11 | OrgInlineParser.swift text → [OrgObject] (inlines) | |
| 12 | OrgHTMLTreeRenderer.swift OrgDocument → HTML | |
| 13 | OrgAttributedStringRenderer.swift [OrgObject] → AttributedString | |
| 14 | OrgRenderer.swift the public entry point, plus OrgRenderOptions | |
| 15 | Escaping.swift, Links.swift, … shared helpers: escaping, URL sanitising and | |
| 16 | repository-relative resolution, line predicates | |
| 17 | Skeleton.swift the HTML → semantic-skeleton reduction used by tests | |
| 18 | ``` | |
| 19 | ||
| 20 | `OrgRenderer.renderToHTML` is a thin façade over `OrgParser.parse` + `OrgHTMLTreeRenderer`. | |
| 21 | It, `OrgRenderOptions`, and `CodeHighlighter` are the whole public API most callers need. | |
| 22 | ||
| 23 | ## Why a tree | |
| 24 | ||
| 25 | The renderer this replaced went from source straight to an HTML string in a single pass, | |
| 26 | doing inline work by regex-substituting markup into escaped text and protecting the results | |
| 27 | with placeholder tokens. That worked, but it baked HTML into the parse: any second output | |
| 28 | format would have meant re-deriving the parse rather than reusing it. | |
| 29 | ||
| 30 | With a tree, `OrgAttributedStringRenderer` is roughly 140 lines and shares the parse | |
| 31 | entirely. It is **Foundation-only** — no SwiftUI — so it works server-side, in a CLI, | |
| 32 | anywhere; a SwiftUI layer would sit on top of it for the inline runs inside each block. | |
| 33 | ||
| 34 | The types mirror orgo's `model.rs` — `OrgElement`/`OrgObject` against orgo's | |
| 35 | `Element`/`Object`, `OrgTableRow.{cells,rule}` against `TableRow::{Cells,Rule}`, the same | |
| 36 | `ListKind`/`Checkbox` vocabulary. That makes future *tree-level* conformance possible: today | |
| 37 | the corpus compares rendered HTML reduced to a skeleton, which is a string-level proxy for | |
| 38 | "do these two agree on structure", and matching trees would let that question be asked | |
| 39 | directly. | |
| 40 | ||
| 41 | Resolution is simpler on a tree, too. Link targets stay typed — `.external`, `.file`, `.id` | |
| 42 | — so repository-relative resolution applies to the `.file` case in the renderer instead of a | |
| 43 | resolver closure threaded through the parse. An unsafe or unresolvable target degrades to its | |
| 44 | text rather than becoming a bad anchor. | |
| 45 | ||
| 46 | ## Conformance | |
| 47 | ||
| 48 | Measured against the [org-conformance](../org-conformance) corpus, whose goldens come from | |
| 49 | orgo (itself diffed against Emacs `ox-html`), reduced by the shared skeleton algorithm: | |
| 50 | ||
| 51 | **11 / 12** — every case except `outofscope`, which is `scope: out` in the corpus | |
| 52 | (deliberately unsupported constructs, where orgo itself may differ). | |
| 53 | ||
| 54 | `ConformanceTests` gates this: a regression fails, and `outofscope` starting to match fails | |
| 55 | too, forcing the record in its expectations map to be updated. `GAPS.md` carries the detail. | |
| 56 | ||
| 57 | ## Notes from the migration | |
| 58 | ||
| 59 | The tree renderer replaced the single-pass one only after it was shown to be equivalent: | |
| 60 | every corpus case, each option combination, identical URL resolution, unsafe-scheme | |
| 61 | rejection, and a 28-construct battery drawn from the old renderer's own test inputs. That | |
| 62 | battery earned its keep — it caught three behaviours the corpus never reaches | |
| 63 | (`#+CAPTION:`/`#+NAME:` wrapping a non-image block in `<figure class="org-block">`, the | |
| 64 | nested `[[dest][[img]]]` badge form, and bare email autolinks), each of which would otherwise | |
| 65 | have regressed a consumer. | |
| 66 | ||
| 67 | One behaviour changed on purpose. A range whose halves are *inactive* timestamps, `[a]--[b]`, | |
| 68 | is now joined into one range; the old renderer joined active ranges only and left `--` as | |
| 69 | text. orgo applies the `--` rule to both bracket kinds, requiring only that the halves agree | |
| 70 | on activeness, so the new behaviour is the more correct one. It is asserted directly in | |
| 71 | `joinsInactiveTimestampRanges`. | |
| 72 | ||
| 73 | ## Next | |
| 74 | ||
| 75 | A SwiftUI renderer belongs in a **separate product** depending on this one, so the parser and | |
| 76 | HTML renderer stay Foundation-only and callers who want HTML never import SwiftUI. Tables are | |
| 77 | the interesting part: `Grid`/`GridRow` with `.gridColumnAlignment()`, wrapped in a horizontal | |
| 78 | `ScrollView` for phone-width overflow — the approach MarkdownUI uses. `OrgTable` already | |
| 79 | carries the rows, the rule position, and the per-column alignments that needs. | |
README.md +4 −2
| @@ -4,8 +4,10 @@ A dependency-free Swift library that renders a practical subset of | ||
| 4 | 4 | [org-mode](https://orgmode.org) to sanitized HTML. Pure Foundation — no |
| 5 | 5 | SwiftUI, WebKit, UIKit, or third-party packages. |
| 6 | 6 | |
| 7 | Extracted from the hand-rolled renderer in the Hutch iOS client so it can be | |
| 8 | shared across apps. | |
| 7 | Originally extracted from the hand-rolled renderer in the Hutch iOS client so it | |
| 8 | could be shared across apps, and since rebuilt around an element tree: org is | |
| 9 | parsed once into a document model, and each output format walks it. See | |
| 10 | [ARCHITECTURE.md](ARCHITECTURE.md). | |
| 9 | 11 | |
| 10 | 12 | ## Supported syntax |
| 11 | 13 | |
Sources/OrgSwift/AST/OrgAttributedStringRenderer.swift added +144
| @@ -0,0 +1,144 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Renders inline org content to `AttributedString` — the native counterpart to the HTML | |
| 4 | /// renderer, walking the same tree. | |
| 5 | /// | |
| 6 | /// This is the prototype's argument: a second output format is a walk over the parsed tree, | |
| 7 | /// not a second parser. It stays Foundation-only (no SwiftUI), so it is usable anywhere; a | |
| 8 | /// SwiftUI block renderer would sit on top, using this for the inline runs inside each block. | |
| 9 | public struct OrgAttributedStringRenderer: Sendable { | |
| 10 | ||
| 11 | public init() {} | |
| 12 | ||
| 13 | /// Render one run of inline objects, carrying intents a UI layer can style. | |
| 14 | public func inline(_ objects: [OrgObject]) -> AttributedString { | |
| 15 | var result = AttributedString() | |
| 16 | for object in objects { | |
| 17 | switch object { | |
| 18 | case .text(let text): | |
| 19 | result += AttributedString(text) | |
| 20 | ||
| 21 | case .bold(let children): | |
| 22 | var part = inline(children) | |
| 23 | part.inlinePresentationIntent = .stronglyEmphasized | |
| 24 | result += part | |
| 25 | ||
| 26 | case .italic(let children): | |
| 27 | var part = inline(children) | |
| 28 | part.inlinePresentationIntent = .emphasized | |
| 29 | result += part | |
| 30 | ||
| 31 | // Underline and strikethrough have no Foundation-portable attribute (the | |
| 32 | // underlineStyle/strikethroughStyle keys live in the UIKit/AppKit scopes), so they | |
| 33 | // travel as roles the UI layer applies. | |
| 34 | case .underline(let children): | |
| 35 | var part = inline(children) | |
| 36 | part.orgRole = .underline | |
| 37 | result += part | |
| 38 | ||
| 39 | case .strikeThrough(let children): | |
| 40 | var part = inline(children) | |
| 41 | part.orgRole = .strikeThrough | |
| 42 | result += part | |
| 43 | ||
| 44 | case .verbatim(let text), .code(let text): | |
| 45 | var part = AttributedString(text) | |
| 46 | part.inlinePresentationIntent = .code | |
| 47 | result += part | |
| 48 | ||
| 49 | case .superscript(let children): | |
| 50 | // No portable superscript attribute; mark it so a UI layer can raise it. | |
| 51 | var part = inline(children) | |
| 52 | part.orgRole = .superscript | |
| 53 | result += part | |
| 54 | ||
| 55 | case .lineBreak: | |
| 56 | result += AttributedString("\n") | |
| 57 | ||
| 58 | case .timestamp(let stamp): | |
| 59 | var part = AttributedString(stamp.displayValue) | |
| 60 | part.orgRole = .timestamp | |
| 61 | result += part | |
| 62 | ||
| 63 | case .footnoteRef(let label, _): | |
| 64 | var part = AttributedString("[\(label)]") | |
| 65 | part.orgRole = .footnoteReference | |
| 66 | result += part | |
| 67 | ||
| 68 | case .image(let figure): | |
| 69 | var part = AttributedString(figure.alt ?? figure.source) | |
| 70 | part.orgRole = .image | |
| 71 | result += part | |
| 72 | ||
| 73 | case .link(let link): | |
| 74 | var part = link.description.map { inline($0) } ?? AttributedString(displayValue(link.target)) | |
| 75 | if let url = URL(string: hrefValue(link.target)) { | |
| 76 | part.link = url | |
| 77 | } | |
| 78 | result += part | |
| 79 | } | |
| 80 | } | |
| 81 | return result | |
| 82 | } | |
| 83 | ||
| 84 | /// Flatten a whole document to attributed paragraphs — a convenience for callers that | |
| 85 | /// want text without building block views (a share sheet, a plain-text export). | |
| 86 | public func paragraphs(_ document: OrgDocument) -> [AttributedString] { | |
| 87 | document.elements.compactMap { element in | |
| 88 | switch element { | |
| 89 | case .paragraph(let objects): return inline(objects) | |
| 90 | case .heading(let heading): return inline(heading.title) | |
| 91 | default: return nil | |
| 92 | } | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | private func hrefValue(_ target: OrgLinkTarget) -> String { | |
| 97 | switch target { | |
| 98 | case .external(let url): return url | |
| 99 | case .file(let path): return path | |
| 100 | case .id(let identifier): return "#\(identifier)" | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | private func displayValue(_ target: OrgLinkTarget) -> String { | |
| 105 | switch target { | |
| 106 | case .external(let url): return url | |
| 107 | case .file(let path): return path | |
| 108 | case .id(let identifier): return identifier | |
| 109 | } | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | // MARK: - Custom attribute | |
| 114 | ||
| 115 | /// Org roles that `AttributedString` has no standard attribute for. A UI layer reads these to | |
| 116 | /// decide presentation (raise a superscript, tint a timestamp, make a footnote ref tappable) | |
| 117 | /// without the renderer needing to know about fonts or colors. | |
| 118 | public enum OrgRole: String, Sendable, Codable { | |
| 119 | case superscript | |
| 120 | case timestamp | |
| 121 | case footnoteReference | |
| 122 | case image | |
| 123 | case underline | |
| 124 | case strikeThrough | |
| 125 | } | |
| 126 | ||
| 127 | public enum OrgRoleAttribute: AttributedStringKey { | |
| 128 | public typealias Value = OrgRole | |
| 129 | public static let name = "orgRole" | |
| 130 | } | |
| 131 | ||
| 132 | public extension AttributeScopes { | |
| 133 | struct OrgAttributes: AttributeScope { | |
| 134 | public let orgRole: OrgRoleAttribute | |
| 135 | } | |
| 136 | var org: OrgAttributes.Type { OrgAttributes.self } | |
| 137 | } | |
| 138 | ||
| 139 | public extension AttributedString { | |
| 140 | var orgRole: OrgRole? { | |
| 141 | get { self[OrgRoleAttribute.self] } | |
| 142 | set { self[OrgRoleAttribute.self] = newValue } | |
| 143 | } | |
| 144 | } | |
Sources/OrgSwift/AST/OrgDocument.swift added +258
| @@ -0,0 +1,258 @@ | ||
| 1 | import 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. | |
| 13 | public 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. | |
| 35 | public 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 | ||
| 56 | public 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 | ||
| 76 | public 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 | ||
| 86 | public enum OrgListKind: Sendable, Equatable { | |
| 87 | case unordered | |
| 88 | case ordered | |
| 89 | case description | |
| 90 | } | |
| 91 | ||
| 92 | public 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 | ||
| 110 | public enum OrgCheckbox: Sendable, Equatable { | |
| 111 | case off | |
| 112 | case on | |
| 113 | case partial | |
| 114 | } | |
| 115 | ||
| 116 | // MARK: - Tables | |
| 117 | ||
| 118 | public 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 | ||
| 136 | public enum OrgTableRow: Sendable, Equatable { | |
| 137 | case cells([[OrgObject]]) | |
| 138 | case rule | |
| 139 | } | |
| 140 | ||
| 141 | public enum OrgAlignment: String, Sendable, Equatable { | |
| 142 | case left, center, right | |
| 143 | } | |
| 144 | ||
| 145 | // MARK: - Figures | |
| 146 | ||
| 147 | public 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. | |
| 175 | public 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 | ||
| 196 | public 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 | ||
| 207 | public 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 | ||
| 216 | public 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 | } | |
Sources/OrgSwift/AST/OrgHTMLTreeRenderer.swift added +344
| @@ -0,0 +1,344 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Renders an ``OrgDocument`` to HTML by walking the tree. | |
| 4 | /// | |
| 5 | /// The point of the prototype: this produces the same shape of output as the shipped | |
| 6 | /// single-pass ``OrgRenderer``, but from a parsed tree rather than from source, so a second | |
| 7 | /// renderer (see ``OrgAttributedStringRenderer``) can share the parse instead of re-deriving it. | |
| 8 | public struct OrgHTMLTreeRenderer: Sendable { | |
| 9 | /// The same options the shipped ``OrgRenderer`` takes, so this renderer is a drop-in for it. | |
| 10 | public var options: OrgRenderOptions | |
| 11 | public var highlighter: CodeHighlighter | |
| 12 | ||
| 13 | public init(options: OrgRenderOptions = .init(), highlighter: CodeHighlighter = PlainCodeHighlighter()) { | |
| 14 | self.options = options | |
| 15 | self.highlighter = highlighter | |
| 16 | } | |
| 17 | ||
| 18 | public func render(_ document: OrgDocument) -> String { | |
| 19 | var footnotes = FootnoteNumbering(document: document) | |
| 20 | var html = metadataHeader(document) | |
| 21 | html += document.elements.map { element($0, &footnotes) }.joined() | |
| 22 | html += footnotes.renderSection(self) | |
| 23 | return html | |
| 24 | } | |
| 25 | ||
| 26 | /// The leading `#+TITLE`/`#+AUTHOR`/`#+DATE` block, when the caller wants one. orgo treats | |
| 27 | /// these as document metadata carried by a page template, so the corpus renders with this | |
| 28 | /// off; an app showing a README title turns it on. | |
| 29 | private func metadataHeader(_ document: OrgDocument) -> String { | |
| 30 | guard options.metadataHeader else { return "" } | |
| 31 | let title = document.keyword("title") | |
| 32 | let author = document.keyword("author") | |
| 33 | let date = document.keyword("date") | |
| 34 | guard title != nil || author != nil || date != nil else { return "" } | |
| 35 | ||
| 36 | var html = "<div class=\"org-metadata\">\n" | |
| 37 | if let title { html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n" } | |
| 38 | if let author { html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n" } | |
| 39 | if let date { html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n" } | |
| 40 | return html + "</div>\n" | |
| 41 | } | |
| 42 | ||
| 43 | // MARK: - URL resolution | |
| 44 | ||
| 45 | /// Resolve a link target to a safe `href`, or nil when it cannot be made safe. | |
| 46 | /// | |
| 47 | /// Because the tree keeps targets typed, resolution is a renderer concern applied to the | |
| 48 | /// `.file` case only — no resolver closure has to be threaded through the parse. | |
| 49 | private func href(for target: OrgLinkTarget) -> String? { | |
| 50 | switch target { | |
| 51 | case .id(let identifier): | |
| 52 | return sanitizedReadmeLinkURLString("#\(identifier)") | |
| 53 | case .external(let url): | |
| 54 | return sanitizedReadmeLinkURLString(url) | |
| 55 | case .file(let path): | |
| 56 | return sanitizedReadmeLinkURLString(options.linkURLResolver()?(path) ?? path) | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | /// Resolve an image source to a safe `src`, or nil when it cannot be made safe. | |
| 61 | private func imageSource(_ source: String) -> String? { | |
| 62 | sanitizedReadmeImageURLString(options.imageURLResolver()?(source) ?? source) | |
| 63 | } | |
| 64 | ||
| 65 | // MARK: - Blocks | |
| 66 | ||
| 67 | private func element(_ element: OrgElement, _ notes: inout FootnoteNumbering) -> String { | |
| 68 | switch element { | |
| 69 | case .heading(let heading): | |
| 70 | let level = min(6, max(1, heading.level + options.headingLevelOffset)) | |
| 71 | var inner = "" | |
| 72 | if let todo = heading.todo { | |
| 73 | inner += #"<span class="\#(todo.lowercased()) \#(todo)">\#(todo)</span> "# | |
| 74 | } | |
| 75 | if let priority = heading.priority { | |
| 76 | inner += #"<span class="priority">[#\#(priority)]</span> "# | |
| 77 | } | |
| 78 | inner += renderInline(heading.title, ¬es) | |
| 79 | for tag in heading.tags { | |
| 80 | inner += #" <span class="tag">\#(escapeHTML(tag))</span>"# | |
| 81 | } | |
| 82 | return "<h\(level)>\(inner)</h\(level)>\n" | |
| 83 | ||
| 84 | case .paragraph(let objects): | |
| 85 | return "<p>" + renderInline(objects, ¬es) + "</p>\n" | |
| 86 | ||
| 87 | case .list(let list): | |
| 88 | return renderList(list, ¬es) | |
| 89 | ||
| 90 | case .table(let table): | |
| 91 | return renderTable(table, ¬es) | |
| 92 | ||
| 93 | case .srcBlock(let language, let code): | |
| 94 | let classAttribute = language.map { #" class="language-\#(escapeHTMLAttribute($0))""# } ?? "" | |
| 95 | let body = highlighter.highlightedHTML(code: code, language: language) ?? escapeHTML(code) | |
| 96 | return "<pre><code\(classAttribute)>\(body)</code></pre>\n" | |
| 97 | ||
| 98 | case .exampleBlock(let text): | |
| 99 | return "<pre>\(escapeHTML(text))</pre>\n" | |
| 100 | ||
| 101 | case .quoteBlock(let children): | |
| 102 | return "<blockquote>\n" + children.map { self.element($0, ¬es) }.joined() + "</blockquote>\n" | |
| 103 | ||
| 104 | case .centerBlock(let children): | |
| 105 | return #"<div class="center">"# + "\n" + children.map { self.element($0, ¬es) }.joined() + "</div>\n" | |
| 106 | ||
| 107 | case .verseBlock(let lines): | |
| 108 | let body = lines.map { renderInline($0, ¬es) }.joined(separator: "<br>\n") | |
| 109 | return #"<p class="verse">"# + "\n" + body + "\n</p>\n" | |
| 110 | ||
| 111 | case .specialBlock(let name, let children): | |
| 112 | return #"<div class="\#(escapeHTMLAttribute(name))">"# + "\n" | |
| 113 | + children.map { self.element($0, ¬es) }.joined() + "</div>\n" | |
| 114 | ||
| 115 | case .exportBlock(let backend, let raw): | |
| 116 | return backend == "html" ? raw + "\n" : "" | |
| 117 | ||
| 118 | case .figure(let figure): | |
| 119 | guard let tag = imageTag(figure, caption: figure.caption) else { | |
| 120 | return "<p>" + escapeHTML(figure.alt ?? figure.source) + "</p>\n" | |
| 121 | } | |
| 122 | // A bare image is a paragraph; a caption or explicit attributes promote it to a | |
| 123 | // <figure>, matching org's exporter. | |
| 124 | guard figure.caption != nil || !figure.attributes.isEmpty else { | |
| 125 | return "<p>" + tag + "</p>\n" | |
| 126 | } | |
| 127 | var html = "<figure>" + tag | |
| 128 | if let caption = figure.caption { | |
| 129 | notes.figureNumber += 1 | |
| 130 | html += #"<figcaption><span class="figure-number">Figure \#(notes.figureNumber): </span>"# | |
| 131 | + renderInline(caption, ¬es) + "</figcaption>" | |
| 132 | } | |
| 133 | return html + "</figure>\n" | |
| 134 | ||
| 135 | case .captioned(let name, let caption, let content): | |
| 136 | let idAttribute = name.map { #" id="\#(escapeHTMLAttribute($0))""# } ?? "" | |
| 137 | var html = #"<figure class="org-block"\#(idAttribute)>"# + "\n" | |
| 138 | html += self.element(content, ¬es) | |
| 139 | if let caption { | |
| 140 | html += "<figcaption>" + renderInline(caption, ¬es) + "</figcaption>\n" | |
| 141 | } | |
| 142 | return html + "</figure>\n" | |
| 143 | ||
| 144 | case .horizontalRule: | |
| 145 | return "<hr>\n" | |
| 146 | ||
| 147 | case .footnoteDefinition: | |
| 148 | return "" // collected and emitted in the notes section | |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | private func renderList(_ list: OrgList, _ notes: inout FootnoteNumbering) -> String { | |
| 153 | if list.kind == .description { | |
| 154 | var html = "<dl>\n" | |
| 155 | for item in list.items { | |
| 156 | if let term = item.term { | |
| 157 | html += "<dt>" + renderInline(term, ¬es) + "</dt>\n" | |
| 158 | } | |
| 159 | if let first = item.content.first { | |
| 160 | html += "<dd>" + renderInline(first, ¬es) + "</dd>\n" | |
| 161 | } | |
| 162 | } | |
| 163 | return html + "</dl>\n" | |
| 164 | } | |
| 165 | ||
| 166 | let tag = list.kind == .ordered ? "ol" : "ul" | |
| 167 | var html = "<\(tag)>\n" | |
| 168 | for item in list.items { | |
| 169 | html += "<li>" | |
| 170 | if let checkbox = item.checkbox { | |
| 171 | switch checkbox { | |
| 172 | case .off: html += "<code>[ ]</code> " | |
| 173 | case .on: html += "<code>[X]</code> " | |
| 174 | case .partial: html += "<code>[-]</code> " | |
| 175 | } | |
| 176 | } | |
| 177 | if item.content.count <= 1 { | |
| 178 | html += renderInline(item.content.first ?? [], ¬es) | |
| 179 | } else { | |
| 180 | html += item.content.map { "<p>" + renderInline($0, ¬es) + "</p>" }.joined(separator: "\n") | |
| 181 | } | |
| 182 | if let sublist = item.sublist { | |
| 183 | html += "\n" + renderList(sublist, ¬es) | |
| 184 | } | |
| 185 | html += "</li>\n" | |
| 186 | } | |
| 187 | return html + "</\(tag)>\n" | |
| 188 | } | |
| 189 | ||
| 190 | private func renderTable(_ table: OrgTable, _ notes: inout FootnoteNumbering) -> String { | |
| 191 | var html = "<table>\n" | |
| 192 | var wroteHeader = false | |
| 193 | var inBody = false | |
| 194 | let headerCount = table.headerRowCount | |
| 195 | ||
| 196 | for (index, row) in table.rows.enumerated() { | |
| 197 | switch row { | |
| 198 | case .rule: | |
| 199 | if wroteHeader, !inBody { html += "</thead>\n<tbody>\n"; inBody = true } | |
| 200 | case .cells(let cells): | |
| 201 | let isHeader = headerCount > 0 && index < headerCount | |
| 202 | if isHeader, !wroteHeader { html += "<thead>\n"; wroteHeader = true } | |
| 203 | if !isHeader, !inBody { html += "<tbody>\n"; inBody = true } | |
| 204 | html += "<tr>\n" | |
| 205 | for (column, cell) in cells.enumerated() { | |
| 206 | let tag = isHeader ? "th" : "td" | |
| 207 | let alignment = column < table.alignments.count ? table.alignments[column] : nil | |
| 208 | let style = alignment.map { #" style="text-align: \#($0.rawValue);""# } ?? "" | |
| 209 | html += "<\(tag)\(style)>" + renderInline(cell, ¬es) + "</\(tag)>\n" | |
| 210 | } | |
| 211 | html += "</tr>\n" | |
| 212 | } | |
| 213 | } | |
| 214 | if inBody { html += "</tbody>\n" } | |
| 215 | return html + "</table>\n" | |
| 216 | } | |
| 217 | ||
| 218 | /// The `<img>` for a figure, or nil when the source cannot be resolved to a safe URL — | |
| 219 | /// in which case the caller falls back to text rather than pointing at something unsafe. | |
| 220 | private func imageTag(_ figure: OrgFigure, caption: [OrgObject]?) -> String? { | |
| 221 | guard let source = imageSource(figure.source) else { return nil } | |
| 222 | let alt = figure.alt ?? caption.map { plainText($0) } ?? "" | |
| 223 | var html = #"<img src="\#(source)" alt="\#(escapeHTMLAttribute(alt))""# | |
| 224 | for (key, value) in figure.attributes where key != "alt" { | |
| 225 | html += " \(escapeHTMLAttribute(key))=\"\(escapeHTMLAttribute(value))\"" | |
| 226 | } | |
| 227 | return html + ">" | |
| 228 | } | |
| 229 | ||
| 230 | // MARK: - Inline | |
| 231 | ||
| 232 | func renderInline(_ objects: [OrgObject], _ notes: inout FootnoteNumbering) -> String { | |
| 233 | var html = "" | |
| 234 | for object in objects { | |
| 235 | switch object { | |
| 236 | case .text(let text): html += escapeHTML(text) | |
| 237 | case .bold(let children): html += "<strong>" + renderInline(children, ¬es) + "</strong>" | |
| 238 | case .italic(let children): html += "<em>" + renderInline(children, ¬es) + "</em>" | |
| 239 | case .underline(let children): html += "<u>" + renderInline(children, ¬es) + "</u>" | |
| 240 | case .strikeThrough(let children): html += "<del>" + renderInline(children, ¬es) + "</del>" | |
| 241 | case .verbatim(let text), .code(let text): html += "<code>" + escapeHTML(text) + "</code>" | |
| 242 | case .superscript(let children): html += "<sup>" + renderInline(children, ¬es) + "</sup>" | |
| 243 | case .lineBreak: html += "<br>" | |
| 244 | case .image(let figure): | |
| 245 | html += imageTag(figure, caption: nil) ?? escapeHTML(figure.alt ?? figure.source) | |
| 246 | case .timestamp(let stamp): | |
| 247 | let cssClass = stamp.active ? "timestamp" : "timestamp inactive" | |
| 248 | func time(_ machine: String, _ display: String) -> String { | |
| 249 | #"<time class="\#(cssClass)" datetime="\#(machine)">\#(display)</time>"# | |
| 250 | } | |
| 251 | html += time(stamp.machineValue, stamp.displayValue) | |
| 252 | // A range is two <time> elements joined by an en-dash, as org exports it. | |
| 253 | if let end = stamp.end { | |
| 254 | html += "–" + time(end.machineValue, end.displayValue) | |
| 255 | } | |
| 256 | case .footnoteRef(let label, let inline): | |
| 257 | let number = notes.number(for: label, inline: inline) | |
| 258 | html += ##"<sup class="footnote-ref"><a id="fnr-\##(number)" href="#fn-\##(number)">\##(number)</a></sup>"## | |
| 259 | case .link(let link): | |
| 260 | let text = link.description.map { renderInline($0, ¬es) } | |
| 261 | ?? escapeHTML(displayValue(link.target)) | |
| 262 | // An unsafe or unresolvable target degrades to its text, never a bad anchor. | |
| 263 | if let href = href(for: link.target) { | |
| 264 | html += #"<a href="\#(href)">\#(text)</a>"# | |
| 265 | } else { | |
| 266 | html += text | |
| 267 | } | |
| 268 | } | |
| 269 | } | |
| 270 | return html | |
| 271 | } | |
| 272 | ||
| 273 | private func displayValue(_ target: OrgLinkTarget) -> String { | |
| 274 | switch target { | |
| 275 | case .external(let url): return url | |
| 276 | case .file(let path): return path | |
| 277 | case .id(let identifier): return identifier | |
| 278 | } | |
| 279 | } | |
| 280 | ||
| 281 | /// Inline objects reduced to plain text, for an `alt` attribute. | |
| 282 | func plainText(_ objects: [OrgObject]) -> String { | |
| 283 | objects.map { object in | |
| 284 | switch object { | |
| 285 | case .text(let text): return text | |
| 286 | case .verbatim(let text), .code(let text): return text | |
| 287 | case .bold(let c), .italic(let c), .underline(let c), .strikeThrough(let c), .superscript(let c): | |
| 288 | return plainText(c) | |
| 289 | case .link(let link): return link.description.map { plainText($0) } ?? displayValue(link.target) | |
| 290 | case .timestamp(let stamp): return stamp.displayValue | |
| 291 | case .image(let figure): return figure.alt ?? "" | |
| 292 | case .footnoteRef, .lineBreak: return "" | |
| 293 | } | |
| 294 | }.joined() | |
| 295 | } | |
| 296 | } | |
| 297 | ||
| 298 | // MARK: - Footnote numbering | |
| 299 | ||
| 300 | /// Assigns footnote numbers in first-reference order and renders the notes section. | |
| 301 | struct FootnoteNumbering { | |
| 302 | private var numbers: [String: Int] = [:] | |
| 303 | private var order: [String] = [] | |
| 304 | /// Reference-style definitions, gathered from the document's `[fn:x] …` lines. | |
| 305 | private var definitions: [String: [OrgObject]] = [:] | |
| 306 | /// Inline definitions, gathered from `[fn:x:text]` references as they are rendered. | |
| 307 | private var inlineDefinitions: [String: [OrgObject]] = [:] | |
| 308 | var figureNumber = 0 | |
| 309 | ||
| 310 | init(document: OrgDocument) { | |
| 311 | for element in document.elements { | |
| 312 | if case .footnoteDefinition(let label, let content) = element { | |
| 313 | definitions[label] = content | |
| 314 | } | |
| 315 | } | |
| 316 | } | |
| 317 | ||
| 318 | mutating func number(for label: String, inline: [OrgObject]? = nil) -> Int { | |
| 319 | if let inline, inlineDefinitions[label] == nil { inlineDefinitions[label] = inline } | |
| 320 | if let existing = numbers[label] { return existing } | |
| 321 | let next = order.count + 1 | |
| 322 | numbers[label] = next | |
| 323 | order.append(label) | |
| 324 | return next | |
| 325 | } | |
| 326 | ||
| 327 | mutating func renderSection(_ renderer: OrgHTMLTreeRenderer) -> String { | |
| 328 | guard !order.isEmpty else { return "" } | |
| 329 | var html = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n" | |
| 330 | for label in order { | |
| 331 | let n = numbers[label] ?? 0 | |
| 332 | let back = ##"<a class="footnote-back" href="#fnr-\##(n)" aria-label="Back to reference \##(n)">↩</a>"## | |
| 333 | // An inline footnote's text sits directly in the item; a reference-style | |
| 334 | // definition is a paragraph, matching org's exporter. | |
| 335 | if let inline = inlineDefinitions[label] { | |
| 336 | html += "<li id=\"fn-\(n)\">\(renderer.renderInline(inline, &self)) \(back)</li>\n" | |
| 337 | } else { | |
| 338 | let body = definitions[label].map { renderer.renderInline($0, &self) } ?? "" | |
| 339 | html += "<li id=\"fn-\(n)\"><p>\(body)</p>\n \(back)</li>\n" | |
| 340 | } | |
| 341 | } | |
| 342 | return html + "</ol>\n</section>\n" | |
| 343 | } | |
| 344 | } | |
Sources/OrgSwift/AST/OrgInlineParser.swift added +321
| @@ -0,0 +1,321 @@ | ||
| 1 | import 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 | ||
| 10 | extension 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 | } | |
Sources/OrgSwift/AST/OrgParser.swift added +408
| @@ -0,0 +1,408 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Parses org source into an ``OrgDocument``. | |
| 4 | /// | |
| 5 | /// This is the prototype of the AST split: parsing happens once, here, and renderers walk the | |
| 6 | /// result. It is deliberately a separate pipeline from the shipped ``OrgRenderer`` (which goes | |
| 7 | /// straight from source to an HTML string) so the two can be compared before anything migrates. | |
| 8 | public enum OrgParser { | |
| 9 | ||
| 10 | public static func parse(_ source: String) -> OrgDocument { | |
| 11 | var document = OrgDocument() | |
| 12 | let lines = source | |
| 13 | .replacingOccurrences(of: "\r\n", with: "\n") | |
| 14 | .replacingOccurrences(of: "\r", with: "\n") | |
| 15 | .components(separatedBy: "\n") | |
| 16 | var index = 0 | |
| 17 | var pendingCaption: String? | |
| 18 | var pendingName: String? | |
| 19 | var pendingAttrs: [(key: String, value: String)] = [] | |
| 20 | ||
| 21 | func flushPending() { | |
| 22 | pendingCaption = nil | |
| 23 | pendingName = nil | |
| 24 | pendingAttrs = [] | |
| 25 | } | |
| 26 | ||
| 27 | /// Append a block, wrapping it in `.captioned` when an affiliated `#+CAPTION:` or | |
| 28 | /// `#+NAME:` precedes it — org's exporter turns that pairing into a `<figure>`. | |
| 29 | func append(_ element: OrgElement) { | |
| 30 | if pendingCaption != nil || pendingName != nil { | |
| 31 | document.elements.append(.captioned( | |
| 32 | name: pendingName, | |
| 33 | caption: pendingCaption.map(parseInline), | |
| 34 | content: element | |
| 35 | )) | |
| 36 | } else { | |
| 37 | document.elements.append(element) | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | while index < lines.count { | |
| 42 | let line = lines[index] | |
| 43 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 44 | ||
| 45 | if trimmed.isEmpty { index += 1; continue } | |
| 46 | ||
| 47 | // Comments. | |
| 48 | if trimmed == "#" || trimmed.hasPrefix("# ") { index += 1; continue } | |
| 49 | ||
| 50 | // Property drawers are heading metadata; org's exporter drops them. | |
| 51 | if trimmed == ":PROPERTIES:" { | |
| 52 | index += 1 | |
| 53 | while index < lines.count, | |
| 54 | lines[index].trimmingCharacters(in: .whitespaces) != ":END:" { | |
| 55 | index += 1 | |
| 56 | } | |
| 57 | if index < lines.count { index += 1 } | |
| 58 | continue | |
| 59 | } | |
| 60 | ||
| 61 | // Affiliated keywords and document metadata. | |
| 62 | if let directive = orgKeywordDirective(in: trimmed) { | |
| 63 | switch directive.keyword { | |
| 64 | case "caption": pendingCaption = directive.value | |
| 65 | case "name": pendingName = directive.value | |
| 66 | case "attr_html": pendingAttrs = parseAttributes(directive.value) | |
| 67 | default: document.keywords.append((directive.keyword, directive.value)) | |
| 68 | } | |
| 69 | index += 1 | |
| 70 | continue | |
| 71 | } | |
| 72 | ||
| 73 | // Blocks: #+begin_… / #+end_… | |
| 74 | if trimmed.lowercased().hasPrefix("#+begin_") { | |
| 75 | let (element, next) = parseBlock(lines, from: index) | |
| 76 | if let element { append(element) } | |
| 77 | index = next | |
| 78 | flushPending() | |
| 79 | continue | |
| 80 | } | |
| 81 | ||
| 82 | // Heading. | |
| 83 | if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) { | |
| 84 | document.elements.append(.heading(parseHeading(stars: match.1.count, rest: String(match.2)))) | |
| 85 | index += 1 | |
| 86 | flushPending() | |
| 87 | continue | |
| 88 | } | |
| 89 | ||
| 90 | // Horizontal rule. | |
| 91 | if isOrgHorizontalRule(trimmed) { | |
| 92 | document.elements.append(.horizontalRule) | |
| 93 | index += 1 | |
| 94 | flushPending() | |
| 95 | continue | |
| 96 | } | |
| 97 | ||
| 98 | // Footnote definition. | |
| 99 | if let def = orgFootnoteDefinition(in: trimmed) { | |
| 100 | document.elements.append(.footnoteDefinition(label: def.label, content: parseInline(def.text))) | |
| 101 | index += 1 | |
| 102 | flushPending() | |
| 103 | continue | |
| 104 | } | |
| 105 | ||
| 106 | // A standalone image link, promoted to a figure by an affiliated caption/attrs. | |
| 107 | if let path = standaloneOrgImage(in: trimmed) { | |
| 108 | document.elements.append(.figure(OrgFigure( | |
| 109 | source: path, | |
| 110 | caption: pendingCaption.map(parseInline), | |
| 111 | attributes: pendingAttrs | |
| 112 | ))) | |
| 113 | index += 1 | |
| 114 | flushPending() | |
| 115 | continue | |
| 116 | } | |
| 117 | ||
| 118 | // Table. | |
| 119 | if isTableLine(trimmed) { | |
| 120 | let (table, next) = parseTable(lines, from: index) | |
| 121 | append(.table(table)) | |
| 122 | index = next | |
| 123 | flushPending() | |
| 124 | continue | |
| 125 | } | |
| 126 | ||
| 127 | // List. | |
| 128 | if isListMarkerLine(trimmed), !isIndentedContinuationLine(line) { | |
| 129 | let (list, next) = parseList(lines, from: index) | |
| 130 | document.elements.append(.list(list)) | |
| 131 | index = next | |
| 132 | flushPending() | |
| 133 | continue | |
| 134 | } | |
| 135 | ||
| 136 | // Paragraph: consume until a blank line or a line that starts another construct. | |
| 137 | var paragraph: [String] = [] | |
| 138 | while index < lines.count { | |
| 139 | let candidate = lines[index] | |
| 140 | let candidateTrimmed = candidate.trimmingCharacters(in: .whitespaces) | |
| 141 | if candidateTrimmed.isEmpty || startsNewConstruct(candidateTrimmed, raw: candidate) { break } | |
| 142 | paragraph.append(candidateTrimmed) | |
| 143 | index += 1 | |
| 144 | } | |
| 145 | if !paragraph.isEmpty { | |
| 146 | document.elements.append(.paragraph(parseInline(paragraph.joined(separator: " ")))) | |
| 147 | } | |
| 148 | flushPending() | |
| 149 | } | |
| 150 | ||
| 151 | return document | |
| 152 | } | |
| 153 | ||
| 154 | /// Would this line begin a construct other than the paragraph currently being consumed? | |
| 155 | private static func startsNewConstruct(_ trimmed: String, raw: String) -> Bool { | |
| 156 | if trimmed.hasPrefix("#+") || trimmed.hasPrefix("#") { return true } | |
| 157 | if trimmed.firstMatch(of: /^\*{1,6}\s+/) != nil { return true } | |
| 158 | if isOrgHorizontalRule(trimmed) { return true } | |
| 159 | if isTableLine(trimmed) { return true } | |
| 160 | if orgFootnoteDefinition(in: trimmed) != nil { return true } | |
| 161 | if isListMarkerLine(trimmed), !isIndentedContinuationLine(raw) { return true } | |
| 162 | return false | |
| 163 | } | |
| 164 | ||
| 165 | // MARK: - Heading | |
| 166 | ||
| 167 | private static func parseHeading(stars: Int, rest: String) -> OrgHeading { | |
| 168 | var body = rest | |
| 169 | var todo: String? | |
| 170 | var priority: Character? | |
| 171 | ||
| 172 | for keyword in ["TODO", "DONE"] where body == keyword || body.hasPrefix("\(keyword) ") { | |
| 173 | todo = keyword | |
| 174 | body = String(body.dropFirst(keyword.count)).trimmingCharacters(in: .whitespaces) | |
| 175 | break | |
| 176 | } | |
| 177 | if let match = body.firstMatch(of: /^\[#([A-Z])\]\s*/) { | |
| 178 | priority = Character(String(match.1)) | |
| 179 | body = String(body[match.range.upperBound...]) | |
| 180 | } | |
| 181 | let (title, tags) = splitHeadingTags(body) | |
| 182 | return OrgHeading(level: stars, todo: todo, priority: priority, | |
| 183 | title: parseInline(title), tags: tags) | |
| 184 | } | |
| 185 | ||
| 186 | // MARK: - Blocks | |
| 187 | ||
| 188 | private static func parseBlock(_ lines: [String], from start: Int) -> (OrgElement?, Int) { | |
| 189 | let opener = lines[start].trimmingCharacters(in: .whitespaces) | |
| 190 | let lower = opener.lowercased() | |
| 191 | let name = String(lower.dropFirst("#+begin_".count)).split(separator: " ").first.map(String.init) ?? "" | |
| 192 | let argument = opener | |
| 193 | .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) | |
| 194 | .dropFirst().first.map { String($0).trimmingCharacters(in: .whitespaces) } | |
| 195 | ||
| 196 | var body: [String] = [] | |
| 197 | var index = start + 1 | |
| 198 | while index < lines.count { | |
| 199 | let trimmed = lines[index].trimmingCharacters(in: .whitespaces).lowercased() | |
| 200 | if trimmed == "#+end_\(name)" { index += 1; break } | |
| 201 | body.append(lines[index]) | |
| 202 | index += 1 | |
| 203 | } | |
| 204 | ||
| 205 | switch name { | |
| 206 | case "src": | |
| 207 | return (.srcBlock(language: argument?.isEmpty == false ? argument : nil, | |
| 208 | code: body.joined(separator: "\n")), index) | |
| 209 | case "example": | |
| 210 | return (.exampleBlock(body.joined(separator: "\n")), index) | |
| 211 | case "quote": | |
| 212 | return (.quoteBlock(parse(body.joined(separator: "\n")).elements), index) | |
| 213 | case "center": | |
| 214 | return (.centerBlock(parse(body.joined(separator: "\n")).elements), index) | |
| 215 | case "verse": | |
| 216 | return (.verseBlock(body.map(parseInline)), index) | |
| 217 | case "export": | |
| 218 | return (.exportBlock(backend: (argument ?? "").lowercased(), | |
| 219 | raw: body.joined(separator: "\n")), index) | |
| 220 | default: | |
| 221 | return (.specialBlock(name: name, content: parse(body.joined(separator: "\n")).elements), index) | |
| 222 | } | |
| 223 | } | |
| 224 | ||
| 225 | // MARK: - Table | |
| 226 | ||
| 227 | private static func parseTable(_ lines: [String], from start: Int) -> (OrgTable, Int) { | |
| 228 | var rows: [OrgTableRow] = [] | |
| 229 | var alignments: [OrgAlignment?] = [] | |
| 230 | var index = start | |
| 231 | ||
| 232 | while index < lines.count { | |
| 233 | let trimmed = lines[index].trimmingCharacters(in: .whitespaces) | |
| 234 | guard isTableLine(trimmed) else { break } | |
| 235 | ||
| 236 | // A separator row's columns are divided by `+`, not `|`, so it needs its own | |
| 237 | // split — `|:---+---:|` is two columns, which splitting on `|` would miss. | |
| 238 | let separatorCells = parseOrgTableSeparatorRow(trimmed) | |
| 239 | let cells = separatorCells.allSatisfy(isTableSeparatorCell) | |
| 240 | ? separatorCells : parseTableRow(trimmed) | |
| 241 | if !cells.isEmpty, cells.allSatisfy(isTableSeparatorCell) { | |
| 242 | rows.append(.rule) | |
| 243 | let parsed = cells.map { cell -> OrgAlignment? in | |
| 244 | switch tableAlignment(for: cell) { | |
| 245 | case "left": return .left | |
| 246 | case "center": return .center | |
| 247 | case "right": return .right | |
| 248 | default: return nil | |
| 249 | } | |
| 250 | } | |
| 251 | if alignments.isEmpty || alignments.allSatisfy({ $0 == nil }) { alignments = parsed } | |
| 252 | } else { | |
| 253 | rows.append(.cells(cells.map(parseInline))) | |
| 254 | } | |
| 255 | index += 1 | |
| 256 | } | |
| 257 | return (OrgTable(rows: rows, alignments: alignments), index) | |
| 258 | } | |
| 259 | ||
| 260 | // MARK: - List | |
| 261 | ||
| 262 | private static func parseList(_ lines: [String], from start: Int) -> (OrgList, Int) { | |
| 263 | var block: [String] = [] | |
| 264 | var index = start | |
| 265 | var pendingBlanks: [String] = [] | |
| 266 | // A top-level marker of the other kind starts a *separate* list: an ordered list | |
| 267 | // followed by a bullet list is two lists, not one with mixed items. | |
| 268 | let startsOrdered = orderedListItem(in: lines[start].trimmingCharacters(in: .whitespaces)) != nil | |
| 269 | ||
| 270 | while index < lines.count { | |
| 271 | let line = lines[index] | |
| 272 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 273 | if trimmed.isEmpty { | |
| 274 | pendingBlanks.append(line); index += 1; continue | |
| 275 | } | |
| 276 | if isListMarkerLine(trimmed), !isIndentedContinuationLine(line) { | |
| 277 | guard (orderedListItem(in: trimmed) != nil) == startsOrdered else { break } | |
| 278 | block.append(contentsOf: pendingBlanks); pendingBlanks = [] | |
| 279 | block.append(line); index += 1; continue | |
| 280 | } | |
| 281 | if isIndentedContinuationLine(line) { | |
| 282 | block.append(contentsOf: pendingBlanks); pendingBlanks = [] | |
| 283 | block.append(line); index += 1; continue | |
| 284 | } | |
| 285 | break | |
| 286 | } | |
| 287 | return (buildList(block), index) | |
| 288 | } | |
| 289 | ||
| 290 | /// Group a list block's lines into items, recursing for nested lists. | |
| 291 | private static func buildList(_ lines: [String]) -> OrgList { | |
| 292 | let base = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } | |
| 293 | .map(leadingWidth).min() ?? 0 | |
| 294 | let normalized = lines.map { dropLeading($0, base) } | |
| 295 | ||
| 296 | var groups: [[String]] = [] | |
| 297 | var current: [String] = [] | |
| 298 | for line in normalized { | |
| 299 | if isListMarkerLine(line) { | |
| 300 | if !current.isEmpty { groups.append(current) } | |
| 301 | current = [line] | |
| 302 | } else if !current.isEmpty { | |
| 303 | current.append(line) | |
| 304 | } | |
| 305 | } | |
| 306 | if !current.isEmpty { groups.append(current) } | |
| 307 | ||
| 308 | let firstMarker = groups.first?.first ?? "" | |
| 309 | var kind: OrgListKind = orderedListItem(in: firstMarker) != nil ? .ordered : .unordered | |
| 310 | if stripMarker(firstMarker).contains(" :: ") { kind = .description } | |
| 311 | ||
| 312 | let items = groups.map { buildItem($0, kind: kind) } | |
| 313 | return OrgList(kind: kind, items: items) | |
| 314 | } | |
| 315 | ||
| 316 | private static func buildItem(_ lines: [String], kind: OrgListKind) -> OrgListItem { | |
| 317 | var head = stripMarker(lines[0]) | |
| 318 | var checkbox: OrgCheckbox? | |
| 319 | if head.hasPrefix("[ ] ") { checkbox = .off; head = String(head.dropFirst(4)) } | |
| 320 | else if head.hasPrefix("[X] ") || head.hasPrefix("[x] ") { checkbox = .on; head = String(head.dropFirst(4)) } | |
| 321 | else if head.hasPrefix("[-] ") { checkbox = .partial; head = String(head.dropFirst(4)) } | |
| 322 | ||
| 323 | let rest = Array(lines.dropFirst()) | |
| 324 | let childIndent = rest.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } | |
| 325 | .map(leadingWidth).min() ?? 0 | |
| 326 | let outdented = rest.map { dropLeading($0, childIndent) } | |
| 327 | ||
| 328 | var paragraphs: [String] = [] | |
| 329 | var currentParagraph = [head] | |
| 330 | var sublistLines: [String] = [] | |
| 331 | var inSublist = false | |
| 332 | ||
| 333 | func flush() { | |
| 334 | let joined = currentParagraph.joined(separator: " ").trimmingCharacters(in: .whitespaces) | |
| 335 | if !joined.isEmpty { paragraphs.append(joined) } | |
| 336 | currentParagraph = [] | |
| 337 | } | |
| 338 | ||
| 339 | for line in outdented { | |
| 340 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 341 | if isListMarkerLine(line) || inSublist { | |
| 342 | if !inSublist { flush() } | |
| 343 | inSublist = true | |
| 344 | sublistLines.append(line) | |
| 345 | } else if trimmed.isEmpty { | |
| 346 | flush() | |
| 347 | } else { | |
| 348 | currentParagraph.append(trimmed) | |
| 349 | } | |
| 350 | } | |
| 351 | flush() | |
| 352 | ||
| 353 | var term: [OrgObject]? | |
| 354 | var content = paragraphs | |
| 355 | if kind == .description, let first = paragraphs.first, let range = first.range(of: " :: ") { | |
| 356 | term = parseInline(String(first[..<range.lowerBound])) | |
| 357 | content[0] = String(first[range.upperBound...]) | |
| 358 | } | |
| 359 | ||
| 360 | return OrgListItem( | |
| 361 | checkbox: checkbox, | |
| 362 | term: term, | |
| 363 | content: content.map(parseInline), | |
| 364 | sublist: sublistLines.isEmpty ? nil : buildList(sublistLines) | |
| 365 | ) | |
| 366 | } | |
| 367 | ||
| 368 | // MARK: - Helpers | |
| 369 | ||
| 370 | private static func parseAttributes(_ value: String) -> [(key: String, value: String)] { | |
| 371 | guard let regex = try? NSRegularExpression(pattern: #":([A-Za-z_][A-Za-z0-9_-]*)\s+("[^"]*"|\S+)"#) else { | |
| 372 | return [] | |
| 373 | } | |
| 374 | let ns = value as NSString | |
| 375 | return regex.matches(in: value, range: NSRange(location: 0, length: ns.length)).map { m in | |
| 376 | var raw = ns.substring(with: m.range(at: 2)) | |
| 377 | if raw.count >= 2, raw.hasPrefix("\""), raw.hasSuffix("\"") { raw = String(raw.dropFirst().dropLast()) } | |
| 378 | return (ns.substring(with: m.range(at: 1)).lowercased(), raw) | |
| 379 | } | |
| 380 | } | |
| 381 | ||
| 382 | private static func stripMarker(_ line: String) -> String { | |
| 383 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 384 | if trimmed.hasPrefix("- ") || trimmed.hasPrefix("+ ") { return String(trimmed.dropFirst(2)) } | |
| 385 | if let match = trimmed.firstMatch(of: /^\d+[.)]\s+(.*)$/) { return String(match.1) } | |
| 386 | return trimmed | |
| 387 | } | |
| 388 | ||
| 389 | private static func leadingWidth(_ line: String) -> Int { | |
| 390 | var count = 0 | |
| 391 | for ch in line { | |
| 392 | if ch == " " { count += 1 } else if ch == "\t" { count += 8 } else { break } | |
| 393 | } | |
| 394 | return count | |
| 395 | } | |
| 396 | ||
| 397 | private static func dropLeading(_ line: String, _ n: Int) -> String { | |
| 398 | var dropped = 0 | |
| 399 | var index = line.startIndex | |
| 400 | while index < line.endIndex, dropped < n { | |
| 401 | if line[index] == " " { dropped += 1 } | |
| 402 | else if line[index] == "\t" { dropped += 8 } | |
| 403 | else { break } | |
| 404 | index = line.index(after: index) | |
| 405 | } | |
| 406 | return String(line[index...]) | |
| 407 | } | |
| 408 | } | |
Sources/OrgSwift/Escaping.swift −46
| @@ -80,49 +80,3 @@ func matchesRegex(_ text: String, pattern: String) -> Bool { | ||
| 80 | 80 | let range = NSRange(location: 0, length: (text as NSString).length) |
| 81 | 81 | return regex.firstMatch(in: text, range: range) != nil |
| 82 | 82 | } |
| 83 | ||
| 84 | func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool { | |
| 85 | guard range.location != NSNotFound else { return false } | |
| 86 | let prefix = text.substring(to: range.location) | |
| 87 | guard let lastOpen = prefix.lastIndex(of: "<") else { return false } | |
| 88 | guard let lastClose = prefix.lastIndex(of: ">") else { return true } | |
| 89 | return lastOpen > lastClose | |
| 90 | } | |
| 91 | ||
| 92 | func protectMatches( | |
| 93 | in text: String, | |
| 94 | pattern: String, | |
| 95 | protectedFragments: inout [String: String], | |
| 96 | transform: (NSTextCheckingResult, NSString) -> String | |
| 97 | ) -> String { | |
| 98 | guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } | |
| 99 | var result = text | |
| 100 | let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length)) | |
| 101 | ||
| 102 | for match in matches.reversed() { | |
| 103 | let token = "ZZPROTECTED\(protectedFragments.count)ZZ" | |
| 104 | let nsText = result as NSString | |
| 105 | protectedFragments[token] = transform(match, nsText) | |
| 106 | result = nsText.replacingCharacters(in: match.range, with: token) | |
| 107 | } | |
| 108 | ||
| 109 | return result | |
| 110 | } | |
| 111 | ||
| 112 | func replaceMatches( | |
| 113 | in text: String, | |
| 114 | pattern: String, | |
| 115 | transform: (NSTextCheckingResult, NSString) -> String | |
| 116 | ) -> String { | |
| 117 | guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } | |
| 118 | var result = text | |
| 119 | let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length)) | |
| 120 | ||
| 121 | for match in matches.reversed() { | |
| 122 | let nsText = result as NSString | |
| 123 | let replacement = transform(match, nsText) | |
| 124 | result = nsText.replacingCharacters(in: match.range, with: replacement) | |
| 125 | } | |
| 126 | ||
| 127 | return result | |
| 128 | } | |
Sources/OrgSwift/Footnotes.swift −66
| @@ -1,71 +1,5 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | // Org footnotes: references `[fn:1]` in running text, inline footnotes that define at the | |
| 4 | // point of use `[fn:label:text]`, and reference-style definitions on their own line | |
| 5 | // `[fn:1] the definition`. References become `<sup>` anchors; the definitions are | |
| 6 | // collected and emitted as a `<section class="footnotes">` at the end of the document, in | |
| 7 | // order of first reference — matching orgo's `ox-html` shape. | |
| 8 | ||
| 9 | /// Accumulates footnote references and definitions across a document render. A single | |
| 10 | /// instance lives for one `orgToHTML` call; references register as paragraphs flush, and | |
| 11 | /// the section is rendered once at the end. | |
| 12 | final class FootnoteCollector { | |
| 13 | private(set) var order: [String] = [] | |
| 14 | private var numbers: [String: Int] = [:] | |
| 15 | private var inlineText: [String: String] = [:] | |
| 16 | private var definitions: [String: String] = [:] | |
| 17 | ||
| 18 | var hasEntries: Bool { !order.isEmpty } | |
| 19 | ||
| 20 | /// Register a reference and return its display number. `inline` is the text of an | |
| 21 | /// inline footnote (`[fn:label:inline]`), nil for a plain reference. | |
| 22 | func reference(label: String, inline: String?) -> Int { | |
| 23 | let number: Int | |
| 24 | if let existing = numbers[label] { | |
| 25 | number = existing | |
| 26 | } else { | |
| 27 | number = order.count + 1 | |
| 28 | numbers[label] = number | |
| 29 | order.append(label) | |
| 30 | } | |
| 31 | if let inline, inlineText[label] == nil { | |
| 32 | inlineText[label] = inline | |
| 33 | } | |
| 34 | return number | |
| 35 | } | |
| 36 | ||
| 37 | /// Record a reference-style definition line. A definition can arrive before or after | |
| 38 | /// its reference; the number is assigned by reference order regardless. | |
| 39 | func define(label: String, text: String) { | |
| 40 | if numbers[label] == nil { | |
| 41 | numbers[label] = order.count + 1 | |
| 42 | order.append(label) | |
| 43 | } | |
| 44 | definitions[label] = text | |
| 45 | } | |
| 46 | ||
| 47 | /// Render the footnotes section, or "" if there are none. `inlineRenderer` renders a | |
| 48 | /// block definition's own inline markup (it must not itself collect footnotes). Inline | |
| 49 | /// footnote text was captured mid-inline and is already escaped, so it is emitted | |
| 50 | /// directly — running it through `inlineRenderer` again would double-escape it. | |
| 51 | func renderSection(inlineRenderer: (String) -> String) -> String { | |
| 52 | guard hasEntries else { return "" } | |
| 53 | var html = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n" | |
| 54 | for label in order { | |
| 55 | let n = numbers[label] ?? 0 | |
| 56 | let back = ##"<a class="footnote-back" href="#fnr-\##(n)" aria-label="Back to reference \##(n)">↩</a>"## | |
| 57 | if let inline = inlineText[label] { | |
| 58 | html += "<li id=\"fn-\(n)\">\(inline) \(back)</li>\n" | |
| 59 | } else { | |
| 60 | let body = definitions[label].map(inlineRenderer) ?? "" | |
| 61 | html += "<li id=\"fn-\(n)\"><p>\(body)</p>\n \(back)</li>\n" | |
| 62 | } | |
| 63 | } | |
| 64 | html += "</ol>\n</section>\n" | |
| 65 | return html | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | 3 | /// Detect a reference-style footnote definition line: `[fn:label] text…`. Returns the |
| 70 | 4 | /// label and the definition text, or nil if the line is not one. |
| 71 | 5 | func orgFootnoteDefinition(in line: String) -> (label: String, text: String)? { |
Sources/OrgSwift/Inline.swift deleted −171
| @@ -1,171 +0,0 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | func processOrgInline( | |
| 4 | _ text: String, | |
| 5 | imageURLResolver: ((String) -> String?)? = nil, | |
| 6 | linkURLResolver: ((String) -> String?)? = nil, | |
| 7 | footnotes: FootnoteCollector? = nil | |
| 8 | ) -> String { | |
| 9 | var result = escapeHTML(text) | |
| 10 | var protectedFragments: [String: String] = [:] | |
| 11 | ||
| 12 | result = protectMatches( | |
| 13 | in: result, | |
| 14 | pattern: #"\[\[([^\]]+)\]\[\[([^\]]+)\]\]\]"#, | |
| 15 | protectedFragments: &protectedFragments | |
| 16 | ) { match, nsText in | |
| 17 | let destination = nsText.substring(with: match.range(at: 1)) | |
| 18 | let source = nsText.substring(with: match.range(at: 2)) | |
| 19 | guard let imageHTML = makeOrgImageHTML( | |
| 20 | source: source, | |
| 21 | alt: nil, | |
| 22 | imageURLResolver: imageURLResolver | |
| 23 | ) else { | |
| 24 | return source | |
| 25 | } | |
| 26 | let resolvedDestination = linkURLResolver?(destination) ?? destination | |
| 27 | guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else { | |
| 28 | return imageHTML | |
| 29 | } | |
| 30 | return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"# | |
| 31 | } | |
| 32 | ||
| 33 | result = protectOrgLinks( | |
| 34 | in: result, | |
| 35 | protectedFragments: &protectedFragments, | |
| 36 | imageURLResolver: imageURLResolver, | |
| 37 | linkURLResolver: linkURLResolver | |
| 38 | ) | |
| 39 | ||
| 40 | result = protectTimestamps(in: result, protectedFragments: &protectedFragments) | |
| 41 | ||
| 42 | if let footnotes { | |
| 43 | // Assign footnote numbers in textual (forward) order first. The protect passes | |
| 44 | // below replace right-to-left, so registering there would number references | |
| 45 | // backwards and reverse the definitions section; this pre-scan fixes the order, | |
| 46 | // and the passes then just look up the already-assigned number. | |
| 47 | if let scan = try? NSRegularExpression(pattern: #"\[fn:([A-Za-z0-9_-]+)(?::([^\]]*))?\]"#) { | |
| 48 | let ns = result as NSString | |
| 49 | for m in scan.matches(in: result, range: NSRange(location: 0, length: ns.length)) { | |
| 50 | let label = ns.substring(with: m.range(at: 1)) | |
| 51 | let textRange = m.range(at: 2) | |
| 52 | let inline = textRange.location == NSNotFound ? nil : ns.substring(with: textRange) | |
| 53 | _ = footnotes.reference(label: label, inline: inline) | |
| 54 | } | |
| 55 | } | |
| 56 | // Inline footnote `[fn:label:text]` first (more specific), then a plain reference. | |
| 57 | result = protectMatches( | |
| 58 | in: result, | |
| 59 | pattern: #"\[fn:([A-Za-z0-9_-]+):([^\]]*)\]"#, | |
| 60 | protectedFragments: &protectedFragments | |
| 61 | ) { match, nsText in | |
| 62 | let label = nsText.substring(with: match.range(at: 1)) | |
| 63 | let inline = nsText.substring(with: match.range(at: 2)) | |
| 64 | let n = footnotes.reference(label: label, inline: inline) | |
| 65 | return ##"<sup class="footnote-ref"><a id="fnr-\##(n)" href="#fn-\##(n)">\##(n)</a></sup>"## | |
| 66 | } | |
| 67 | result = protectMatches( | |
| 68 | in: result, | |
| 69 | pattern: #"\[fn:([A-Za-z0-9_-]+)\]"#, | |
| 70 | protectedFragments: &protectedFragments | |
| 71 | ) { match, nsText in | |
| 72 | let label = nsText.substring(with: match.range(at: 1)) | |
| 73 | let n = footnotes.reference(label: label, inline: nil) | |
| 74 | return ##"<sup class="footnote-ref"><a id="fnr-\##(n)" href="#fn-\##(n)">\##(n)</a></sup>"## | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | // Superscript: `x^2` or `x^{group}`. Must attach to a preceding character, so `3^rd` | |
| 79 | // works but a lone `^` does not. | |
| 80 | result = protectMatches( | |
| 81 | in: result, | |
| 82 | pattern: #"(?<=[A-Za-z0-9])\^(\{[^}]*\}|[A-Za-z0-9]+)"#, | |
| 83 | protectedFragments: &protectedFragments | |
| 84 | ) { match, nsText in | |
| 85 | var inner = nsText.substring(with: match.range(at: 1)) | |
| 86 | if inner.hasPrefix("{") && inner.hasSuffix("}") { | |
| 87 | inner = String(inner.dropFirst().dropLast()) | |
| 88 | } | |
| 89 | return "<sup>\(inner)</sup>" | |
| 90 | } | |
| 91 | ||
| 92 | // Bare URLs in running text become links. Bracketed `[[…]]` links are already | |
| 93 | // protected above, so this only sees truly bare URLs; trailing sentence punctuation is | |
| 94 | // left outside the link. | |
| 95 | result = protectMatches( | |
| 96 | in: result, | |
| 97 | pattern: #"https?://[^\s<>()\[\]]+"#, | |
| 98 | protectedFragments: &protectedFragments | |
| 99 | ) { match, nsText in | |
| 100 | var url = nsText.substring(with: match.range) | |
| 101 | var trailing = "" | |
| 102 | while let last = url.last, ".,;:!?".contains(last) { | |
| 103 | trailing = String(last) + trailing | |
| 104 | url.removeLast() | |
| 105 | } | |
| 106 | guard let safe = sanitizedReadmeLinkURLString(url) else { return url + trailing } | |
| 107 | return #"<a href="\#(safe)">\#(url)</a>"# + trailing | |
| 108 | } | |
| 109 | ||
| 110 | result = protectMatches( | |
| 111 | in: result, | |
| 112 | pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#, | |
| 113 | protectedFragments: &protectedFragments | |
| 114 | ) { match, nsText in | |
| 115 | let tildeRange = match.range(at: 1) | |
| 116 | let equalsRange = match.range(at: 2) | |
| 117 | let codeText: String | |
| 118 | if tildeRange.location != NSNotFound { | |
| 119 | codeText = nsText.substring(with: tildeRange) | |
| 120 | } else { | |
| 121 | codeText = nsText.substring(with: equalsRange) | |
| 122 | } | |
| 123 | return "<code>\(codeText)</code>" | |
| 124 | } | |
| 125 | result = protectMatches( | |
| 126 | in: result, | |
| 127 | pattern: #"(?<!\S)\+(.+?)\+(?=\s|$|[.,;:!?])"#, | |
| 128 | protectedFragments: &protectedFragments | |
| 129 | ) { match, nsText in | |
| 130 | let value = nsText.substring(with: match.range(at: 1)) | |
| 131 | return "<del>\(value)</del>" | |
| 132 | } | |
| 133 | result = protectMatches( | |
| 134 | in: result, | |
| 135 | pattern: #"(?<!\S)_(.+?)_(?=\s|$|[.,;:!?])"#, | |
| 136 | protectedFragments: &protectedFragments | |
| 137 | ) { match, nsText in | |
| 138 | let value = nsText.substring(with: match.range(at: 1)) | |
| 139 | return "<u>\(value)</u>" | |
| 140 | } | |
| 141 | ||
| 142 | // Bold: *text* | |
| 143 | result = result.replacingOccurrences( | |
| 144 | of: #"(?<!\S)\*(.+?)\*(?=\s|$|[.,;:!?])"#, | |
| 145 | with: "<strong>$1</strong>", | |
| 146 | options: .regularExpression | |
| 147 | ) | |
| 148 | // Italic: /text/ | |
| 149 | result = result.replacingOccurrences( | |
| 150 | of: #"(?<!\S)/(.+?)/(?=\s|$|[.,;:!?])"#, | |
| 151 | with: "<em>$1</em>", | |
| 152 | options: .regularExpression | |
| 153 | ) | |
| 154 | result = replaceMatches( | |
| 155 | in: result, | |
| 156 | pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"# | |
| 157 | ) { match, nsText in | |
| 158 | guard !isInsideHTMLTag(nsText, range: match.range) else { | |
| 159 | return nsText.substring(with: match.range) | |
| 160 | } | |
| 161 | let email = nsText.substring(with: match.range(at: 1)) | |
| 162 | let href = escapeHTMLAttribute("mailto:\(email)") | |
| 163 | return #"<a href="\#(href)">\#(email)</a>"# | |
| 164 | } | |
| 165 | ||
| 166 | for (token, fragment) in protectedFragments { | |
| 167 | result = result.replacingOccurrences(of: token, with: fragment) | |
| 168 | } | |
| 169 | ||
| 170 | return result | |
| 171 | } | |
Sources/OrgSwift/Links.swift −210
| @@ -1,154 +1,11 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | func protectOrgLinks( | |
| 4 | in text: String, | |
| 5 | protectedFragments: inout [String: String], | |
| 6 | imageURLResolver: ((String) -> String?)? = nil, | |
| 7 | linkURLResolver: ((String) -> String?)? = nil | |
| 8 | ) -> String { | |
| 9 | var result = text | |
| 10 | ||
| 11 | while let range = result.range(of: "[[") { | |
| 12 | guard let parsed = parseOrgLink(in: result, from: range.lowerBound) else { | |
| 13 | break | |
| 14 | } | |
| 15 | let token = "ZZPROTECTED\(protectedFragments.count)ZZ" | |
| 16 | protectedFragments[token] = renderOrgLink( | |
| 17 | destination: parsed.destination, | |
| 18 | label: parsed.label, | |
| 19 | imageURLResolver: imageURLResolver, | |
| 20 | linkURLResolver: linkURLResolver | |
| 21 | ) | |
| 22 | result.replaceSubrange(parsed.range, with: token) | |
| 23 | } | |
| 24 | ||
| 25 | return result | |
| 26 | } | |
| 27 | ||
| 28 | private func parseOrgLink( | |
| 29 | in text: String, | |
| 30 | from start: String.Index | |
| 31 | ) -> (range: Range<String.Index>, destination: String, label: String?)? { | |
| 32 | guard text[start...].hasPrefix("[[") else { return nil } | |
| 33 | ||
| 34 | var index = text.index(start, offsetBy: 2) | |
| 35 | let descSeparator = text[index...].range(of: "][")?.lowerBound | |
| 36 | let plainClose = text[index...].range(of: "]]")?.lowerBound | |
| 37 | // A `][` only starts a description when it comes before this link's closing `]]`; | |
| 38 | // otherwise it belongs to a later link and this one has no description. | |
| 39 | guard let destinationEnd = descSeparator, | |
| 40 | plainClose == nil || destinationEnd < plainClose! else { | |
| 41 | guard let end = plainClose else { return nil } | |
| 42 | return (start..<text.index(end, offsetBy: 2), String(text[index..<end]), nil) | |
| 43 | } | |
| 44 | ||
| 45 | let destination = String(text[index..<destinationEnd]) | |
| 46 | index = text.index(destinationEnd, offsetBy: 2) | |
| 47 | let labelStart = index | |
| 48 | var depth = 0 | |
| 49 | ||
| 50 | while index < text.endIndex { | |
| 51 | if text[index...].hasPrefix("[[") { | |
| 52 | depth += 1 | |
| 53 | index = text.index(index, offsetBy: 2) | |
| 54 | continue | |
| 55 | } | |
| 56 | if text[index...].hasPrefix("]]") { | |
| 57 | if depth == 0 { | |
| 58 | let end = text.index(index, offsetBy: 2) | |
| 59 | return (start..<end, destination, String(text[labelStart..<index])) | |
| 60 | } | |
| 61 | depth -= 1 | |
| 62 | index = text.index(index, offsetBy: 2) | |
| 63 | continue | |
| 64 | } | |
| 65 | index = text.index(after: index) | |
| 66 | } | |
| 67 | ||
| 68 | return nil | |
| 69 | } | |
| 70 | ||
| 71 | 3 | /// Strip org's `file:` link prefix. `[[file:diagram.png]]` targets a local path the same |
| 72 | 4 | /// way `[[diagram.png]]` does; the scheme is org bookkeeping, not part of the URL. |
| 73 | 5 | func normalizeOrgLinkTarget(_ target: String) -> String { |
| 74 | 6 | target.hasPrefix("file:") ? String(target.dropFirst(5)) : target |
| 75 | 7 | } |
| 76 | 8 | |
| 77 | /// If a link *description* is itself an image reference — `[[img]]`, a `file:` image, or an | |
| 78 | /// image URL — return its source, so the image becomes the link's content. A bare relative | |
| 79 | /// string (`img.png`) is a plain text description, not an image. | |
| 80 | private func descriptionImageSource(_ label: String) -> String? { | |
| 81 | if label.hasPrefix("[["), label.hasSuffix("]]") { | |
| 82 | return String(label.dropFirst(2).dropLast(2)) | |
| 83 | } | |
| 84 | if label.hasPrefix("file:") || label.hasPrefix("http://") || label.hasPrefix("https://") { | |
| 85 | return label | |
| 86 | } | |
| 87 | return nil | |
| 88 | } | |
| 89 | ||
| 90 | private func renderOrgLink( | |
| 91 | destination rawDestination: String, | |
| 92 | label: String?, | |
| 93 | imageURLResolver: ((String) -> String?)? = nil, | |
| 94 | linkURLResolver: ((String) -> String?)? = nil | |
| 95 | ) -> String { | |
| 96 | // `id:` links target a heading by its :ID:/:CUSTOM_ID:; org exports them as an | |
| 97 | // in-page fragment link, with the id itself as the text when there is no description. | |
| 98 | if rawDestination.hasPrefix("id:") { | |
| 99 | let id = String(rawDestination.dropFirst(3)) | |
| 100 | let text = label.map { | |
| 101 | processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 102 | } ?? escapeHTML(id) | |
| 103 | return ##"<a href="#\##(escapeHTMLAttribute(id))">\##(text)</a>"## | |
| 104 | } | |
| 105 | let destination = normalizeOrgLinkTarget(rawDestination) | |
| 106 | // A description that is itself an image link (`[[url][file:badge.svg]]`, the common | |
| 107 | // build-badge form, or the double-bracketed `[[url][[badge.svg]]]`) makes the image the | |
| 108 | // clickable content of the link. A plain-text description like `img.png` stays text. | |
| 109 | if let label, let source = descriptionImageSource(label), | |
| 110 | let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) { | |
| 111 | let resolvedDestination = linkURLResolver?(destination) ?? destination | |
| 112 | guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else { | |
| 113 | return imageHTML | |
| 114 | } | |
| 115 | return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"# | |
| 116 | } | |
| 117 | ||
| 118 | // A bare `[[image]]` with no description is an inline image. `[[image][text]]` is a | |
| 119 | // link whose text happens to point at an image — org renders it as a link, not an | |
| 120 | // image, so only take the image path when there is no description. | |
| 121 | if label == nil, let imageHTML = makeOrgImageHTML( | |
| 122 | source: destination, | |
| 123 | alt: nil, | |
| 124 | imageURLResolver: imageURLResolver | |
| 125 | ) { | |
| 126 | return imageHTML | |
| 127 | } | |
| 128 | ||
| 129 | let resolvedDestination = linkURLResolver?(destination) ?? destination | |
| 130 | guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else { | |
| 131 | return label ?? destination | |
| 132 | } | |
| 133 | ||
| 134 | let renderedLabel = label.map { | |
| 135 | processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 136 | } ?? destination | |
| 137 | return #"<a href="\#(sanitizedURL)">\#(renderedLabel)</a>"# | |
| 138 | } | |
| 139 | ||
| 140 | func makeOrgImageHTML( | |
| 141 | source rawSource: String, | |
| 142 | alt: String?, | |
| 143 | imageURLResolver: ((String) -> String?)? | |
| 144 | ) -> String? { | |
| 145 | let source = normalizeOrgLinkTarget(rawSource) | |
| 146 | guard isRenderableImageSource(source) else { return nil } | |
| 147 | let resolvedSource = imageURLResolver?(source) ?? source | |
| 148 | guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil } | |
| 149 | let altText = escapeHTMLAttribute(alt ?? "") | |
| 150 | return #"<img src="\#(sanitizedSource)" alt="\#(altText)">"# | |
| 151 | } | |
| 152 | 9 | |
| 153 | 10 | private func isRenderableImageSource(_ source: String) -> Bool { |
| 154 | 11 | let lowercased = source.lowercased() |
| @@ -168,73 +25,6 @@ func standaloneOrgImage(in line: String) -> String? { | ||
| 168 | 25 | return path |
| 169 | 26 | } |
| 170 | 27 | |
| 171 | /// Build the `<img>` for a figure or bare image. `alt` comes from the caption (markup | |
| 172 | /// stripped) when present, else from an `:alt` in `#+ATTR_HTML`, else empty; the remaining | |
| 173 | /// `#+ATTR_HTML` pairs become attributes. | |
| 174 | func makeFigureImageHTML( | |
| 175 | path: String, | |
| 176 | caption: String?, | |
| 177 | attrHtml: String?, | |
| 178 | imageURLResolver: ((String) -> String?)? | |
| 179 | ) -> String { | |
| 180 | let resolved = imageURLResolver?(path) ?? path | |
| 181 | let src = sanitizedReadmeImageURLString(resolved) ?? escapeHTMLAttribute(path) | |
| 182 | let attrs = attrHtml.map(parseAttrHtml) ?? [] | |
| 183 | ||
| 184 | let alt: String | |
| 185 | if let caption { | |
| 186 | alt = stripOrgEmphasis(caption) | |
| 187 | } else if let attrAlt = attrs.first(where: { $0.key == "alt" })?.value { | |
| 188 | alt = attrAlt | |
| 189 | } else { | |
| 190 | alt = "" | |
| 191 | } | |
| 192 | ||
| 193 | var html = #"<img src="\#(src)" alt="\#(escapeHTMLAttribute(alt))""# | |
| 194 | for (key, value) in attrs where key != "alt" { | |
| 195 | html += " \(escapeHTMLAttribute(key))=\"\(escapeHTMLAttribute(value))\"" | |
| 196 | } | |
| 197 | html += ">" | |
| 198 | return html | |
| 199 | } | |
| 200 | ||
| 201 | /// Parse `#+ATTR_HTML` `:key value` pairs, honoring quoted values (`:alt "a cat, sitting"`). | |
| 202 | private func parseAttrHtml(_ value: String) -> [(key: String, value: String)] { | |
| 203 | guard let regex = try? NSRegularExpression(pattern: #":([A-Za-z_][A-Za-z0-9_-]*)\s+("[^"]*"|\S+)"#) else { | |
| 204 | return [] | |
| 205 | } | |
| 206 | let ns = value as NSString | |
| 207 | var result: [(String, String)] = [] | |
| 208 | for m in regex.matches(in: value, range: NSRange(location: 0, length: ns.length)) { | |
| 209 | let key = ns.substring(with: m.range(at: 1)).lowercased() | |
| 210 | var v = ns.substring(with: m.range(at: 2)) | |
| 211 | if v.count >= 2, v.hasPrefix("\""), v.hasSuffix("\"") { | |
| 212 | v = String(v.dropFirst().dropLast()) | |
| 213 | } | |
| 214 | result.append((key, v)) | |
| 215 | } | |
| 216 | return result | |
| 217 | } | |
| 218 | ||
| 219 | /// Strip paired org emphasis markers for plain-text uses like an image `alt`. | |
| 220 | private func stripOrgEmphasis(_ s: String) -> String { | |
| 221 | guard let regex = try? NSRegularExpression(pattern: #"(?<!\S)([/*_+=~])(.+?)\1(?=\s|$|[.,;:!?])"#) else { | |
| 222 | return s | |
| 223 | } | |
| 224 | var result = s | |
| 225 | for _ in 0..<3 { | |
| 226 | let ns = result as NSString | |
| 227 | let matches = regex.matches(in: result, range: NSRange(location: 0, length: ns.length)) | |
| 228 | if matches.isEmpty { break } | |
| 229 | for m in matches.reversed() { | |
| 230 | let inner = ns.substring(with: m.range(at: 2)) | |
| 231 | result = (result as NSString).replacingCharacters(in: m.range, with: inner) | |
| 232 | } | |
| 233 | } | |
| 234 | return result | |
| 235 | } | |
| 236 | ||
| 237 | // MARK: - Relative link/image resolution | |
| 238 | 28 | |
| 239 | 29 | func resolveRepositoryLinkURL( |
| 240 | 30 | _ source: String, |
Sources/OrgSwift/Lists.swift +1 −172
| @@ -1,188 +1,17 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | enum OrgListType: Equatable { | |
| 4 | case unordered | |
| 5 | case ordered | |
| 6 | } | |
| 7 | ||
| 8 | 3 | func orderedListItem(in line: String) -> String? { |
| 9 | 4 | guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil } |
| 10 | 5 | return String(match.2) |
| 11 | 6 | } |
| 12 | 7 | |
| 13 | /// Render a whole list block (all its lines, at any nesting depth) to HTML. The block is a | |
| 14 | /// run of list lines the caller has collected — top-level item markers plus every deeper or | |
| 15 | /// continuation line, including the blank lines between an item's paragraphs. | |
| 16 | func renderOrgList(_ lines: [String], inlineRenderer: (String) -> String) -> String { | |
| 17 | // Outdent the block so this level's markers sit at column 0. | |
| 18 | let base = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } | |
| 19 | .map(leadingSpaces).min() ?? 0 | |
| 20 | let norm = lines.map { dropLeadingSpaces($0, base) } | |
| 21 | ||
| 22 | // Group into items: each begins at a column-0 marker; deeper/continuation/blank lines | |
| 23 | // belong to the item above them. | |
| 24 | var items: [[String]] = [] | |
| 25 | var current: [String] = [] | |
| 26 | for line in norm { | |
| 27 | if isListMarkerLine(line) { | |
| 28 | if !current.isEmpty { items.append(current) } | |
| 29 | current = [line] | |
| 30 | } else if !current.isEmpty { | |
| 31 | current.append(line) | |
| 32 | } | |
| 33 | } | |
| 34 | if !current.isEmpty { items.append(current) } | |
| 35 | guard let firstMarker = items.first?.first else { return "" } | |
| 36 | ||
| 37 | if let dl = renderDescriptionList(items, inlineRenderer: inlineRenderer) { return dl } | |
| 38 | ||
| 39 | let ordered = orderedListItem(in: firstMarker) != nil | |
| 40 | var html = ordered ? "<ol>\n" : "<ul>\n" | |
| 41 | for item in items { | |
| 42 | html += "<li>" + renderListItem(item, inlineRenderer: inlineRenderer) + "</li>\n" | |
| 43 | } | |
| 44 | html += ordered ? "</ol>\n" : "</ul>\n" | |
| 45 | return html | |
| 46 | } | |
| 47 | ||
| 48 | /// A single item's lines (the marker line plus everything under it) → the `<li>` body: | |
| 49 | /// the item's paragraph(s), then any nested list. Text is wrapped in `<p>` only when the | |
| 50 | /// item has more than one paragraph, matching org's exporter. | |
| 51 | private func renderListItem(_ lines: [String], inlineRenderer: (String) -> String) -> String { | |
| 52 | let head = stripListMarker(lines[0]) | |
| 53 | let rest = lines.dropFirst() | |
| 54 | let childIndent = rest.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } | |
| 55 | .map(leadingSpaces).min() ?? 0 | |
| 56 | let outdented = rest.map { dropLeadingSpaces($0, childIndent) } | |
| 57 | ||
| 58 | var paragraphs: [String] = [] | |
| 59 | var currentParagraph: [String] = [head] | |
| 60 | var sublistLines: [String] = [] | |
| 61 | var inSublist = false | |
| 62 | ||
| 63 | func flushParagraph() { | |
| 64 | let joined = currentParagraph.joined(separator: " ").trimmingCharacters(in: .whitespaces) | |
| 65 | if !joined.isEmpty { paragraphs.append(joined) } | |
| 66 | currentParagraph = [] | |
| 67 | } | |
| 68 | ||
| 69 | for line in outdented { | |
| 70 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 71 | if isListMarkerLine(line) || inSublist { | |
| 72 | // The first marker begins a nested list; everything after belongs to it. | |
| 73 | if !inSublist { flushParagraph() } | |
| 74 | inSublist = true | |
| 75 | sublistLines.append(line) | |
| 76 | } else if trimmed.isEmpty { | |
| 77 | flushParagraph() | |
| 78 | } else { | |
| 79 | currentParagraph.append(trimmed) | |
| 80 | } | |
| 81 | } | |
| 82 | flushParagraph() | |
| 83 | ||
| 84 | var body: String | |
| 85 | if paragraphs.count <= 1 { | |
| 86 | body = renderTaskListItem(paragraphs.first ?? "", inlineRenderer: inlineRenderer) | |
| 87 | } else { | |
| 88 | body = paragraphs.enumerated().map { index, para in | |
| 89 | let rendered = index == 0 ? renderTaskListItem(para, inlineRenderer: inlineRenderer) : inlineRenderer(para) | |
| 90 | return "<p>" + rendered + "</p>" | |
| 91 | }.joined(separator: "\n") | |
| 92 | } | |
| 93 | ||
| 94 | if !sublistLines.isEmpty { | |
| 95 | body += "\n" + renderOrgList(sublistLines, inlineRenderer: inlineRenderer) | |
| 96 | } | |
| 97 | return body | |
| 98 | } | |
| 99 | ||
| 100 | /// If the items are a description list (`term :: definition`), render `<dl>`; otherwise nil. | |
| 101 | private func renderDescriptionList(_ items: [[String]], inlineRenderer: (String) -> String) -> String? { | |
| 102 | guard let first = items.first, stripListMarker(first[0]).contains(" :: ") else { return nil } | |
| 103 | var html = "<dl>\n" | |
| 104 | for item in items { | |
| 105 | let head = stripListMarker(item[0]) | |
| 106 | let continuation = item.dropFirst() | |
| 107 | .map { $0.trimmingCharacters(in: .whitespaces) } | |
| 108 | .filter { !$0.isEmpty } | |
| 109 | let full = ([head] + continuation).joined(separator: " ") | |
| 110 | if let range = full.range(of: " :: ") { | |
| 111 | html += "<dt>" + inlineRenderer(String(full[..<range.lowerBound])) + "</dt>\n" | |
| 112 | html += "<dd>" + inlineRenderer(String(full[range.upperBound...])) + "</dd>\n" | |
| 113 | } else { | |
| 114 | html += "<dt>" + inlineRenderer(full) + "</dt>\n" | |
| 115 | } | |
| 116 | } | |
| 117 | html += "</dl>\n" | |
| 118 | return html | |
| 119 | } | |
| 120 | ||
| 121 | 8 | /// True if a line (already outdented to its level) starts a list item at column 0. |
| 122 | 9 | func isListMarkerLine(_ line: String) -> Bool { |
| 123 | 10 | line.hasPrefix("- ") || line.hasPrefix("+ ") || orderedListItem(in: line) != nil |
| 124 | 11 | } |
| 125 | 12 | |
| 126 | private func stripListMarker(_ line: String) -> String { | |
| 127 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 128 | if trimmed.hasPrefix("- ") || trimmed.hasPrefix("+ ") { | |
| 129 | return String(trimmed.dropFirst(2)) | |
| 130 | } | |
| 131 | if let match = trimmed.firstMatch(of: /^\d+[.)]\s+(.*)$/) { | |
| 132 | return String(match.1) | |
| 133 | } | |
| 134 | return trimmed | |
| 135 | } | |
| 136 | ||
| 137 | 13 | func isIndentedContinuationLine(_ line: String) -> Bool { |
| 138 | 14 | guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false } |
| 139 | 15 | guard let first = line.first else { return false } |
| 140 | 16 | return first == " " || first == "\t" |
| 141 | } | |
| 142 | ||
| 143 | private func leadingSpaces(_ line: String) -> Int { | |
| 144 | var count = 0 | |
| 145 | for ch in line { | |
| 146 | if ch == " " { count += 1 } | |
| 147 | else if ch == "\t" { count += 8 } | |
| 148 | else { break } | |
| 149 | } | |
| 150 | return count | |
| 151 | } | |
| 152 | ||
| 153 | private func dropLeadingSpaces(_ line: String, _ n: Int) -> String { | |
| 154 | var dropped = 0 | |
| 155 | var index = line.startIndex | |
| 156 | while index < line.endIndex, dropped < n { | |
| 157 | if line[index] == " " { dropped += 1 } | |
| 158 | else if line[index] == "\t" { dropped += 8 } | |
| 159 | else { break } | |
| 160 | index = line.index(after: index) | |
| 161 | } | |
| 162 | return String(line[index...]) | |
| 163 | } | |
| 164 | ||
| 165 | func renderTaskListItem( | |
| 166 | _ text: String, | |
| 167 | inlineRenderer: (String) -> String | |
| 168 | ) -> String { | |
| 169 | let trimmed = text.trimmingCharacters(in: .whitespaces) | |
| 170 | guard trimmed.count >= 4 else { | |
| 171 | return inlineRenderer(text) | |
| 172 | } | |
| 173 | ||
| 174 | let prefix = String(trimmed.prefix(4)) | |
| 175 | let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces) | |
| 176 | ||
| 177 | // Rendered as org's HTML exporter does: the bracket state in a <code>, not an <input>. | |
| 178 | switch prefix { | |
| 179 | case "[ ] ": | |
| 180 | return #"<code>[ ]</code> \#(inlineRenderer(remainder))"# | |
| 181 | case "[x] ", "[X] ": | |
| 182 | return #"<code>[X]</code> \#(inlineRenderer(remainder))"# | |
| 183 | case "[-] ": | |
| 184 | return #"<code>[-]</code> \#(inlineRenderer(remainder))"# | |
| 185 | default: | |
| 186 | return inlineRenderer(text) | |
| 187 | } | |
| 188 | } | |
| 17 | } | |
| \ No newline at end of file | ||
Sources/OrgSwift/OrgRenderer.swift +2 −552
| @@ -115,14 +115,8 @@ public enum OrgRenderer { | ||
| 115 | 115 | options: OrgRenderOptions = .init(), |
| 116 | 116 | highlighter: CodeHighlighter = PlainCodeHighlighter() |
| 117 | 117 | ) -> String { |
| 118 | orgToHTML( | |
| 119 | source, | |
| 120 | highlighter: highlighter, | |
| 121 | imageURLResolver: options.imageURLResolver(), | |
| 122 | linkURLResolver: options.linkURLResolver(), | |
| 123 | metadataHeader: options.metadataHeader, | |
| 124 | headingLevelOffset: options.headingLevelOffset | |
| 125 | ) | |
| 118 | OrgHTMLTreeRenderer(options: options, highlighter: highlighter) | |
| 119 | .render(OrgParser.parse(source)) | |
| 126 | 120 | } |
| 127 | 121 | } |
| 128 | 122 | |
| @@ -138,547 +132,3 @@ func splitHeadingTags(_ heading: String) -> (title: String, tags: [String]) { | ||
| 138 | 132 | let tags = String(match.2).split(separator: ":").map(String.init).filter { !$0.isEmpty } |
| 139 | 133 | return (String(match.1).trimmingCharacters(in: .whitespaces), tags) |
| 140 | 134 | } |
| 141 | ||
| 142 | func orgToHTML( | |
| 143 | _ text: String, | |
| 144 | highlighter: CodeHighlighter, | |
| 145 | imageURLResolver: ((String) -> String?)? = nil, | |
| 146 | linkURLResolver: ((String) -> String?)? = nil, | |
| 147 | metadataHeader: Bool = true, | |
| 148 | headingLevelOffset: Int = 0 | |
| 149 | ) -> String { | |
| 150 | let normalizedText = text | |
| 151 | .replacingOccurrences(of: "\r\n", with: "\n") | |
| 152 | .replacingOccurrences(of: "\r", with: "\n") | |
| 153 | let rawLines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) | |
| 154 | var title: String? | |
| 155 | var author: String? | |
| 156 | var date: String? | |
| 157 | let lines = rawLines.filter { line in | |
| 158 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 159 | guard let directive = orgKeywordDirective(in: trimmed) else { | |
| 160 | return true | |
| 161 | } | |
| 162 | switch directive.keyword { | |
| 163 | case "title": | |
| 164 | title = directive.value | |
| 165 | return false | |
| 166 | case "author": | |
| 167 | author = directive.value | |
| 168 | return false | |
| 169 | case "date": | |
| 170 | date = directive.value | |
| 171 | return false | |
| 172 | default: | |
| 173 | return true | |
| 174 | } | |
| 175 | } | |
| 176 | var html = "" | |
| 177 | var listType: OrgListType? | |
| 178 | var inQuoteBlock = false | |
| 179 | var inPropertyDrawer = false | |
| 180 | var srcLanguage: String? | |
| 181 | var srcLines: [String] = [] | |
| 182 | var inExampleBlock = false | |
| 183 | var inCenterBlock = false | |
| 184 | var inVerseBlock = false | |
| 185 | var inExportBlock = false | |
| 186 | var exportIsHTML = false | |
| 187 | var exportLines: [String] = [] | |
| 188 | var inSpecialBlock = false | |
| 189 | var specialBlockName = "" | |
| 190 | var listBuffer: [String] = [] | |
| 191 | var pendingListBlanks: [String] = [] | |
| 192 | var paragraph: [String] = [] | |
| 193 | var tableRows: [[String]] = [] | |
| 194 | var propertyRows: [(String, String)] = [] | |
| 195 | var verseLines: [String] = [] | |
| 196 | var pendingBlockName: String? | |
| 197 | var pendingBlockCaption: String? | |
| 198 | var pendingAttrHtml: String? | |
| 199 | var activeBlockCaption: String? | |
| 200 | var isWrappingBlockFigure = false | |
| 201 | var figureNumber = 0 | |
| 202 | let footnotes = FootnoteCollector() | |
| 203 | ||
| 204 | func beginPendingBlockWrapperIfNeeded() { | |
| 205 | guard pendingBlockName != nil || pendingBlockCaption != nil else { return } | |
| 206 | let idAttribute = pendingBlockName.map { #" id="\#(escapeHTMLAttribute($0))""# } ?? "" | |
| 207 | html += #"<figure class="org-block"\#(idAttribute)>"# + "\n" | |
| 208 | activeBlockCaption = pendingBlockCaption | |
| 209 | isWrappingBlockFigure = true | |
| 210 | pendingBlockName = nil | |
| 211 | pendingBlockCaption = nil | |
| 212 | } | |
| 213 | ||
| 214 | func closePendingBlockWrapper() { | |
| 215 | guard isWrappingBlockFigure else { return } | |
| 216 | if let activeBlockCaption { | |
| 217 | html += "<figcaption>" + processOrgInline(activeBlockCaption, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</figcaption>\n" | |
| 218 | } | |
| 219 | html += "</figure>\n" | |
| 220 | activeBlockCaption = nil | |
| 221 | isWrappingBlockFigure = false | |
| 222 | } | |
| 223 | ||
| 224 | func flushParagraph() { | |
| 225 | if !paragraph.isEmpty { | |
| 226 | let normalizedParagraph = paragraph | |
| 227 | .map { $0.trimmingCharacters(in: .whitespaces) } | |
| 228 | .joined(separator: " ") | |
| 229 | html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver, footnotes: footnotes) + "</p>\n" | |
| 230 | paragraph = [] | |
| 231 | } | |
| 232 | } | |
| 233 | ||
| 234 | func closeList() { | |
| 235 | if !listBuffer.isEmpty { | |
| 236 | html += renderOrgList(listBuffer) { | |
| 237 | processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 238 | } | |
| 239 | } | |
| 240 | listBuffer = [] | |
| 241 | pendingListBlanks = [] | |
| 242 | listType = nil | |
| 243 | } | |
| 244 | ||
| 245 | func flushTable() { | |
| 246 | guard !tableRows.isEmpty else { return } | |
| 247 | beginPendingBlockWrapperIfNeeded() | |
| 248 | html += renderHTMLTable( | |
| 249 | rows: tableRows, | |
| 250 | inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } | |
| 251 | ) | |
| 252 | closePendingBlockWrapper() | |
| 253 | tableRows = [] | |
| 254 | } | |
| 255 | ||
| 256 | func flushPropertyDrawer() { | |
| 257 | // Property drawers are heading metadata, not body content. org's HTML exporter | |
| 258 | // drops them (CUSTOM_ID becomes the heading's anchor); we drop them too rather | |
| 259 | // than render a stray <dl>. The lines were still consumed above, so they never | |
| 260 | // fall through to become a paragraph. | |
| 261 | propertyRows = [] | |
| 262 | } | |
| 263 | ||
| 264 | func closeQuoteBlock() { | |
| 265 | if inQuoteBlock { | |
| 266 | flushParagraph() | |
| 267 | html += "</blockquote>\n" | |
| 268 | inQuoteBlock = false | |
| 269 | } | |
| 270 | } | |
| 271 | ||
| 272 | func closeSourceBlock() { | |
| 273 | if let language = srcLanguage { | |
| 274 | let code = srcLines.joined(separator: "\n") | |
| 275 | if !code.isEmpty { | |
| 276 | let highlighted = highlighter.highlightedHTML(code: code, language: language.isEmpty ? nil : language) | |
| 277 | html += (highlighted ?? escapeHTML(code)) + "\n" | |
| 278 | } | |
| 279 | html += "</code></pre>\n" | |
| 280 | srcLanguage = nil | |
| 281 | srcLines = [] | |
| 282 | closePendingBlockWrapper() | |
| 283 | } | |
| 284 | } | |
| 285 | ||
| 286 | func closeExampleBlock() { | |
| 287 | if inExampleBlock { | |
| 288 | html += "</pre>\n" | |
| 289 | inExampleBlock = false | |
| 290 | closePendingBlockWrapper() | |
| 291 | } | |
| 292 | } | |
| 293 | ||
| 294 | func closeCenterBlock() { | |
| 295 | if inCenterBlock { | |
| 296 | flushParagraph() | |
| 297 | html += "</div>\n" | |
| 298 | inCenterBlock = false | |
| 299 | closePendingBlockWrapper() | |
| 300 | } | |
| 301 | } | |
| 302 | ||
| 303 | func closeVerseBlock() { | |
| 304 | if inVerseBlock { | |
| 305 | let content = verseLines | |
| 306 | .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } | |
| 307 | .joined(separator: "<br>\n") | |
| 308 | html += #"<p class="verse">"# + "\n" | |
| 309 | html += content + "\n" | |
| 310 | html += "</p>\n" | |
| 311 | verseLines = [] | |
| 312 | inVerseBlock = false | |
| 313 | closePendingBlockWrapper() | |
| 314 | } | |
| 315 | } | |
| 316 | ||
| 317 | func flushBlockState() { | |
| 318 | flushParagraph() | |
| 319 | closeList() | |
| 320 | flushTable() | |
| 321 | flushPropertyDrawer() | |
| 322 | } | |
| 323 | ||
| 324 | if metadataHeader, title != nil || author != nil || date != nil { | |
| 325 | html += "<div class=\"org-metadata\">\n" | |
| 326 | if let title { | |
| 327 | html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n" | |
| 328 | } | |
| 329 | if let author { | |
| 330 | html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n" | |
| 331 | } | |
| 332 | if let date { | |
| 333 | html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n" | |
| 334 | } | |
| 335 | html += "</div>\n" | |
| 336 | } | |
| 337 | ||
| 338 | for line in lines { | |
| 339 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 340 | ||
| 341 | if srcLanguage != nil { | |
| 342 | if trimmed.lowercased() == "#+end_src" { | |
| 343 | closeSourceBlock() | |
| 344 | } else { | |
| 345 | srcLines.append(line) | |
| 346 | } | |
| 347 | continue | |
| 348 | } | |
| 349 | ||
| 350 | if inExampleBlock { | |
| 351 | if trimmed.lowercased() == "#+end_example" { | |
| 352 | closeExampleBlock() | |
| 353 | } else { | |
| 354 | html += escapeHTML(line) + "\n" | |
| 355 | } | |
| 356 | continue | |
| 357 | } | |
| 358 | ||
| 359 | if inVerseBlock { | |
| 360 | if trimmed.lowercased() == "#+end_verse" { | |
| 361 | closeVerseBlock() | |
| 362 | } else { | |
| 363 | verseLines.append(line) | |
| 364 | } | |
| 365 | continue | |
| 366 | } | |
| 367 | ||
| 368 | if inExportBlock { | |
| 369 | if trimmed.lowercased() == "#+end_export" { | |
| 370 | if exportIsHTML { | |
| 371 | html += exportLines.joined(separator: "\n") + "\n" | |
| 372 | } | |
| 373 | inExportBlock = false | |
| 374 | exportLines = [] | |
| 375 | } else if exportIsHTML { | |
| 376 | // The `html` backend passes through verbatim; any other backend is dropped. | |
| 377 | exportLines.append(line) | |
| 378 | } | |
| 379 | continue | |
| 380 | } | |
| 381 | ||
| 382 | if inSpecialBlock { | |
| 383 | if trimmed.lowercased() == "#+end_\(specialBlockName)" { | |
| 384 | flushParagraph() | |
| 385 | html += "</div>\n" | |
| 386 | inSpecialBlock = false | |
| 387 | specialBlockName = "" | |
| 388 | } else if trimmed.isEmpty { | |
| 389 | flushParagraph() | |
| 390 | } else { | |
| 391 | paragraph.append(line) | |
| 392 | } | |
| 393 | continue | |
| 394 | } | |
| 395 | ||
| 396 | if inQuoteBlock, trimmed.lowercased() == "#+end_quote" { | |
| 397 | closeQuoteBlock() | |
| 398 | continue | |
| 399 | } | |
| 400 | ||
| 401 | if inCenterBlock { | |
| 402 | if trimmed.lowercased() == "#+end_center" { | |
| 403 | closeCenterBlock() | |
| 404 | } else if trimmed.isEmpty { | |
| 405 | flushParagraph() | |
| 406 | } else { | |
| 407 | paragraph.append(line) | |
| 408 | } | |
| 409 | continue | |
| 410 | } | |
| 411 | ||
| 412 | if trimmed == "#" || trimmed.hasPrefix("# ") { | |
| 413 | continue | |
| 414 | } | |
| 415 | ||
| 416 | if let directive = orgKeywordDirective(in: trimmed) { | |
| 417 | switch directive.keyword { | |
| 418 | case "caption": | |
| 419 | pendingBlockCaption = directive.value | |
| 420 | continue | |
| 421 | case "name": | |
| 422 | pendingBlockName = directive.value | |
| 423 | continue | |
| 424 | case "attr_html": | |
| 425 | pendingAttrHtml = directive.value | |
| 426 | continue | |
| 427 | default: | |
| 428 | // Any other `#+keyword:` line (OPTIONS, PROPERTY, FILETAGS, TBLFM, RESULTS, | |
| 429 | // …) is document metadata, not body content — org's exporter consumes it. | |
| 430 | // `#+begin_…`/`#+end_…` have no colon, so they are not matched here. | |
| 431 | continue | |
| 432 | } | |
| 433 | } | |
| 434 | ||
| 435 | // A standalone image link on its own line. An affiliated #+CAPTION or #+ATTR_HTML | |
| 436 | // promotes it to a <figure>; otherwise it is a plain <p><img>. A link *with* a | |
| 437 | // description ([[file:x][label]]) is an ordinary link and falls through to the | |
| 438 | // paragraph path instead. | |
| 439 | if let imagePath = standaloneOrgImage(in: trimmed) { | |
| 440 | closeQuoteBlock() | |
| 441 | flushBlockState() | |
| 442 | let img = makeFigureImageHTML( | |
| 443 | path: imagePath, | |
| 444 | caption: pendingBlockCaption, | |
| 445 | attrHtml: pendingAttrHtml, | |
| 446 | imageURLResolver: imageURLResolver | |
| 447 | ) | |
| 448 | if pendingBlockCaption != nil || pendingAttrHtml != nil { | |
| 449 | html += "<figure>" + img | |
| 450 | if let caption = pendingBlockCaption { | |
| 451 | figureNumber += 1 | |
| 452 | html += #"<figcaption><span class="figure-number">Figure \#(figureNumber): </span>"# | |
| 453 | + processOrgInline(caption, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 454 | + "</figcaption>" | |
| 455 | } | |
| 456 | html += "</figure>\n" | |
| 457 | } else { | |
| 458 | html += "<p>" + img + "</p>\n" | |
| 459 | } | |
| 460 | pendingBlockCaption = nil | |
| 461 | pendingBlockName = nil | |
| 462 | pendingAttrHtml = nil | |
| 463 | continue | |
| 464 | } | |
| 465 | ||
| 466 | if trimmed.lowercased().hasPrefix("#+begin_src") { | |
| 467 | // No closeQuoteBlock(): a source block can sit inside a quote, and org keeps it | |
| 468 | // there. flushBlockState() still ends any open paragraph, list, or table. | |
| 469 | flushBlockState() | |
| 470 | beginPendingBlockWrapperIfNeeded() | |
| 471 | let language = trimmed | |
| 472 | .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) | |
| 473 | .dropFirst() | |
| 474 | .first | |
| 475 | .map(String.init)? | |
| 476 | .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 477 | let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? "" | |
| 478 | html += "<pre><code\(classAttribute)>" | |
| 479 | srcLanguage = language ?? "" | |
| 480 | srcLines = [] | |
| 481 | continue | |
| 482 | } | |
| 483 | ||
| 484 | if trimmed.lowercased() == "#+begin_example" { | |
| 485 | closeQuoteBlock() | |
| 486 | flushBlockState() | |
| 487 | beginPendingBlockWrapperIfNeeded() | |
| 488 | html += "<pre>" | |
| 489 | inExampleBlock = true | |
| 490 | continue | |
| 491 | } | |
| 492 | ||
| 493 | if trimmed.lowercased() == "#+begin_quote" { | |
| 494 | flushBlockState() | |
| 495 | beginPendingBlockWrapperIfNeeded() | |
| 496 | html += "<blockquote>\n" | |
| 497 | inQuoteBlock = true | |
| 498 | continue | |
| 499 | } | |
| 500 | ||
| 501 | if trimmed.lowercased() == "#+begin_center" { | |
| 502 | closeQuoteBlock() | |
| 503 | flushBlockState() | |
| 504 | beginPendingBlockWrapperIfNeeded() | |
| 505 | html += "<div style=\"text-align:center\">\n" | |
| 506 | inCenterBlock = true | |
| 507 | continue | |
| 508 | } | |
| 509 | ||
| 510 | if trimmed.lowercased() == "#+begin_verse" { | |
| 511 | closeQuoteBlock() | |
| 512 | flushBlockState() | |
| 513 | beginPendingBlockWrapperIfNeeded() | |
| 514 | verseLines = [] | |
| 515 | inVerseBlock = true | |
| 516 | continue | |
| 517 | } | |
| 518 | ||
| 519 | if trimmed.lowercased().hasPrefix("#+begin_export") { | |
| 520 | closeQuoteBlock() | |
| 521 | flushBlockState() | |
| 522 | let backend = trimmed | |
| 523 | .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) | |
| 524 | .dropFirst().first.map(String.init)? | |
| 525 | .trimmingCharacters(in: .whitespaces).lowercased() | |
| 526 | inExportBlock = true | |
| 527 | exportIsHTML = backend == "html" | |
| 528 | exportLines = [] | |
| 529 | continue | |
| 530 | } | |
| 531 | ||
| 532 | // Any other `#+begin_<name>` is a special block: org renders it as a `<div>` carrying | |
| 533 | // the name as a class, with the contents parsed as ordinary org. Must stay last so | |
| 534 | // the specific block types above win. | |
| 535 | if trimmed.lowercased().hasPrefix("#+begin_") { | |
| 536 | closeQuoteBlock() | |
| 537 | flushBlockState() | |
| 538 | let name = String(trimmed.lowercased().dropFirst("#+begin_".count)) | |
| 539 | .split(separator: " ").first.map(String.init) ?? "" | |
| 540 | if !name.isEmpty { | |
| 541 | html += #"<div class="\#(escapeHTMLAttribute(name))">"# + "\n" | |
| 542 | inSpecialBlock = true | |
| 543 | specialBlockName = name | |
| 544 | continue | |
| 545 | } | |
| 546 | } | |
| 547 | ||
| 548 | if trimmed == ":PROPERTIES:" { | |
| 549 | closeQuoteBlock() | |
| 550 | flushBlockState() | |
| 551 | inPropertyDrawer = true | |
| 552 | continue | |
| 553 | } | |
| 554 | ||
| 555 | if trimmed == ":END:", inPropertyDrawer { | |
| 556 | flushPropertyDrawer() | |
| 557 | inPropertyDrawer = false | |
| 558 | continue | |
| 559 | } | |
| 560 | ||
| 561 | if inPropertyDrawer, | |
| 562 | trimmed.hasPrefix(":"), | |
| 563 | let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") { | |
| 564 | let keyStart = trimmed.index(after: trimmed.startIndex) | |
| 565 | let key = String(trimmed[keyStart..<secondColonIndex]).trimmingCharacters(in: .whitespaces) | |
| 566 | let valueStart = trimmed.index(after: secondColonIndex) | |
| 567 | let value = String(trimmed[valueStart...]).trimmingCharacters(in: .whitespaces) | |
| 568 | if !key.isEmpty { | |
| 569 | propertyRows.append((key, value)) | |
| 570 | continue | |
| 571 | } | |
| 572 | } | |
| 573 | ||
| 574 | if isTableLine(trimmed) { | |
| 575 | closeQuoteBlock() | |
| 576 | flushParagraph() | |
| 577 | closeList() | |
| 578 | tableRows.append(parseTableRow(trimmed)) | |
| 579 | continue | |
| 580 | } else { | |
| 581 | flushTable() | |
| 582 | } | |
| 583 | ||
| 584 | if isOrgHorizontalRule(trimmed) { | |
| 585 | closeQuoteBlock() | |
| 586 | flushBlockState() | |
| 587 | html += "<hr>\n" | |
| 588 | continue | |
| 589 | } | |
| 590 | ||
| 591 | // Reference-style footnote definition: [fn:label] text. Collected out of the body | |
| 592 | // flow and emitted in the footnotes section at the end. | |
| 593 | if let definition = orgFootnoteDefinition(in: trimmed) { | |
| 594 | closeQuoteBlock() | |
| 595 | flushBlockState() | |
| 596 | footnotes.define(label: definition.label, text: definition.text) | |
| 597 | continue | |
| 598 | } | |
| 599 | ||
| 600 | // Org headings: * heading, ** heading, *** heading | |
| 601 | if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) { | |
| 602 | closeQuoteBlock() | |
| 603 | flushBlockState() | |
| 604 | let level = min(6, max(1, match.1.count + headingLevelOffset)) | |
| 605 | let (titleText, tags) = splitHeadingTags(String(match.2)) | |
| 606 | var content = processOrgInline(titleText, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver, footnotes: footnotes) | |
| 607 | if !tags.isEmpty { | |
| 608 | content += " " + tags.map { #"<span class="tag">\#(escapeHTML($0))</span>"# }.joined(separator: " ") | |
| 609 | } | |
| 610 | html += "<h\(level)>" + content + "</h\(level)>\n" | |
| 611 | continue | |
| 612 | } | |
| 613 | ||
| 614 | // Inside a list, hold blank lines instead of ending it: an item can have several | |
| 615 | // paragraphs, separated by blanks, before the next item or the list's end. | |
| 616 | if listType != nil, trimmed.isEmpty { | |
| 617 | pendingListBlanks.append(line) | |
| 618 | continue | |
| 619 | } | |
| 620 | ||
| 621 | // A list item marker at column 0 starts or continues a list. A different marker | |
| 622 | // type (ordered vs unordered) at the top level begins a separate list. | |
| 623 | if !isIndentedContinuationLine(line), isListMarkerLine(trimmed) { | |
| 624 | let newType: OrgListType = orderedListItem(in: trimmed) != nil ? .ordered : .unordered | |
| 625 | if listType == nil { | |
| 626 | flushParagraph() | |
| 627 | flushPropertyDrawer() | |
| 628 | } else if listType != newType { | |
| 629 | closeList() | |
| 630 | flushParagraph() | |
| 631 | flushPropertyDrawer() | |
| 632 | } | |
| 633 | listType = newType | |
| 634 | listBuffer.append(contentsOf: pendingListBlanks) | |
| 635 | pendingListBlanks = [] | |
| 636 | listBuffer.append(line) | |
| 637 | continue | |
| 638 | } | |
| 639 | ||
| 640 | // A line indented under an open list is a continuation or a nested item. | |
| 641 | if listType != nil, isIndentedContinuationLine(line) { | |
| 642 | listBuffer.append(contentsOf: pendingListBlanks) | |
| 643 | pendingListBlanks = [] | |
| 644 | listBuffer.append(line) | |
| 645 | continue | |
| 646 | } | |
| 647 | ||
| 648 | // Any other line ends an open list, then is processed normally below. | |
| 649 | if listType != nil { | |
| 650 | closeList() | |
| 651 | } | |
| 652 | ||
| 653 | // Blank line | |
| 654 | if trimmed.isEmpty { | |
| 655 | if inQuoteBlock { | |
| 656 | flushParagraph() | |
| 657 | } else { | |
| 658 | flushBlockState() | |
| 659 | } | |
| 660 | continue | |
| 661 | } | |
| 662 | ||
| 663 | // Regular text | |
| 664 | if pendingBlockName != nil || pendingBlockCaption != nil || pendingAttrHtml != nil { | |
| 665 | pendingBlockName = nil | |
| 666 | pendingBlockCaption = nil | |
| 667 | pendingAttrHtml = nil | |
| 668 | } | |
| 669 | paragraph.append(line) | |
| 670 | } | |
| 671 | ||
| 672 | closeSourceBlock() | |
| 673 | closeExampleBlock() | |
| 674 | closeCenterBlock() | |
| 675 | closeVerseBlock() | |
| 676 | closeQuoteBlock() | |
| 677 | flushBlockState() | |
| 678 | ||
| 679 | html += footnotes.renderSection { text in | |
| 680 | processOrgInline(text, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 681 | } | |
| 682 | ||
| 683 | return html | |
| 684 | } | |
Sources/OrgSwift/Tables.swift +3 −51
| @@ -12,7 +12,7 @@ func parseTableRow(_ line: String) -> [String] { | ||
| 12 | 12 | .map { String($0).trimmingCharacters(in: .whitespaces) } |
| 13 | 13 | } |
| 14 | 14 | |
| 15 | private func parseOrgTableSeparatorRow(_ line: String) -> [String] { | |
| 15 | func parseOrgTableSeparatorRow(_ line: String) -> [String] { | |
| 16 | 16 | var content = line.trimmingCharacters(in: .whitespaces) |
| 17 | 17 | if content.hasPrefix("|") { |
| 18 | 18 | content.removeFirst() |
| @@ -25,11 +25,11 @@ private func parseOrgTableSeparatorRow(_ line: String) -> [String] { | ||
| 25 | 25 | .map { String($0).trimmingCharacters(in: .whitespaces) } |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | private func isTableSeparatorCell(_ cell: String) -> Bool { | |
| 28 | func isTableSeparatorCell(_ cell: String) -> Bool { | |
| 29 | 29 | tableAlignment(for: cell) != nil |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | private func tableAlignment(for cell: String) -> String? { | |
| 32 | func tableAlignment(for cell: String) -> String? { | |
| 33 | 33 | let trimmed = cell.trimmingCharacters(in: .whitespaces) |
| 34 | 34 | guard !trimmed.isEmpty else { return nil } |
| 35 | 35 | |
| @@ -51,51 +51,3 @@ private func tableAlignment(for cell: String) -> String? { | ||
| 51 | 51 | return "" |
| 52 | 52 | } |
| 53 | 53 | } |
| 54 | ||
| 55 | func renderHTMLTable( | |
| 56 | rows: [[String]], | |
| 57 | inlineRenderer: (String) -> String | |
| 58 | ) -> String { | |
| 59 | guard !rows.isEmpty else { return "" } | |
| 60 | let separatorCells: [String] | |
| 61 | if rows.count > 1, rows[1].count == 1 { | |
| 62 | separatorCells = parseOrgTableSeparatorRow(rows[1][0]) | |
| 63 | } else { | |
| 64 | separatorCells = rows.count > 1 ? rows[1] : [] | |
| 65 | } | |
| 66 | let hasHeaderSeparator = rows.count > 1 && !separatorCells.isEmpty && separatorCells.allSatisfy(isTableSeparatorCell) | |
| 67 | let headerRow = rows.first ?? [] | |
| 68 | let bodyRows = hasHeaderSeparator ? Array(rows.dropFirst(2)) : rows | |
| 69 | let columnAlignments = hasHeaderSeparator ? separatorCells.map(tableAlignment) : [] | |
| 70 | var html = "<table>\n" | |
| 71 | ||
| 72 | if hasHeaderSeparator { | |
| 73 | html += "<thead><tr>" | |
| 74 | for (index, cell) in headerRow.enumerated() { | |
| 75 | html += "<th" + tableAlignmentStyleAttribute(columnAlignment(at: index, in: columnAlignments)) + ">" + inlineRenderer(cell) + "</th>" | |
| 76 | } | |
| 77 | html += "</tr></thead>\n" | |
| 78 | } | |
| 79 | ||
| 80 | html += "<tbody>\n" | |
| 81 | for row in bodyRows { | |
| 82 | html += "<tr>" | |
| 83 | for (index, cell) in row.enumerated() { | |
| 84 | html += "<td" + tableAlignmentStyleAttribute(columnAlignment(at: index, in: columnAlignments)) + ">" + inlineRenderer(cell) + "</td>" | |
| 85 | } | |
| 86 | html += "</tr>\n" | |
| 87 | } | |
| 88 | html += "</tbody>\n" | |
| 89 | html += "</table>\n" | |
| 90 | return html | |
| 91 | } | |
| 92 | ||
| 93 | private func columnAlignment(at index: Int, in alignments: [String?]) -> String? { | |
| 94 | guard alignments.indices.contains(index) else { return nil } | |
| 95 | return alignments[index] | |
| 96 | } | |
| 97 | ||
| 98 | private func tableAlignmentStyleAttribute(_ alignment: String?) -> String { | |
| 99 | guard let alignment, !alignment.isEmpty else { return "" } | |
| 100 | return #" style="text-align: \#(alignment);""# | |
| 101 | } | |
Sources/OrgSwift/Timestamps.swift deleted −89
| @@ -1,89 +0,0 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // Org timestamps: active `<2024-01-15 Mon>`, inactive `[2024-01-15 Mon]`, with a time | |
| 4 | // `<… 10:30>`, a same-day time range `<… 10:00-11:45>`, and a multi-day range | |
| 5 | // `<…>--<…>`. Rendered as `<time>` elements the way orgo does: the date is data a | |
| 6 | // browser can act on, day names and repeater/warning cookies are dropped, and a range | |
| 7 | // becomes two `<time>`s joined by an en-dash. The interior arrives already | |
| 8 | // HTML-escaped, so these operate on `<…>` / `[…]`. | |
| 9 | ||
| 10 | /// Render one timestamp interior (no surrounding brackets) as one or two `<time>` | |
| 11 | /// elements. `interior` is e.g. "2024-01-15 Mon", "2024-01-15 Mon 10:30", | |
| 12 | /// "2024-01-15 Mon 10:00-11:45". A day name and any repeater/warning cookie are ignored. | |
| 13 | func renderTimestamp(interior: String, inactive: Bool) -> String { | |
| 14 | let ns = interior as NSString | |
| 15 | let full = NSRange(location: 0, length: ns.length) | |
| 16 | let cssClass = inactive ? "timestamp inactive" : "timestamp" | |
| 17 | ||
| 18 | guard let dateRegex = try? NSRegularExpression(pattern: #"\d{4}-\d{2}-\d{2}"#), | |
| 19 | let dateMatch = dateRegex.firstMatch(in: interior, range: full) else { | |
| 20 | // No date — not really a timestamp; hand the text back untouched. | |
| 21 | return interior | |
| 22 | } | |
| 23 | let date = ns.substring(with: dateMatch.range) | |
| 24 | ||
| 25 | var startTime: String? | |
| 26 | var endTime: String? | |
| 27 | if let timeRegex = try? NSRegularExpression(pattern: #"(\d{2}:\d{2})(?:-(\d{2}:\d{2}))?"#), | |
| 28 | let timeMatch = timeRegex.firstMatch(in: interior, range: full) { | |
| 29 | startTime = ns.substring(with: timeMatch.range(at: 1)) | |
| 30 | let endRange = timeMatch.range(at: 2) | |
| 31 | if endRange.location != NSNotFound { | |
| 32 | endTime = ns.substring(with: endRange) | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | func time(_ datetime: String, _ text: String) -> String { | |
| 37 | #"<time class="\#(cssClass)" datetime="\#(datetime)">\#(text)</time>"# | |
| 38 | } | |
| 39 | ||
| 40 | if let startTime, let endTime { | |
| 41 | return time("\(date)T\(startTime)", "\(date) \(startTime)") | |
| 42 | + "–" | |
| 43 | + time("\(date)T\(endTime)", endTime) | |
| 44 | } | |
| 45 | if let startTime { | |
| 46 | return time("\(date)T\(startTime)", "\(date) \(startTime)") | |
| 47 | } | |
| 48 | return time(date, date) | |
| 49 | } | |
| 50 | ||
| 51 | /// Extract every timestamp in already-escaped inline text into protected fragments, | |
| 52 | /// leaving placeholders behind so later emphasis passes cannot touch a `<time>`. | |
| 53 | func protectTimestamps(in text: String, protectedFragments: inout [String: String]) -> String { | |
| 54 | var result = text | |
| 55 | ||
| 56 | // Multi-day range first, so its two `<…>` are consumed as one unit before the | |
| 57 | // single-timestamp pass sees them. `--` between them becomes the en-dash. | |
| 58 | result = protectMatches( | |
| 59 | in: result, | |
| 60 | pattern: #"<(\d{4}-\d{2}-\d{2}[^&]*?)>--<(\d{4}-\d{2}-\d{2}[^&]*?)>"#, | |
| 61 | protectedFragments: &protectedFragments | |
| 62 | ) { match, nsText in | |
| 63 | let a = nsText.substring(with: match.range(at: 1)) | |
| 64 | let b = nsText.substring(with: match.range(at: 2)) | |
| 65 | return renderTimestamp(interior: a, inactive: false) | |
| 66 | + "–" | |
| 67 | + renderTimestamp(interior: b, inactive: false) | |
| 68 | } | |
| 69 | ||
| 70 | // Active single. | |
| 71 | result = protectMatches( | |
| 72 | in: result, | |
| 73 | pattern: #"<(\d{4}-\d{2}-\d{2}[^&]*?)>"#, | |
| 74 | protectedFragments: &protectedFragments | |
| 75 | ) { match, nsText in | |
| 76 | renderTimestamp(interior: nsText.substring(with: match.range(at: 1)), inactive: false) | |
| 77 | } | |
| 78 | ||
| 79 | // Inactive. Requires a date inside, so `[not a stamp]` is left alone. | |
| 80 | result = protectMatches( | |
| 81 | in: result, | |
| 82 | pattern: #"\[(\d{4}-\d{2}-\d{2}[^\]]*?)\]"#, | |
| 83 | protectedFragments: &protectedFragments | |
| 84 | ) { match, nsText in | |
| 85 | renderTimestamp(interior: nsText.substring(with: match.range(at: 1)), inactive: true) | |
| 86 | } | |
| 87 | ||
| 88 | return result | |
| 89 | } | |
Tests/OrgSwiftTests/OrgTreeTests.swift added +300
| @@ -0,0 +1,300 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgSwift | |
| 4 | ||
| 5 | /// Tests for the element tree: `source → OrgDocument → {HTML, AttributedString}`. | |
| 6 | /// | |
| 7 | /// The parse tests assert the tree carries structure faithfully. The renderer tests assert | |
| 8 | /// the point of the split — that a second output format is a walk over the same tree rather | |
| 9 | /// than a second parser. Corpus conformance is gated separately, in `ConformanceTests`. | |
| 10 | struct ASTParseTests { | |
| 11 | ||
| 12 | @Test | |
| 13 | func headingCarriesTodoPriorityAndTags() { | |
| 14 | let doc = OrgParser.parse("* TODO [#A] Write the parser :work:rust:") | |
| 15 | guard case .heading(let heading) = doc.elements.first else { | |
| 16 | Issue.record("expected a heading"); return | |
| 17 | } | |
| 18 | #expect(heading.level == 1) | |
| 19 | #expect(heading.todo == "TODO") | |
| 20 | #expect(heading.priority == "A") | |
| 21 | #expect(heading.tags == ["work", "rust"]) | |
| 22 | #expect(OrgParser.plain(heading.title) == "Write the parser") | |
| 23 | } | |
| 24 | ||
| 25 | @Test | |
| 26 | func emphasisNestsRatherThanFlattening() { | |
| 27 | // The point of a tree: bold containing italic is structure, not a markup string. | |
| 28 | let objects = OrgParser.parseInline("*bold /inner/ rest*") | |
| 29 | guard case .bold(let children) = objects.first else { | |
| 30 | Issue.record("expected bold"); return | |
| 31 | } | |
| 32 | #expect(children.contains { if case .italic = $0 { return true } else { return false } }) | |
| 33 | } | |
| 34 | ||
| 35 | @Test | |
| 36 | func documentKeywordsAreMetadataNotContent() { | |
| 37 | let doc = OrgParser.parse("#+TITLE: My Doc\n#+AUTHOR: Someone\n\nBody.") | |
| 38 | #expect(doc.keyword("title") == "My Doc") | |
| 39 | #expect(doc.keyword("author") == "Someone") | |
| 40 | // Metadata does not appear as a body element. | |
| 41 | #expect(doc.elements.count == 1) | |
| 42 | guard case .paragraph = doc.elements.first else { | |
| 43 | Issue.record("expected a single paragraph"); return | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | @Test | |
| 48 | func nestedListsBecomeNestedItems() { | |
| 49 | let doc = OrgParser.parse(""" | |
| 50 | - outer | |
| 51 | - inner | |
| 52 | - deepest | |
| 53 | - second | |
| 54 | """) | |
| 55 | guard case .list(let list) = doc.elements.first else { | |
| 56 | Issue.record("expected a list"); return | |
| 57 | } | |
| 58 | #expect(list.items.count == 2) | |
| 59 | let inner = list.items[0].sublist | |
| 60 | #expect(inner != nil) | |
| 61 | #expect(inner?.items.first?.sublist?.items.count == 1) | |
| 62 | } | |
| 63 | ||
| 64 | @Test | |
| 65 | func tableKeepsRuleRowAndAlignments() { | |
| 66 | let doc = OrgParser.parse(""" | |
| 67 | | Name | Score | | |
| 68 | |:------+------:| | |
| 69 | | alpha | 10 | | |
| 70 | """) | |
| 71 | guard case .table(let table) = doc.elements.first else { | |
| 72 | Issue.record("expected a table"); return | |
| 73 | } | |
| 74 | #expect(table.rows.count == 3) | |
| 75 | #expect(table.headerRowCount == 1) | |
| 76 | #expect(table.alignments == [.left, .right]) | |
| 77 | if case .rule = table.rows[1] {} else { Issue.record("row 1 should be the rule") } | |
| 78 | } | |
| 79 | ||
| 80 | @Test | |
| 81 | func timestampsAndLinksBecomeTypedObjects() { | |
| 82 | let objects = OrgParser.parseInline("due <2024-01-15 Mon 10:30> see [[id:abc][the thing]]") | |
| 83 | let hasTimestamp = objects.contains { | |
| 84 | if case .timestamp(let stamp) = $0 { return stamp.machineValue == "2024-01-15T10:30" } | |
| 85 | return false | |
| 86 | } | |
| 87 | #expect(hasTimestamp) | |
| 88 | let hasIDLink = objects.contains { | |
| 89 | if case .link(let link) = $0, case .id(let identifier) = link.target { return identifier == "abc" } | |
| 90 | return false | |
| 91 | } | |
| 92 | #expect(hasIDLink) | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | struct ASTRendererTests { | |
| 97 | ||
| 98 | /// The payoff: one parse, two output formats, neither re-deriving the other's work. | |
| 99 | @Test | |
| 100 | func oneParseFeedsTwoRenderers() { | |
| 101 | let document = OrgParser.parse("A *bold* claim with ~code~ and a [[https://example.com][link]].") | |
| 102 | ||
| 103 | let html = OrgHTMLTreeRenderer().render(document) | |
| 104 | #expect(html.contains("<strong>bold</strong>")) | |
| 105 | #expect(html.contains("<code>code</code>")) | |
| 106 | #expect(html.contains(#"<a href="https://example.com">link</a>"#)) | |
| 107 | ||
| 108 | let attributed = OrgAttributedStringRenderer().inline({ | |
| 109 | if case .paragraph(let objects) = document.elements[0] { return objects } | |
| 110 | return [] | |
| 111 | }()) | |
| 112 | // Same content, native representation: no markup, real attributes. | |
| 113 | let plain = String(attributed.characters) | |
| 114 | #expect(plain == "A bold claim with code and a link.") | |
| 115 | #expect(!plain.contains("<")) | |
| 116 | ||
| 117 | let boldRun = attributed.runs.first { $0.inlinePresentationIntent == .stronglyEmphasized } | |
| 118 | #expect(boldRun != nil) | |
| 119 | let codeRun = attributed.runs.first { $0.inlinePresentationIntent == .code } | |
| 120 | #expect(codeRun != nil) | |
| 121 | let linkRun = attributed.runs.first { $0.link != nil } | |
| 122 | #expect(linkRun?.link?.absoluteString == "https://example.com") | |
| 123 | } | |
| 124 | ||
| 125 | @Test | |
| 126 | func attributedStringCarriesRolesForNonStandardIntents() { | |
| 127 | let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>") | |
| 128 | let attributed = OrgAttributedStringRenderer().inline(objects) | |
| 129 | let roles: [OrgRole] = attributed.runs.compactMap { $0[OrgRoleAttribute.self] } | |
| 130 | #expect(roles.contains(.superscript)) | |
| 131 | #expect(roles.contains(.timestamp)) | |
| 132 | } | |
| 133 | ||
| 134 | @Test | |
| 135 | func consecutiveListsOfDifferentKindsStaySeparate() { | |
| 136 | // An ordered list followed by a bullet list is two lists, not one with mixed items. | |
| 137 | let doc = OrgParser.parse(""" | |
| 138 | 1. first | |
| 139 | 2. second | |
| 140 | ||
| 141 | - [ ] todo | |
| 142 | - [X] done | |
| 143 | """) | |
| 144 | let lists = doc.elements.compactMap { element -> OrgList? in | |
| 145 | if case .list(let list) = element { return list } else { return nil } | |
| 146 | } | |
| 147 | #expect(lists.count == 2) | |
| 148 | #expect(lists.first?.kind == .ordered) | |
| 149 | #expect(lists.last?.kind == .unordered) | |
| 150 | #expect(lists.last?.items.first?.checkbox == .off) | |
| 151 | ||
| 152 | let html = OrgHTMLTreeRenderer().render(doc) | |
| 153 | #expect(html.contains("</ol>")) | |
| 154 | #expect(html.contains("<ul>")) | |
| 155 | } | |
| 156 | ||
| 157 | @Test | |
| 158 | func inlineFootnoteDefinesItsNoteAtTheReference() { | |
| 159 | let doc = OrgParser.parse("A claim.[fn:x:defined right here]") | |
| 160 | let html = OrgHTMLTreeRenderer().render(doc) | |
| 161 | #expect(html.contains(##"href="#fn-1">1</a>"##)) | |
| 162 | // Inline note text sits directly in the item; only reference-style notes get a <p>. | |
| 163 | #expect(html.contains(#"<li id="fn-1">defined right here "#)) | |
| 164 | #expect(!html.contains(#"<li id="fn-1"><p>"#)) | |
| 165 | } | |
| 166 | ||
| 167 | @Test | |
| 168 | func referenceStyleFootnoteKeepsItsParagraph() { | |
| 169 | let doc = OrgParser.parse("A claim.[fn:1]\n\n[fn:1] The definition.") | |
| 170 | let html = OrgHTMLTreeRenderer().render(doc) | |
| 171 | #expect(html.contains(#"<li id="fn-1"><p>The definition.</p>"#)) | |
| 172 | } | |
| 173 | ||
| 174 | @Test | |
| 175 | func timestampRangesRenderAsTwoTimeElements() { | |
| 176 | // Same-day: the end shows only its time, since the start carries the date. | |
| 177 | let sameDay = OrgHTMLTreeRenderer().render(OrgParser.parse("Range <2024-01-15 Mon 10:00-11:45>.")) | |
| 178 | #expect(sameDay.contains(#"datetime="2024-01-15T10:00">2024-01-15 10:00</time>"#)) | |
| 179 | #expect(sameDay.contains("–")) | |
| 180 | #expect(sameDay.contains(#"datetime="2024-01-15T11:45">11:45</time>"#)) | |
| 181 | ||
| 182 | // Multi-day: one timestamp carrying an end date, rendered as two stamps. | |
| 183 | let multiDay = OrgHTMLTreeRenderer().render(OrgParser.parse("Span <2024-01-15 Mon>--<2024-01-20 Sat>.")) | |
| 184 | #expect(multiDay.contains(#"datetime="2024-01-15">2024-01-15</time>"#)) | |
| 185 | #expect(multiDay.contains("–")) | |
| 186 | #expect(multiDay.contains(#"datetime="2024-01-20">2024-01-20</time>"#)) | |
| 187 | // The `--` join is consumed, not left as stray text. | |
| 188 | #expect(!multiDay.contains("--")) | |
| 189 | } | |
| 190 | ||
| 191 | @Test | |
| 192 | func treeRendererProducesStructuralHTML() { | |
| 193 | let document = OrgParser.parse(""" | |
| 194 | * Heading | |
| 195 | ||
| 196 | | a | b | | |
| 197 | |---+---| | |
| 198 | | 1 | 2 | | |
| 199 | ||
| 200 | - [ ] todo | |
| 201 | - [X] done | |
| 202 | """) | |
| 203 | let html = OrgHTMLTreeRenderer().render(document) | |
| 204 | #expect(html.contains("<h1>Heading</h1>")) | |
| 205 | #expect(html.contains("<thead>")) | |
| 206 | #expect(html.contains("<th>a</th>")) | |
| 207 | #expect(html.contains("<td>1</td>")) | |
| 208 | #expect(html.contains("<code>[ ]</code> todo")) | |
| 209 | #expect(html.contains("<code>[X]</code> done")) | |
| 210 | } | |
| 211 | } | |
| 212 | ||
| 213 | /// The render options, which the conformance corpus does not exercise because it renders with | |
| 214 | /// no repository context. These were written as shipped-vs-tree equivalence checks during the | |
| 215 | /// migration; now that the tree *is* the renderer, they assert the behaviour directly. | |
| 216 | struct OrgRenderOptionsTests { | |
| 217 | ||
| 218 | /// gitbay's configuration: images resolve against `raw`, links against `blob`. | |
| 219 | private static let repositoryOptions = OrgRenderOptions( | |
| 220 | host: "gitbay.org", | |
| 221 | owner: "krz", | |
| 222 | repositoryName: "gitbay", | |
| 223 | ref: "HEAD", | |
| 224 | readmePath: "README.org", | |
| 225 | imagePathSegment: "raw", | |
| 226 | linkPathSegment: "blob" | |
| 227 | ) | |
| 228 | ||
| 229 | @Test | |
| 230 | func resolvesRepositoryRelativeURLsAgainstTheirOwnSegment() { | |
| 231 | let html = OrgRenderer.renderToHTML(""" | |
| 232 | A relative link to [[docs/DESIGN.org][the design]] and an absolute one to | |
| 233 | [[https://example.org][elsewhere]]. | |
| 234 | ||
| 235 | [[file:docs/logo.png]] | |
| 236 | """, options: Self.repositoryOptions) | |
| 237 | ||
| 238 | #expect(html.contains("https://gitbay.org/krz/gitbay/raw/HEAD/docs/logo.png")) | |
| 239 | #expect(html.contains("https://gitbay.org/krz/gitbay/blob/HEAD/docs/DESIGN.org")) | |
| 240 | #expect(html.contains("https://example.org")) | |
| 241 | } | |
| 242 | ||
| 243 | @Test | |
| 244 | func leavesRelativeTargetsAloneWithoutRepositoryContext() { | |
| 245 | let html = OrgRenderer.renderToHTML("[[docs/DESIGN.org][the design]]\n\n[[file:logo.png]]") | |
| 246 | #expect(html.contains(#"href="docs/DESIGN.org""#)) | |
| 247 | #expect(html.contains(#"src="logo.png""#)) | |
| 248 | } | |
| 249 | ||
| 250 | @Test | |
| 251 | func emitsTheMetadataHeaderOnlyWhenAsked() { | |
| 252 | let source = "#+TITLE: My Doc\n#+AUTHOR: Someone\n\nBody." | |
| 253 | ||
| 254 | let withHeader = OrgRenderer.renderToHTML(source) | |
| 255 | #expect(withHeader.contains(#"<h1 class="org-title">My Doc</h1>"#)) | |
| 256 | #expect(withHeader.contains(#"<p class="org-author">Someone</p>"#)) | |
| 257 | ||
| 258 | let without = OrgRenderer.renderToHTML(source, options: OrgRenderOptions(metadataHeader: false)) | |
| 259 | #expect(!without.contains("org-title")) | |
| 260 | #expect(!without.contains("My Doc")) | |
| 261 | #expect(without.contains("<p>Body.</p>")) | |
| 262 | } | |
| 263 | ||
| 264 | @Test | |
| 265 | func rejectsUnsafeSchemes() { | |
| 266 | let html = OrgRenderer.renderToHTML("[[javascript:alert(1)][click]]") | |
| 267 | #expect(!html.lowercased().contains("javascript:")) | |
| 268 | // The link degrades to its text rather than becoming a bad anchor. | |
| 269 | #expect(html.contains("click")) | |
| 270 | } | |
| 271 | ||
| 272 | /// A range whose halves are inactive timestamps is still a range. orgo applies the `--` | |
| 273 | /// rule to both bracket kinds, requiring only that the halves agree on activeness; the | |
| 274 | /// renderer this replaced joined active ranges only, so this is the one behaviour the | |
| 275 | /// swap deliberately changed. | |
| 276 | @Test | |
| 277 | func joinsInactiveTimestampRanges() { | |
| 278 | let html = OrgRenderer.renderToHTML("CLOCK: [2024-01-15 Mon 09:00]--[2024-01-15 Mon 10:00]") | |
| 279 | #expect(!html.contains("--")) | |
| 280 | #expect(html.contains("–")) | |
| 281 | #expect(html.contains(#"class="timestamp inactive""#)) | |
| 282 | } | |
| 283 | } | |
| 284 | ||
| 285 | private func astCorpusCasesDir() -> URL? { | |
| 286 | if let env = ProcessInfo.processInfo.environment["ORG_CONFORMANCE_DIR"] { | |
| 287 | let cases = URL(fileURLWithPath: env).appendingPathComponent("cases") | |
| 288 | if FileManager.default.fileExists(atPath: cases.path) { return cases } | |
| 289 | } | |
| 290 | let pkgRoot = URL(fileURLWithPath: #filePath) | |
| 291 | .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() | |
| 292 | let sibling = pkgRoot.deletingLastPathComponent() | |
| 293 | .appendingPathComponent("org-conformance").appendingPathComponent("cases") | |
| 294 | return FileManager.default.fileExists(atPath: sibling.path) ? sibling : nil | |
| 295 | } | |
| 296 | ||
| 297 | private func astCaseNames(_ dir: URL) -> [String] { | |
| 298 | let items = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? [] | |
| 299 | return items.filter { $0.hasSuffix(".org") }.map { String($0.dropLast(4)) }.sorted() | |
| 300 | } | |