Parse org into an element tree, and render the tree !1
16 files changed, +1864 −1359
Layout: unified · split
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 | [org-mode](https://orgmode.org) to sanitized HTML. Pure Foundation — no | 4 | [org-mode](https://orgmode.org) to sanitized HTML. Pure Foundation — no |
| 5 | SwiftUI, WebKit, UIKit, or third-party packages. | 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 | 7 | Originally extracted from the hand-rolled renderer in the Hutch iOS client so it |
| 8 | shared across apps. | 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 | ## Supported syntax | 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 | let range = NSRange(location: 0, length: (text as NSString).length) | 80 | let range = NSRange(location: 0, length: (text as NSString).length) |
| 81 | return regex.firstMatch(in: text, range: range) != nil | 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 | import Foundation | 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 | /// Detect a reference-style footnote definition line: `[fn:label] text…`. Returns the | 3 | /// Detect a reference-style footnote definition line: `[fn:label] text…`. Returns the |
| 70 | /// label and the definition text, or nil if the line is not one. | 4 | /// label and the definition text, or nil if the line is not one. |
| 71 | func orgFootnoteDefinition(in line: String) -> (label: String, text: String)? { | 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 | import Foundation | 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 | /// Strip org's `file:` link prefix. `[[file:diagram.png]]` targets a local path the same | 3 | /// Strip org's `file:` link prefix. `[[file:diagram.png]]` targets a local path the same |
| 72 | /// way `[[diagram.png]]` does; the scheme is org bookkeeping, not part of the URL. | 4 | /// way `[[diagram.png]]` does; the scheme is org bookkeeping, not part of the URL. |
| 73 | func normalizeOrgLinkTarget(_ target: String) -> String { | 5 | func normalizeOrgLinkTarget(_ target: String) -> String { |
| 74 | target.hasPrefix("file:") ? String(target.dropFirst(5)) : target | 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 | private func isRenderableImageSource(_ source: String) -> Bool { | 10 | private func isRenderableImageSource(_ source: String) -> Bool { |
| 154 | let lowercased = source.lowercased() | 11 | let lowercased = source.lowercased() |
| @@ -168,73 +25,6 @@ func standaloneOrgImage(in line: String) -> String? { | |||
| 168 | return path | 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 | func resolveRepositoryLinkURL( | 29 | func resolveRepositoryLinkURL( |
| 240 | _ source: String, | 30 | _ source: String, |
Sources/OrgSwift/Lists.swift +1 −172
| @@ -1,188 +1,17 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | 2 | ||
| 3 | enum OrgListType: Equatable { | ||
| 4 | case unordered | ||
| 5 | case ordered | ||
| 6 | } | ||
| 7 | |||
| 8 | func orderedListItem(in line: String) -> String? { | 3 | func orderedListItem(in line: String) -> String? { |
| 9 | guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil } | 4 | guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil } |
| 10 | return String(match.2) | 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 | /// True if a line (already outdented to its level) starts a list item at column 0. | 8 | /// True if a line (already outdented to its level) starts a list item at column 0. |
| 122 | func isListMarkerLine(_ line: String) -> Bool { | 9 | func isListMarkerLine(_ line: String) -> Bool { |
| 123 | line.hasPrefix("- ") || line.hasPrefix("+ ") || orderedListItem(in: line) != nil | 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 | func isIndentedContinuationLine(_ line: String) -> Bool { | 13 | func isIndentedContinuationLine(_ line: String) -> Bool { |
| 138 | guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false } | 14 | guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false } |
| 139 | guard let first = line.first else { return false } | 15 | guard let first = line.first else { return false } |
| 140 | return first == " " || first == "\t" | 16 | return first == " " || first == "\t" |
| 141 | } | 17 | }\ No newline at end of file |
| 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 | } | ||
Sources/OrgSwift/OrgRenderer.swift +2 −552
| @@ -115,14 +115,8 @@ public enum OrgRenderer { | |||
| 115 | options: OrgRenderOptions = .init(), | 115 | options: OrgRenderOptions = .init(), |
| 116 | highlighter: CodeHighlighter = PlainCodeHighlighter() | 116 | highlighter: CodeHighlighter = PlainCodeHighlighter() |
| 117 | ) -> String { | 117 | ) -> String { |
| 118 | orgToHTML( | 118 | OrgHTMLTreeRenderer(options: options, highlighter: highlighter) |
| 119 | source, | 119 | .render(OrgParser.parse(source)) |
| 120 | highlighter: highlighter, | ||
| 121 | imageURLResolver: options.imageURLResolver(), | ||
| 122 | linkURLResolver: options.linkURLResolver(), | ||
| 123 | metadataHeader: options.metadataHeader, | ||
| 124 | headingLevelOffset: options.headingLevelOffset | ||
| 125 | ) | ||
| 126 | } | 120 | } |
| 127 | } | 121 | } |
| 128 | 122 | ||
| @@ -138,547 +132,3 @@ func splitHeadingTags(_ heading: String) -> (title: String, tags: [String]) { | |||
| 138 | let tags = String(match.2).split(separator: ":").map(String.init).filter { !$0.isEmpty } | 132 | let tags = String(match.2).split(separator: ":").map(String.init).filter { !$0.isEmpty } |
| 139 | return (String(match.1).trimmingCharacters(in: .whitespaces), tags) | 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 | .map { String($0).trimmingCharacters(in: .whitespaces) } | 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 | var content = line.trimmingCharacters(in: .whitespaces) | 16 | var content = line.trimmingCharacters(in: .whitespaces) |
| 17 | if content.hasPrefix("|") { | 17 | if content.hasPrefix("|") { |
| 18 | content.removeFirst() | 18 | content.removeFirst() |
| @@ -25,11 +25,11 @@ private func parseOrgTableSeparatorRow(_ line: String) -> [String] { | |||
| 25 | .map { String($0).trimmingCharacters(in: .whitespaces) } | 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 | tableAlignment(for: cell) != nil | 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 | let trimmed = cell.trimmingCharacters(in: .whitespaces) | 33 | let trimmed = cell.trimmingCharacters(in: .whitespaces) |
| 34 | guard !trimmed.isEmpty else { return nil } | 34 | guard !trimmed.isEmpty else { return nil } |
| 35 | 35 | ||
| @@ -51,51 +51,3 @@ private func tableAlignment(for cell: String) -> String? { | |||
| 51 | return "" | 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 | } | ||