Add OrgSwiftUI, a native renderer for the shared tree !2

merged merged by cmc on 2026-08-31 17:52 UTC · krz/org-swift:swiftui-renderer into main

18 files changed, +1201 −125

Layout: unified · split

ARCHITECTURE.md +45 −7
@@ -4,17 +4,27 @@ OrgSwift parses org into an element tree and renders that tree. Parsing happens
4output format is a walk over the result. 4output format is a walk over the result.
5 5
6``` 6```
7Sources/OrgSwift/ 7Sources/OrgSwift/ Foundation only
8 AST/ 8 AST/
9 OrgDocument.swift the element tree 9 OrgDocument.swift the element tree
10 OrgParser.swift source → OrgDocument (blocks) 10 OrgParser.swift source → OrgDocument (blocks)
11 OrgInlineParser.swift text → [OrgObject] (inlines) 11 OrgInlineParser.swift text → [OrgObject] (inlines)
12 OrgHTMLTreeRenderer.swift OrgDocument → HTML 12 OrgHTMLTreeRenderer.swift OrgDocument → HTML
13 OrgAttributedStringRenderer.swift [OrgObject] → AttributedString 13 OrgAttributedStringRenderer.swift [OrgObject] → AttributedString
14 OrgFootnotes.swift document-wide numbering, shared by renderers
15 OrgURLResolver.swift typed target → safe URL, shared by renderers
14 OrgRenderer.swift the public entry point, plus OrgRenderOptions 16 OrgRenderer.swift the public entry point, plus OrgRenderOptions
15 Escaping.swift, Links.swift, … shared helpers: escaping, URL sanitising and 17 Escaping.swift, Links.swift, … shared helpers: escaping, URL sanitising and
16 repository-relative resolution, line predicates 18 repository-relative resolution, line predicates
17 Skeleton.swift the HTML → semantic-skeleton reduction used by tests 19 Skeleton.swift the HTML → semantic-skeleton reduction used by tests
20
21Sources/OrgSwiftUI/ depends on OrgSwift
22 OrgView.swift the public entry point
23 OrgLayout.swift the display model
24 OrgLayoutBuilder.swift OrgDocument → OrgLayout
25 OrgInlineStyler.swift [OrgObject] → styled AttributedString
26 OrgBlockView.swift, OrgListView.swift, OrgTableView.swift
27 OrgCodeStyler.swift the app's syntax highlighter, as a protocol
18``` 28```
19 29
20`OrgRenderer.renderToHTML` is a thin façade over `OrgParser.parse` + `OrgHTMLTreeRenderer`. 30`OrgRenderer.renderToHTML` is a thin façade over `OrgParser.parse` + `OrgHTMLTreeRenderer`.
@@ -29,7 +39,12 @@ format would have meant re-deriving the parse rather than reusing it.
29 39
30With a tree, `OrgAttributedStringRenderer` is roughly 140 lines and shares the parse 40With a tree, `OrgAttributedStringRenderer` is roughly 140 lines and shares the parse
31entirely. It is **Foundation-only** — no SwiftUI — so it works server-side, in a CLI, 41entirely. It is **Foundation-only** — no SwiftUI — so it works server-side, in a CLI,
32anywhere; a SwiftUI layer would sit on top of it for the inline runs inside each block. 42anywhere. `OrgSwiftUI` sits on top of it for the inline runs inside each block, and is a
43separate product so callers who want HTML never import SwiftUI.
44
45Three renderers now share one parse, one URL resolver (`OrgURLResolver`) and one footnote
46numbering (`OrgFootnotes`), both `package`-scoped. That sharing is the point: a link that
47resolves in HTML resolves natively, and note 1 is note 1 in both.
33 48
34The types mirror orgo's `model.rs` — `OrgElement`/`OrgObject` against orgo's 49The types mirror orgo's `model.rs` — `OrgElement`/`OrgObject` against orgo's
35`Element`/`Object`, `OrgTableRow.{cells,rule}` against `TableRow::{Cells,Rule}`, the same 50`Element`/`Object`, `OrgTableRow.{cells,rule}` against `TableRow::{Cells,Rule}`, the same
@@ -70,10 +85,33 @@ text. orgo applies the `--` rule to both bracket kinds, requiring only that the
70on activeness, so the new behaviour is the more correct one. It is asserted directly in 85on activeness, so the new behaviour is the more correct one. It is asserted directly in
71`joinsInactiveTimestampRanges`. 86`joinsInactiveTimestampRanges`.
72 87
88## OrgSwiftUI
89
90`OrgView(source, options:styler:)` renders org as native SwiftUI: selectable text, Dynamic
91Type, VoiceOver, and no web view.
92
93The walk runs **off `body`**, in a `.task` keyed to the source, producing an `OrgLayout` the
94views then render as pure layout. Three things force that. Footnote numbering is sequential
95and document-wide, so it cannot happen in a `@ViewBuilder`. Syntax highlighting is expensive
96and must not re-run per layout pass. And SwiftUI re-initialises views freely, so `init` is
97not a safe place either.
98
99Tables are the interesting part: `Grid`/`GridRow` with `.gridColumnAlignment()` set on the
100first row and inherited down, wrapped in a horizontal `ScrollView` for phone-width overflow.
101`OrgTable` already carried the rows, the rule position and the per-column alignments.
102
103Syntax highlighting arrives through `OrgCodeStyler`, the native counterpart to
104`CodeHighlighter` — returning `AttributedString` where the other returns HTML. Keeping it a
105protocol is what lets the package stay dependency-free.
106
107One behaviour deliberately differs from HTML. An image source that will not resolve to an
108**absolute** URL degrades to its alt text: HTML may emit a relative `src` for a document base
109URL to resolve, and a view has no base, so a relative source is unloadable.
110
111Not covered natively: `#+BEGIN_EXPORT html` passes through verbatim in HTML and has nowhere
112to go in a view, so it renders as the literal block it is.
113
73## Next 114## Next
74 115
75A SwiftUI renderer belongs in a **separate product** depending on this one, so the parser and 116The corpus measures HTML only. Tree-level conformance would let `OrgLayout` be measured
76HTML renderer stay Foundation-only and callers who want HTML never import SwiftUI. Tables are 117against the same goldens instead of only its own unit tests.
77the interesting part: `Grid`/`GridRow` with `.gridColumnAlignment()`, wrapped in a horizontal
78`ScrollView` for phone-width overflow — the approach MarkdownUI uses. `OrgTable` already
79carries the rows, the rule position, and the per-column alignments that needs.
Package.swift +9 −2
@@ -8,13 +8,20 @@ let package = Package(
8 .iOS(.v16) 8 .iOS(.v16)
9 ], 9 ],
10 products: [ 10 products: [
11 .library(name: "OrgSwift", targets: ["OrgSwift"]) 11 .library(name: "OrgSwift", targets: ["OrgSwift"]),
12 .library(name: "OrgSwiftUI", targets: ["OrgSwiftUI"])
12 ], 13 ],
13 targets: [ 14 targets: [
14 .target( 15 .target(
15 name: "OrgSwift", 16 name: "OrgSwift",
16 swiftSettings: [.enableUpcomingFeature("BareSlashRegexLiterals")] 17 swiftSettings: [.enableUpcomingFeature("BareSlashRegexLiterals")]
17 ), 18 ),
18 .testTarget(name: "OrgSwiftTests", dependencies: ["OrgSwift"]) 19 .target(
20 name: "OrgSwiftUI",
21 dependencies: ["OrgSwift"],
22 swiftSettings: [.enableUpcomingFeature("BareSlashRegexLiterals")]
23 ),
24 .testTarget(name: "OrgSwiftTests", dependencies: ["OrgSwift"]),
25 .testTarget(name: "OrgSwiftUITests", dependencies: ["OrgSwiftUI"])
19 ] 26 ]
20) 27)
README.md +33 −2
@@ -1,8 +1,11 @@
1# OrgSwift 1# OrgSwift
2 2
3A dependency-free Swift library that renders a practical subset of 3A 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). Two products:
5SwiftUI, WebKit, UIKit, or third-party packages. 5
6- **`OrgSwift`** — org to sanitized HTML or `AttributedString`. Pure Foundation,
7 no SwiftUI, WebKit, UIKit, or third-party packages.
8- **`OrgSwiftUI`** — org to native SwiftUI views, no web view involved.
6 9
7Originally extracted from the hand-rolled renderer in the Hutch iOS client so it 10Originally extracted from the hand-rolled renderer in the Hutch iOS client so it
8could be shared across apps, and since rebuilt around an element tree: org is 11could be shared across apps, and since rebuilt around an element tree: org is
@@ -58,6 +61,34 @@ let html = OrgRenderer.renderToHTML(
58`./images/badge.svg` then resolves to 61`./images/badge.svg` then resolves to
59`https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg`. 62`https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg`.
60 63
64### Native SwiftUI
65
66`OrgSwiftUI` renders the same parse as views rather than HTML — selectable text,
67Dynamic Type, and VoiceOver, without a `WKWebView`.
68
69```swift
70import OrgSwiftUI
71
72OrgView(orgSource, options: options)
73```
74
75`OrgRenderOptions` means the same thing here, so links and images resolve exactly
76as they do in HTML. An image whose source will not resolve to an *absolute* URL
77shows its alt text instead: HTML can lean on a document base URL, a view cannot.
78
79Syntax highlighting for `#+begin_src` blocks is the app's to supply, which is what
80keeps this package dependency-free:
81
82```swift
83struct Highlighter: OrgCodeStyler {
84 func highlighted(code: String, language: String?) -> AttributedString? {
85 // nil means "show it as plain monospaced text"
86 }
87}
88
89OrgView(orgSource, options: options, styler: Highlighter())
90```
91
61### Title and heading level 92### Title and heading level
62 93
63Two presentation knobs, both defaulting to how Hutch rendered: 94Two presentation knobs, both defaulting to how Hutch rendered:
Sources/OrgSwift/AST/OrgAttributedStringRenderer.swift +53 −38
@@ -7,11 +7,26 @@ import Foundation
7/// not a second parser. It stays Foundation-only (no SwiftUI), so it is usable anywhere; a 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. 8/// SwiftUI block renderer would sit on top, using this for the inline runs inside each block.
9public struct OrgAttributedStringRenderer: Sendable { 9public struct OrgAttributedStringRenderer: Sendable {
10 /// Resolution options, so repository-relative links resolve exactly as they do in HTML.
11 public var options: OrgRenderOptions
10 12
11 public init() {} 13 public init(options: OrgRenderOptions = .init()) {
14 self.options = options
15 }
16
17 private var resolver: OrgURLResolver { OrgURLResolver(options: options) }
12 18
13 /// Render one run of inline objects, carrying intents a UI layer can style. 19 /// Render one run of inline objects, carrying intents a UI layer can style.
20 ///
21 /// Footnote numbering is document-wide, so a caller rendering a whole document threads
22 /// its own ``OrgFootnotes`` through ``inline(_:_:)``; this overload numbers within the
23 /// run it is given.
14 public func inline(_ objects: [OrgObject]) -> AttributedString { 24 public func inline(_ objects: [OrgObject]) -> AttributedString {
25 var notes = OrgFootnotes(document: OrgDocument())
26 return inline(objects, &notes)
27 }
28
29 package func inline(_ objects: [OrgObject], _ notes: inout OrgFootnotes) -> AttributedString {
15 var result = AttributedString() 30 var result = AttributedString()
16 for object in objects { 31 for object in objects {
17 switch object { 32 switch object {
@@ -19,38 +34,26 @@ public struct OrgAttributedStringRenderer: Sendable {
19 result += AttributedString(text) 34 result += AttributedString(text)
20 35
21 case .bold(let children): 36 case .bold(let children):
22 var part = inline(children) 37 result += applying(.stronglyEmphasized, to: inline(children, &notes))
23 part.inlinePresentationIntent = .stronglyEmphasized
24 result += part
25 38
26 case .italic(let children): 39 case .italic(let children):
27 var part = inline(children) 40 result += applying(.emphasized, to: inline(children, &notes))
28 part.inlinePresentationIntent = .emphasized
29 result += part
30 41
31 // Underline and strikethrough have no Foundation-portable attribute (the 42 // Underline and strikethrough have no Foundation-portable attribute (the
32 // underlineStyle/strikethroughStyle keys live in the UIKit/AppKit scopes), so they 43 // underlineStyle/strikethroughStyle keys live in the UIKit/AppKit scopes), so they
33 // travel as roles the UI layer applies. 44 // travel as roles the UI layer applies.
34 case .underline(let children): 45 case .underline(let children):
35 var part = inline(children) 46 result += applying(.underline, to: inline(children, &notes))
36 part.orgRole = .underline
37 result += part
38 47
39 case .strikeThrough(let children): 48 case .strikeThrough(let children):
40 var part = inline(children) 49 result += applying(.strikeThrough, to: inline(children, &notes))
41 part.orgRole = .strikeThrough
42 result += part
43 50
44 case .verbatim(let text), .code(let text): 51 case .verbatim(let text), .code(let text):
45 var part = AttributedString(text) 52 result += applying(.code, to: AttributedString(text))
46 part.inlinePresentationIntent = .code
47 result += part
48 53
49 case .superscript(let children): 54 case .superscript(let children):
50 // No portable superscript attribute; mark it so a UI layer can raise it. 55 // No portable superscript attribute; mark it so a UI layer can raise it.
51 var part = inline(children) 56 result += applying(.superscript, to: inline(children, &notes))
52 part.orgRole = .superscript
53 result += part
54 57
55 case .lineBreak: 58 case .lineBreak:
56 result += AttributedString("\n") 59 result += AttributedString("\n")
@@ -60,8 +63,8 @@ public struct OrgAttributedStringRenderer: Sendable {
60 part.orgRole = .timestamp 63 part.orgRole = .timestamp
61 result += part 64 result += part
62 65
63 case .footnoteRef(let label, _): 66 case .footnoteRef(let label, let inlineNote):
64 var part = AttributedString("[\(label)]") 67 var part = AttributedString("[\(notes.number(for: label, inline: inlineNote))]")
65 part.orgRole = .footnoteReference 68 part.orgRole = .footnoteReference
66 result += part 69 result += part
67 70
@@ -71,8 +74,9 @@ public struct OrgAttributedStringRenderer: Sendable {
71 result += part 74 result += part
72 75
73 case .link(let link): 76 case .link(let link):
74 var part = link.description.map { inline($0) } ?? AttributedString(displayValue(link.target)) 77 var part = link.description.map { inline($0, &notes) }
75 if let url = URL(string: hrefValue(link.target)) { 78 ?? AttributedString(resolver.displayValue(link.target))
79 if let href = resolver.href(for: link.target), let url = URL(string: href) {
76 part.link = url 80 part.link = url
77 } 81 }
78 result += part 82 result += part
@@ -81,6 +85,32 @@ public struct OrgAttributedStringRenderer: Sendable {
81 return result 85 return result
82 } 86 }
83 87
88 // MARK: - Nesting
89
90 /// Add an intent to every run, keeping intents the children already carry. Assigning
91 /// over the whole range instead would flatten `*bold /italic/*` to bold alone.
92 private func applying(
93 _ intent: InlinePresentationIntent, to string: AttributedString
94 ) -> AttributedString {
95 var result = string
96 for range in result.runs.map(\.range) {
97 result[range].inlinePresentationIntent =
98 (result[range].inlinePresentationIntent ?? []).union(intent)
99 }
100 return result
101 }
102
103 /// Mark runs that carry no role yet. A role is a single slot, so the innermost — the more
104 /// specific — wins; org nests two role-bearing markups (`_+both+_`) rarely enough that a
105 /// set is not worth the public API.
106 private func applying(_ role: OrgRole, to string: AttributedString) -> AttributedString {
107 var result = string
108 for range in result.runs.map(\.range) where result[range][OrgRoleAttribute.self] == nil {
109 result[range][OrgRoleAttribute.self] = role
110 }
111 return result
112 }
113
84 /// Flatten a whole document to attributed paragraphs — a convenience for callers that 114 /// 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). 115 /// want text without building block views (a share sheet, a plain-text export).
86 public func paragraphs(_ document: OrgDocument) -> [AttributedString] { 116 public func paragraphs(_ document: OrgDocument) -> [AttributedString] {
@@ -93,21 +123,6 @@ public struct OrgAttributedStringRenderer: Sendable {
93 } 123 }
94 } 124 }
95 125
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} 126}
112 127
113// MARK: - Custom attribute 128// MARK: - Custom attribute
Sources/OrgSwift/AST/OrgFootnotes.swift added +57
@@ -0,0 +1,57 @@
1import Foundation
2
3/// Assigns footnote numbers in first-reference order and holds the notes they resolve to.
4///
5/// Numbering is document-wide and order-of-use, so it belongs to the render pass rather than
6/// the parse: a renderer walks the tree, calls ``number(for:inline:)`` as it meets each
7/// reference, then reads ``notes`` to emit the section. It is output-format agnostic —
8/// the HTML renderer and the SwiftUI one share it.
9package struct OrgFootnotes {
10 private var numbers: [String: Int] = [:]
11 private var order: [String] = []
12 /// Reference-style definitions, gathered from the document's `[fn:x] …` lines.
13 private var definitions: [String: [OrgObject]] = [:]
14 /// Inline definitions, gathered from `[fn:x:text]` references as they are rendered.
15 private var inlineDefinitions: [String: [OrgObject]] = [:]
16 package var figureNumber = 0
17
18 package init(document: OrgDocument) {
19 for element in document.elements {
20 if case .footnoteDefinition(let label, let content) = element {
21 definitions[label] = content
22 }
23 }
24 }
25
26 package mutating func number(for label: String, inline: [OrgObject]? = nil) -> Int {
27 if let inline, inlineDefinitions[label] == nil { inlineDefinitions[label] = inline }
28 if let existing = numbers[label] { return existing }
29 let next = order.count + 1
30 numbers[label] = next
31 order.append(label)
32 return next
33 }
34
35 /// One numbered note, resolved to its content.
36 package struct Note {
37 package var number: Int
38 package var label: String
39 package var content: [OrgObject]
40 /// An inline footnote's text sits directly in the item; a reference-style definition
41 /// is a paragraph, matching org's exporter.
42 package var isInline: Bool
43 }
44
45 /// The referenced notes, in numbering order. Empty when the document has none, which is
46 /// the renderer's cue to omit the section entirely.
47 package var notes: [Note] {
48 order.map { label in
49 Note(
50 number: numbers[label] ?? 0,
51 label: label,
52 content: inlineDefinitions[label] ?? definitions[label] ?? [],
53 isInline: inlineDefinitions[label] != nil
54 )
55 }
56 }
57}
Sources/OrgSwift/AST/OrgHTMLTreeRenderer.swift +28 −75
@@ -5,7 +5,11 @@ import Foundation
5/// The point of the prototype: this produces the same shape of output as the shipped 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 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. 7/// renderer (see ``OrgAttributedStringRenderer``) can share the parse instead of re-deriving it.
8public struct OrgHTMLTreeRenderer: Sendable { 8///
9/// Not `Sendable`: it holds a ``CodeHighlighter``, and a real one wraps a thread-confined
10/// engine (Highlightr's JavaScriptCore context, say). Sending the renderer across isolation
11/// domains would call that engine off its thread. Make one where you render.
12public struct OrgHTMLTreeRenderer {
9 /// The same options the shipped ``OrgRenderer`` takes, so this renderer is a drop-in for it. 13 /// The same options the shipped ``OrgRenderer`` takes, so this renderer is a drop-in for it.
10 public var options: OrgRenderOptions 14 public var options: OrgRenderOptions
11 public var highlighter: CodeHighlighter 15 public var highlighter: CodeHighlighter
@@ -16,10 +20,10 @@ public struct OrgHTMLTreeRenderer: Sendable {
16 } 20 }
17 21
18 public func render(_ document: OrgDocument) -> String { 22 public func render(_ document: OrgDocument) -> String {
19 var footnotes = FootnoteNumbering(document: document) 23 var footnotes = OrgFootnotes(document: document)
20 var html = metadataHeader(document) 24 var html = metadataHeader(document)
21 html += document.elements.map { element($0, &footnotes) }.joined() 25 html += document.elements.map { element($0, &footnotes) }.joined()
22 html += footnotes.renderSection(self) 26 html += renderFootnoteSection(&footnotes)
23 return html 27 return html
24 } 28 }
25 29
@@ -42,29 +46,12 @@ public struct OrgHTMLTreeRenderer: Sendable {
42 46
43 // MARK: - URL resolution 47 // MARK: - URL resolution
44 48
45 /// Resolve a link target to a safe `href`, or nil when it cannot be made safe. 49 /// Link and image resolution is shared with the other renderers; see ``OrgURLResolver``.
46 /// 50 private var resolver: OrgURLResolver { OrgURLResolver(options: options) }
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 51
65 // MARK: - Blocks 52 // MARK: - Blocks
66 53
67 private func element(_ element: OrgElement, _ notes: inout FootnoteNumbering) -> String { 54 private func element(_ element: OrgElement, _ notes: inout OrgFootnotes) -> String {
68 switch element { 55 switch element {
69 case .heading(let heading): 56 case .heading(let heading):
70 let level = min(6, max(1, heading.level + options.headingLevelOffset)) 57 let level = min(6, max(1, heading.level + options.headingLevelOffset))
@@ -149,7 +136,7 @@ public struct OrgHTMLTreeRenderer: Sendable {
149 } 136 }
150 } 137 }
151 138
152 private func renderList(_ list: OrgList, _ notes: inout FootnoteNumbering) -> String { 139 private func renderList(_ list: OrgList, _ notes: inout OrgFootnotes) -> String {
153 if list.kind == .description { 140 if list.kind == .description {
154 var html = "<dl>\n" 141 var html = "<dl>\n"
155 for item in list.items { 142 for item in list.items {
@@ -187,7 +174,7 @@ public struct OrgHTMLTreeRenderer: Sendable {
187 return html + "</\(tag)>\n" 174 return html + "</\(tag)>\n"
188 } 175 }
189 176
190 private func renderTable(_ table: OrgTable, _ notes: inout FootnoteNumbering) -> String { 177 private func renderTable(_ table: OrgTable, _ notes: inout OrgFootnotes) -> String {
191 var html = "<table>\n" 178 var html = "<table>\n"
192 var wroteHeader = false 179 var wroteHeader = false
193 var inBody = false 180 var inBody = false
@@ -218,7 +205,7 @@ public struct OrgHTMLTreeRenderer: Sendable {
218 /// The `<img>` for a figure, or nil when the source cannot be resolved to a safe URL — 205 /// 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. 206 /// in which case the caller falls back to text rather than pointing at something unsafe.
220 private func imageTag(_ figure: OrgFigure, caption: [OrgObject]?) -> String? { 207 private func imageTag(_ figure: OrgFigure, caption: [OrgObject]?) -> String? {
221 guard let source = imageSource(figure.source) else { return nil } 208 guard let source = resolver.imageSource(figure.source) else { return nil }
222 let alt = figure.alt ?? caption.map { plainText($0) } ?? "" 209 let alt = figure.alt ?? caption.map { plainText($0) } ?? ""
223 var html = #"<img src="\#(source)" alt="\#(escapeHTMLAttribute(alt))""# 210 var html = #"<img src="\#(source)" alt="\#(escapeHTMLAttribute(alt))""#
224 for (key, value) in figure.attributes where key != "alt" { 211 for (key, value) in figure.attributes where key != "alt" {
@@ -229,7 +216,7 @@ public struct OrgHTMLTreeRenderer: Sendable {
229 216
230 // MARK: - Inline 217 // MARK: - Inline
231 218
232 func renderInline(_ objects: [OrgObject], _ notes: inout FootnoteNumbering) -> String { 219 func renderInline(_ objects: [OrgObject], _ notes: inout OrgFootnotes) -> String {
233 var html = "" 220 var html = ""
234 for object in objects { 221 for object in objects {
235 switch object { 222 switch object {
@@ -258,9 +245,9 @@ public struct OrgHTMLTreeRenderer: Sendable {
258 html += ##"<sup class="footnote-ref"><a id="fnr-\##(number)" href="#fn-\##(number)">\##(number)</a></sup>"## 245 html += ##"<sup class="footnote-ref"><a id="fnr-\##(number)" href="#fn-\##(number)">\##(number)</a></sup>"##
259 case .link(let link): 246 case .link(let link):
260 let text = link.description.map { renderInline($0, &notes) } 247 let text = link.description.map { renderInline($0, &notes) }
261 ?? escapeHTML(displayValue(link.target)) 248 ?? escapeHTML(resolver.displayValue(link.target))
262 // An unsafe or unresolvable target degrades to its text, never a bad anchor. 249 // An unsafe or unresolvable target degrades to its text, never a bad anchor.
263 if let href = href(for: link.target) { 250 if let href = resolver.href(for: link.target) {
264 html += #"<a href="\#(href)">\#(text)</a>"# 251 html += #"<a href="\#(href)">\#(text)</a>"#
265 } else { 252 } else {
266 html += text 253 html += text
@@ -270,14 +257,6 @@ public struct OrgHTMLTreeRenderer: Sendable {
270 return html 257 return html
271 } 258 }
272 259
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. 260 /// Inline objects reduced to plain text, for an `alt` attribute.
282 func plainText(_ objects: [OrgObject]) -> String { 261 func plainText(_ objects: [OrgObject]) -> String {
283 objects.map { object in 262 objects.map { object in
@@ -286,7 +265,7 @@ public struct OrgHTMLTreeRenderer: Sendable {
286 case .verbatim(let text), .code(let text): return text 265 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): 266 case .bold(let c), .italic(let c), .underline(let c), .strikeThrough(let c), .superscript(let c):
288 return plainText(c) 267 return plainText(c)
289 case .link(let link): return link.description.map { plainText($0) } ?? displayValue(link.target) 268 case .link(let link): return link.description.map { plainText($0) } ?? resolver.displayValue(link.target)
290 case .timestamp(let stamp): return stamp.displayValue 269 case .timestamp(let stamp): return stamp.displayValue
291 case .image(let figure): return figure.alt ?? "" 270 case .image(let figure): return figure.alt ?? ""
292 case .footnoteRef, .lineBreak: return "" 271 case .footnoteRef, .lineBreak: return ""
@@ -295,47 +274,21 @@ public struct OrgHTMLTreeRenderer: Sendable {
295 } 274 }
296} 275}
297 276
298// MARK: - Footnote numbering 277// MARK: - Footnote section
299
300/// Assigns footnote numbers in first-reference order and renders the notes section.
301struct 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 278
327 mutating func renderSection(_ renderer: OrgHTMLTreeRenderer) -> String { 279private extension OrgHTMLTreeRenderer {
328 guard !order.isEmpty else { return "" } 280 /// The `<section class="footnotes">` org's exporter puts at the end of a document.
281 func renderFootnoteSection(_ notes: inout OrgFootnotes) -> String {
282 let collected = notes.notes
283 guard !collected.isEmpty else { return "" }
329 var html = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n" 284 var html = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n"
330 for label in order { 285 for note in collected {
331 let n = numbers[label] ?? 0 286 let n = note.number
332 let back = ##"<a class="footnote-back" href="#fnr-\##(n)" aria-label="Back to reference \##(n)">&#8617;</a>"## 287 let back = ##"<a class="footnote-back" href="#fnr-\##(n)" aria-label="Back to reference \##(n)">&#8617;</a>"##
333 // An inline footnote's text sits directly in the item; a reference-style 288 let body = renderInline(note.content, &notes)
334 // definition is a paragraph, matching org's exporter. 289 if note.isInline {
335 if let inline = inlineDefinitions[label] { 290 html += "<li id=\"fn-\(n)\">\(body) \(back)</li>\n"
336 html += "<li id=\"fn-\(n)\">\(renderer.renderInline(inline, &self)) \(back)</li>\n"
337 } else { 291 } else {
338 let body = definitions[label].map { renderer.renderInline($0, &self) } ?? ""
339 html += "<li id=\"fn-\(n)\"><p>\(body)</p>\n \(back)</li>\n" 292 html += "<li id=\"fn-\(n)\"><p>\(body)</p>\n \(back)</li>\n"
340 } 293 }
341 } 294 }
Sources/OrgSwift/AST/OrgURLResolver.swift added +40
@@ -0,0 +1,40 @@
1import Foundation
2
3/// Resolves a link target or image source to a safe absolute URL string, or nil when it
4/// cannot be made safe.
5///
6/// Because the tree keeps targets typed, resolution is a renderer concern applied to the
7/// `.file` case only — no resolver closure has to be threaded through the parse. Both
8/// renderers resolve identically, so a link that degrades to text in HTML degrades to text
9/// natively too.
10package struct OrgURLResolver: Sendable {
11 package var options: OrgRenderOptions
12
13 package init(options: OrgRenderOptions) {
14 self.options = options
15 }
16
17 package func href(for target: OrgLinkTarget) -> String? {
18 switch target {
19 case .id(let identifier):
20 return sanitizedReadmeLinkURLString("#\(identifier)")
21 case .external(let url):
22 return sanitizedReadmeLinkURLString(url)
23 case .file(let path):
24 return sanitizedReadmeLinkURLString(options.linkURLResolver()?(path) ?? path)
25 }
26 }
27
28 package func imageSource(_ source: String) -> String? {
29 sanitizedReadmeImageURLString(options.imageURLResolver()?(source) ?? source)
30 }
31
32 /// The text a target shows when it carries no description.
33 package func displayValue(_ target: OrgLinkTarget) -> String {
34 switch target {
35 case .external(let url): return url
36 case .file(let path): return path
37 case .id(let identifier): return identifier
38 }
39 }
40}
Sources/OrgSwift/OrgRenderer.swift +1 −1
@@ -19,7 +19,7 @@ public struct PlainCodeHighlighter: CodeHighlighter {
19/// sources are rewritten to `{host}/{owner}/{repositoryName}/blob/{ref}/...`. 19/// sources are rewritten to `{host}/{owner}/{repositoryName}/blob/{ref}/...`.
20/// When either is nil, relative links are left as-is (and dropped by the 20/// When either is nil, relative links are left as-is (and dropped by the
21/// URL-scheme allowlist, which only permits absolute http/https/mailto). 21/// URL-scheme allowlist, which only permits absolute http/https/mailto).
22public struct OrgRenderOptions { 22public struct OrgRenderOptions: Sendable {
23 /// Host used to build absolute URLs for repository-relative links. 23 /// Host used to build absolute URLs for repository-relative links.
24 public var host: String 24 public var host: String
25 /// Repository owner (e.g. `~ccleberg`). Nil disables relative-link resolution. 25 /// Repository owner (e.g. `~ccleberg`). Nil disables relative-link resolution.
Sources/OrgSwiftUI/OrgBlockView.swift added +108
@@ -0,0 +1,108 @@
1import OrgSwift
2import SwiftUI
3
4/// Renders prepared blocks. All the org work happened in ``OrgLayoutBuilder``; this is
5/// layout only.
6struct OrgBlockListView: View {
7 let blocks: [OrgBlock]
8
9 var body: some View {
10 VStack(alignment: .leading, spacing: 12) {
11 ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
12 OrgBlockView(block: block)
13 }
14 }
15 .frame(maxWidth: .infinity, alignment: .leading)
16 }
17}
18
19struct OrgBlockView: View {
20 let block: OrgBlock
21
22 var body: some View {
23 switch block {
24 case .heading(let text, let level):
25 Text(text)
26 .padding(.top, level <= 2 ? 8 : 2)
27 .fixedSize(horizontal: false, vertical: true)
28
29 case .paragraph(let text):
30 Text(text)
31 .fixedSize(horizontal: false, vertical: true)
32
33 case .code(let code):
34 ScrollView(.horizontal) {
35 Text(code)
36 .textSelection(.enabled)
37 .padding(10)
38 }
39 .background(
40 Color.secondary.opacity(0.12),
41 in: RoundedRectangle(cornerRadius: 4)
42 )
43
44 case .quote(let blocks):
45 HStack(spacing: 10) {
46 RoundedRectangle(cornerRadius: 2)
47 .fill(.tertiary)
48 .frame(width: 3)
49 OrgBlockListView(blocks: blocks)
50 .foregroundStyle(.secondary)
51 }
52 .fixedSize(horizontal: false, vertical: true)
53
54 case .center(let blocks):
55 OrgBlockListView(blocks: blocks)
56 .multilineTextAlignment(.center)
57 .frame(maxWidth: .infinity, alignment: .center)
58
59 case .verse(let lines):
60 // Verse keeps its line breaks; that is the whole point of the block.
61 VStack(alignment: .leading, spacing: 0) {
62 ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
63 Text(line).fixedSize(horizontal: false, vertical: true)
64 }
65 }
66
67 case .special(_, let blocks):
68 OrgBlockListView(blocks: blocks)
69
70 case .list(let list):
71 OrgListView(list: list)
72
73 case .table(let table):
74 OrgTableView(table: table)
75
76 case .image(let source, let alt, let caption):
77 VStack(alignment: .leading, spacing: 4) {
78 AsyncImage(url: source) { phase in
79 switch phase {
80 case .success(let image):
81 image.resizable().scaledToFit()
82 case .failure:
83 Text(alt.isEmpty ? source.lastPathComponent : alt)
84 .font(.footnote)
85 .foregroundStyle(.secondary)
86 default:
87 ProgressView()
88 }
89 }
90 .accessibilityLabel(alt)
91 if let caption {
92 Text(caption).foregroundStyle(.secondary)
93 }
94 }
95
96 case .captioned(let caption, let blocks):
97 VStack(alignment: .leading, spacing: 4) {
98 OrgBlockListView(blocks: blocks)
99 if let caption {
100 Text(caption).foregroundStyle(.secondary)
101 }
102 }
103
104 case .rule:
105 Divider()
106 }
107 }
108}
Sources/OrgSwiftUI/OrgCodeStyler.swift added +20
@@ -0,0 +1,20 @@
1import SwiftUI
2
3/// Syntax highlighting for `#+begin_src` blocks, supplied by the app.
4///
5/// The native counterpart to ``CodeHighlighter``: that one returns HTML for the HTML
6/// renderer, this returns an `AttributedString`. Keeping it a protocol is what lets
7/// OrgSwiftUI stay dependency-free — a highlighting engine is the app's business.
8///
9/// Returning nil means "show it as plain monospaced text", which is a fine outcome for an
10/// unknown language, never an error.
11@MainActor
12public protocol OrgCodeStyler {
13 func highlighted(code: String, language: String?) -> AttributedString?
14}
15
16/// The default: no highlighting.
17public struct PlainOrgCodeStyler: OrgCodeStyler {
18 nonisolated public init() {}
19 public func highlighted(code _: String, language _: String?) -> AttributedString? { nil }
20}
Sources/OrgSwiftUI/OrgInlineStyler.swift added +84
@@ -0,0 +1,84 @@
1import OrgSwift
2import SwiftUI
3
4/// Turns a run of inline org objects into a styled `AttributedString`.
5///
6/// The parse and the attribute intents come from ``OrgAttributedStringRenderer``; this adds
7/// only presentation. Everything is expressed in semantic `Font.TextStyle`s and semantic
8/// colors, so the result scales with Dynamic Type and follows light/dark on its own.
9struct OrgInlineStyler {
10 var renderer: OrgAttributedStringRenderer
11
12 init(options: OrgRenderOptions) {
13 renderer = OrgAttributedStringRenderer(options: options)
14 }
15
16 /// - Parameter style: the text style of the block this run sits in, so emphasis inside a
17 /// heading bolds the heading's font rather than the body font.
18 func attributed(
19 _ objects: [OrgObject], _ notes: inout OrgFootnotes, style: Font.TextStyle = .body
20 ) -> AttributedString {
21 var string = renderer.inline(objects, &notes)
22
23 for range in string.runs.map(\.range) {
24 let intents = string[range].inlinePresentationIntent ?? []
25 let role = string[range][OrgRoleAttribute.self]
26
27 var textStyle = style
28 var monospaced = false
29 var bold = false
30 var italic = false
31
32 if intents.contains(.stronglyEmphasized) { bold = true }
33 if intents.contains(.emphasized) { italic = true }
34 if intents.contains(.code) {
35 monospaced = true
36 string[range].backgroundColor = .secondary.opacity(0.12)
37 }
38
39 switch role {
40 case .underline:
41 string[range].underlineStyle = .single
42 case .strikeThrough:
43 string[range].strikethroughStyle = .single
44 case .superscript, .footnoteReference:
45 // No portable superscript attribute, so raise it and step the style down.
46 textStyle = Self.smaller(textStyle)
47 string[range].baselineOffset = 5
48 if role == .footnoteReference { string[range].foregroundColor = .accentColor }
49 case .timestamp:
50 string[range].foregroundColor = .secondary
51 case .image:
52 // An inline image inside a paragraph shows its alt text; the block-level
53 // `.figure` case is where an image is actually loaded.
54 italic = true
55 string[range].foregroundColor = .secondary
56 case nil:
57 break
58 }
59
60 if bold || italic || monospaced || textStyle != style {
61 var font = Font.system(textStyle, design: monospaced ? .monospaced : .default)
62 if bold { font = font.bold() }
63 if italic { font = font.italic() }
64 string[range].font = font
65 }
66 }
67 return string
68 }
69
70 /// One step down the semantic scale, for superscripts and footnote markers.
71 private static func smaller(_ style: Font.TextStyle) -> Font.TextStyle {
72 switch style {
73 case .largeTitle: .title
74 case .title: .title2
75 case .title2: .title3
76 case .title3: .headline
77 case .headline, .body: .footnote
78 case .callout: .caption
79 case .subheadline: .caption
80 case .footnote, .caption, .caption2: .caption2
81 @unknown default: .caption
82 }
83 }
84}
Sources/OrgSwiftUI/OrgLayout.swift added +77
@@ -0,0 +1,77 @@
1import OrgSwift
2import SwiftUI
3
4/// A document prepared for display: inline runs already styled, footnotes already numbered,
5/// image URLs already resolved, code already highlighted.
6///
7/// The walk happens once, ahead of `body`, because three things it does must not happen per
8/// layout pass — footnote numbering is sequential and document-wide, syntax highlighting is
9/// expensive, and both would otherwise re-run every time SwiftUI recomputes a view.
10struct OrgLayout {
11 var blocks: [OrgBlock] = []
12 var footnotes: [OrgFootnote] = []
13}
14
15struct OrgFootnote: Identifiable {
16 var number: Int
17 var text: AttributedString
18 var id: Int { number }
19}
20
21/// A block ready to render. Mirrors ``OrgElement`` minus the cases that resolve away:
22/// footnote definitions move to ``OrgLayout/footnotes``, and an export block for another
23/// backend drops entirely.
24indirect enum OrgBlock: Identifiable {
25 case heading(text: AttributedString, level: Int)
26 case paragraph(AttributedString)
27 case code(AttributedString)
28 case quote([OrgBlock])
29 case center([OrgBlock])
30 case verse([AttributedString])
31 case special(name: String, blocks: [OrgBlock])
32 case list(OrgListLayout)
33 case table(OrgTableLayout)
34 case image(source: URL, alt: String, caption: AttributedString?)
35 case captioned(caption: AttributedString?, blocks: [OrgBlock])
36 case rule
37
38 /// Positional identity. Blocks are rebuilt wholesale when the source changes, so their
39 /// index in the layout is stable for as long as the layout is.
40 var id: String { String(describing: self).prefix(64).description }
41}
42
43struct OrgListLayout {
44 var kind: OrgListKind
45 var items: [Item]
46
47 struct Item: Identifiable {
48 var checkbox: OrgCheckbox?
49 var term: AttributedString?
50 var paragraphs: [AttributedString]
51 var sublist: OrgListLayout?
52 var id = UUID()
53 }
54}
55
56struct OrgTableLayout {
57 /// Cell rows only; org's `|---|` rule rows are structure, carried by ``headerRowCount``.
58 var rows: [Row]
59 var headerRowCount: Int
60 var alignments: [OrgAlignment?]
61
62 struct Row: Identifiable {
63 var cells: [Cell]
64 var id = UUID()
65 }
66
67 struct Cell: Identifiable {
68 var text: AttributedString
69 var id = UUID()
70 }
71
72 var columnCount: Int { rows.map(\.cells.count).max() ?? 0 }
73
74 func alignment(column: Int) -> OrgAlignment? {
75 column < alignments.count ? alignments[column] : nil
76 }
77}
Sources/OrgSwiftUI/OrgLayoutBuilder.swift added +177
@@ -0,0 +1,177 @@
1import OrgSwift
2import SwiftUI
3
4/// Walks an ``OrgDocument`` into an ``OrgLayout``.
5///
6/// The third renderer over the shared tree, alongside HTML and `AttributedString`. It
7/// resolves URLs through the same ``OrgURLResolver`` and numbers footnotes through the same
8/// ``OrgFootnotes``, so a link that works in the HTML output works here and note 1 is note 1
9/// in both.
10@MainActor
11struct OrgLayoutBuilder {
12 var options: OrgRenderOptions
13 var styler: OrgCodeStyler
14
15 private var inline: OrgInlineStyler { OrgInlineStyler(options: options) }
16 private var resolver: OrgURLResolver { OrgURLResolver(options: options) }
17
18 func layout(_ document: OrgDocument) -> OrgLayout {
19 var notes = OrgFootnotes(document: document)
20 var layout = OrgLayout()
21 layout.blocks = document.elements.flatMap { block($0, &notes) }
22 // Numbering is complete only after the whole body is walked, so the notes section is
23 // built last — exactly as the HTML renderer does it.
24 layout.footnotes = notes.notes.map {
25 OrgFootnote(number: $0.number, text: text($0.content, &notes, style: .footnote))
26 }
27 return layout
28 }
29
30 private func text(
31 _ objects: [OrgObject], _ notes: inout OrgFootnotes, style: Font.TextStyle = .body
32 ) -> AttributedString {
33 inline.attributed(objects, &notes, style: style)
34 }
35
36 /// One element to zero or more blocks. Zero for a footnote definition (it moves to the
37 /// notes section) and for an export block aimed at another backend.
38 private func block(_ element: OrgElement, _ notes: inout OrgFootnotes) -> [OrgBlock] {
39 switch element {
40 case .heading(let heading):
41 var title = AttributedString()
42 if let todo = heading.todo {
43 var keyword = AttributedString(todo + " ")
44 keyword.foregroundColor = todo == "DONE" ? .green : .orange
45 title += keyword
46 }
47 if let priority = heading.priority {
48 var cookie = AttributedString("[#\(priority)] ")
49 cookie.foregroundColor = .secondary
50 title += cookie
51 }
52 let level = min(6, max(1, heading.level + options.headingLevelOffset))
53 title += text(heading.title, &notes, style: Self.headingStyle(level))
54 for tag in heading.tags {
55 var chip = AttributedString(" \(tag)")
56 chip.foregroundColor = .secondary
57 chip.font = .system(.caption, design: .monospaced)
58 title += chip
59 }
60 return [.heading(text: title, level: level)]
61
62 case .paragraph(let objects):
63 return [.paragraph(text(objects, &notes))]
64
65 case .list(let list):
66 return [.list(listLayout(list, &notes))]
67
68 case .table(let table):
69 return [.table(tableLayout(table, &notes))]
70
71 case .srcBlock(let language, let code):
72 return [.code(styled(code: code, language: language))]
73
74 case .exampleBlock(let raw):
75 return [.code(styled(code: raw, language: nil))]
76
77 case .quoteBlock(let children):
78 return [.quote(children.flatMap { block($0, &notes) })]
79
80 case .centerBlock(let children):
81 return [.center(children.flatMap { block($0, &notes) })]
82
83 case .verseBlock(let lines):
84 return [.verse(lines.map { text($0, &notes) })]
85
86 case .specialBlock(let name, let children):
87 return [.special(name: name, blocks: children.flatMap { block($0, &notes) })]
88
89 case .exportBlock(let backend, let raw):
90 // HTML passes through verbatim in the HTML renderer; natively there is nothing to
91 // pass it to, so it shows as the literal block it is rather than vanishing.
92 return backend == "html" ? [.code(styled(code: raw, language: nil))] : []
93
94 case .figure(let figure):
95 return [imageBlock(figure, &notes)]
96
97 case .captioned(_, let caption, let content):
98 return [.captioned(
99 caption: caption.map { text($0, &notes, style: .footnote) },
100 blocks: block(content, &notes)
101 )]
102
103 case .horizontalRule:
104 return [.rule]
105
106 case .footnoteDefinition:
107 return [] // collected into the notes section
108 }
109 }
110
111 /// An image whose source will not resolve to a safe absolute URL degrades to its alt
112 /// text, the same trade the HTML renderer makes rather than pointing at something unsafe.
113 ///
114 /// The absolute requirement is the one place this diverges from HTML, which may emit a
115 /// relative `src` for a document base URL to resolve. A view has no base, so a relative
116 /// source is unloadable and showing the text beats showing a broken image.
117 private func imageBlock(_ figure: OrgFigure, _ notes: inout OrgFootnotes) -> OrgBlock {
118 let caption = figure.caption.map { text($0, &notes, style: .footnote) }
119 guard let string = resolver.imageSource(figure.source),
120 let url = URL(string: string), url.scheme != nil else {
121 return .paragraph(AttributedString(figure.alt ?? figure.source))
122 }
123 let alt = figure.alt ?? figure.caption.map { OrgParser.plain($0) } ?? ""
124 return .image(source: url, alt: alt, caption: caption)
125 }
126
127 private func styled(code: String, language: String?) -> AttributedString {
128 if let highlighted = styler.highlighted(code: code, language: language) {
129 return highlighted
130 }
131 var plain = AttributedString(code)
132 plain.font = .system(.footnote, design: .monospaced)
133 return plain
134 }
135
136 private func listLayout(_ list: OrgList, _ notes: inout OrgFootnotes) -> OrgListLayout {
137 var items: [OrgListLayout.Item] = []
138 for item in list.items {
139 items.append(OrgListLayout.Item(
140 checkbox: item.checkbox,
141 term: item.term.map { term in
142 var styled = text(term, &notes)
143 styled.font = .body.bold()
144 return styled
145 },
146 paragraphs: item.content.map { text($0, &notes) },
147 sublist: item.sublist.map { listLayout($0, &notes) }
148 ))
149 }
150 return OrgListLayout(kind: list.kind, items: items)
151 }
152
153 private func tableLayout(_ table: OrgTable, _ notes: inout OrgFootnotes) -> OrgTableLayout {
154 var rows: [OrgTableLayout.Row] = []
155 for row in table.rows {
156 guard case .cells(let cells) = row else { continue }
157 rows.append(OrgTableLayout.Row(cells: cells.map {
158 OrgTableLayout.Cell(text: text($0, &notes, style: .subheadline))
159 }))
160 }
161 return OrgTableLayout(
162 rows: rows,
163 headerRowCount: table.headerRowCount,
164 alignments: table.alignments
165 )
166 }
167
168 private static func headingStyle(_ level: Int) -> Font.TextStyle {
169 switch level {
170 case 1: .title
171 case 2: .title2
172 case 3: .title3
173 case 4: .headline
174 default: .subheadline
175 }
176 }
177}
Sources/OrgSwiftUI/OrgListView.swift added +88
@@ -0,0 +1,88 @@
1import OrgSwift
2import SwiftUI
3
4/// A list, recursing into its own sublists.
5///
6/// SwiftUI has no list-layout primitive, so the marker sits in its own column and the content
7/// hangs beside it — wrapped lines line up under the text, not under the bullet.
8struct OrgListView: View {
9 let list: OrgListLayout
10 /// Ordered numbering restarts at each nesting level, as in org.
11 var body: some View {
12 VStack(alignment: .leading, spacing: 6) {
13 ForEach(Array(list.items.enumerated()), id: \.element.id) { index, item in
14 if list.kind == .description {
15 descriptionItem(item)
16 } else {
17 markedItem(item, marker: marker(at: index))
18 }
19 }
20 }
21 }
22
23 private func marker(at index: Int) -> String {
24 list.kind == .ordered ? "\(index + 1)." : "•"
25 }
26
27 private func markedItem(_ item: OrgListLayout.Item, marker: String) -> some View {
28 HStack(alignment: .firstTextBaseline, spacing: 8) {
29 Text(marker)
30 .monospacedDigit()
31 .foregroundStyle(.secondary)
32 VStack(alignment: .leading, spacing: 6) {
33 if let checkbox = item.checkbox {
34 HStack(alignment: .firstTextBaseline, spacing: 6) {
35 Image(systemName: Self.symbol(for: checkbox))
36 .foregroundStyle(checkbox == .on ? Color.accentColor : .secondary)
37 .accessibilityLabel(Self.label(for: checkbox))
38 paragraphs(item)
39 }
40 } else {
41 paragraphs(item)
42 }
43 if let sublist = item.sublist {
44 OrgListView(list: sublist)
45 }
46 }
47 }
48 }
49
50 /// `term :: definition` — the term above, the definition indented under it.
51 private func descriptionItem(_ item: OrgListLayout.Item) -> some View {
52 VStack(alignment: .leading, spacing: 2) {
53 if let term = item.term {
54 Text(term).fixedSize(horizontal: false, vertical: true)
55 }
56 paragraphs(item).padding(.leading, 16)
57 if let sublist = item.sublist {
58 OrgListView(list: sublist).padding(.leading, 16)
59 }
60 }
61 }
62
63 private func paragraphs(_ item: OrgListLayout.Item) -> some View {
64 VStack(alignment: .leading, spacing: 6) {
65 ForEach(Array(item.paragraphs.enumerated()), id: \.offset) { _, paragraph in
66 Text(paragraph)
67 .fixedSize(horizontal: false, vertical: true)
68 .frame(maxWidth: .infinity, alignment: .leading)
69 }
70 }
71 }
72
73 private static func symbol(for checkbox: OrgCheckbox) -> String {
74 switch checkbox {
75 case .on: "checkmark.square.fill"
76 case .off: "square"
77 case .partial: "minus.square"
78 }
79 }
80
81 private static func label(for checkbox: OrgCheckbox) -> String {
82 switch checkbox {
83 case .on: "Done"
84 case .off: "Not done"
85 case .partial: "Partly done"
86 }
87 }
88}
Sources/OrgSwiftUI/OrgTableView.swift added +56
@@ -0,0 +1,56 @@
1import OrgSwift
2import SwiftUI
3
4/// A table, in a `Grid` that scrolls sideways when it is wider than the screen.
5///
6/// Column alignment comes from org's `|---+:---:+---|` separator row. `Grid` takes it from
7/// one cell per column, so it is set on the first row and inherited down.
8struct OrgTableView: View {
9 let table: OrgTableLayout
10
11 var body: some View {
12 ScrollView(.horizontal, showsIndicators: false) {
13 Grid(alignment: .topLeading, horizontalSpacing: 14, verticalSpacing: 7) {
14 ForEach(Array(table.rows.enumerated()), id: \.element.id) { index, row in
15 GridRow {
16 ForEach(0 ..< table.columnCount, id: \.self) { column in
17 cell(row, column: column, isHeader: index < table.headerRowCount)
18 }
19 }
20 // The rule row separating the header band from the body.
21 if index + 1 == table.headerRowCount {
22 Divider().gridCellColumns(table.columnCount)
23 }
24 }
25 }
26 .padding(.vertical, 2)
27 }
28 }
29
30 @ViewBuilder
31 private func cell(_ row: OrgTableLayout.Row, column: Int, isHeader: Bool) -> some View {
32 // Org tolerates ragged rows; a missing cell is empty, not a crash.
33 let text = column < row.cells.count ? row.cells[column].text : AttributedString()
34 Text(text)
35 .fontWeight(isHeader ? .semibold : .regular)
36 .multilineTextAlignment(Self.textAlignment(table.alignment(column: column)))
37 .fixedSize(horizontal: false, vertical: true)
38 .gridColumnAlignment(Self.gridAlignment(table.alignment(column: column)))
39 }
40
41 private static func gridAlignment(_ alignment: OrgAlignment?) -> HorizontalAlignment {
42 switch alignment {
43 case .center: .center
44 case .right: .trailing
45 case .left, nil: .leading
46 }
47 }
48
49 private static func textAlignment(_ alignment: OrgAlignment?) -> TextAlignment {
50 switch alignment {
51 case .center: .center
52 case .right: .trailing
53 case .left, nil: .leading
54 }
55 }
56}
Sources/OrgSwiftUI/OrgView.swift added +70
@@ -0,0 +1,70 @@
1import OrgSwift
2import SwiftUI
3
4/// Renders org-mode as native SwiftUI.
5///
6/// ```swift
7/// OrgView(source, options: OrgRenderOptions(host: "gitbay.org", owner: "krz",
8/// repositoryName: "org-swift"))
9/// ```
10///
11/// The parse, footnote numbering, URL resolution and syntax highlighting run once off
12/// `body`, in a task keyed to the source, so scrolling and Dynamic Type changes do not
13/// re-derive them. Text is selectable, links open through the environment's `openURL`, and
14/// everything scales with Dynamic Type.
15public struct OrgView: View {
16 private let source: String
17 private let options: OrgRenderOptions
18 private let styler: OrgCodeStyler
19
20 @State private var layout = OrgLayout()
21
22 public init(
23 _ source: String,
24 options: OrgRenderOptions = .init(),
25 styler: OrgCodeStyler = PlainOrgCodeStyler()
26 ) {
27 self.source = source
28 self.options = options
29 self.styler = styler
30 }
31
32 public var body: some View {
33 VStack(alignment: .leading, spacing: 12) {
34 OrgBlockListView(blocks: layout.blocks)
35 if !layout.footnotes.isEmpty {
36 OrgFootnotesView(footnotes: layout.footnotes)
37 }
38 }
39 .frame(maxWidth: .infinity, alignment: .leading)
40 .textSelection(.enabled)
41 .task(id: source) {
42 layout = OrgLayoutBuilder(options: options, styler: styler)
43 .layout(OrgParser.parse(source))
44 }
45 }
46}
47
48/// The notes section org's exporter puts at the end of a document.
49struct OrgFootnotesView: View {
50 let footnotes: [OrgFootnote]
51
52 var body: some View {
53 VStack(alignment: .leading, spacing: 6) {
54 Divider()
55 ForEach(footnotes) { note in
56 HStack(alignment: .firstTextBaseline, spacing: 6) {
57 Text("\(note.number).")
58 .font(.footnote)
59 .monospacedDigit()
60 .foregroundStyle(.secondary)
61 Text(note.text)
62 .fixedSize(horizontal: false, vertical: true)
63 .frame(maxWidth: .infinity, alignment: .leading)
64 }
65 }
66 }
67 .padding(.top, 8)
68 .accessibilityLabel("Footnotes")
69 }
70}
Tests/OrgSwiftTests/OrgTreeTests.swift +62
@@ -122,6 +122,68 @@ struct ASTRendererTests {
122 #expect(linkRun?.link?.absoluteString == "https://example.com") 122 #expect(linkRun?.link?.absoluteString == "https://example.com")
123 } 123 }
124 124
125 /// The native renderer resolves and sanitizes exactly as the HTML one does, so a README
126 /// rendered natively gets the same working links rather than raw relative paths.
127 @Test
128 func attributedStringResolvesRelativeLinksAndRejectsUnsafeOnes() {
129 let renderer = OrgAttributedStringRenderer(options: OrgRenderOptions(
130 host: "gitbay.org",
131 owner: "krz",
132 repositoryName: "org-swift",
133 ref: "HEAD",
134 readmePath: "README.org"
135 ))
136
137 let relative = renderer.inline(OrgParser.parseInline("[[./ARCHITECTURE.md][notes]]"))
138 #expect(relative.runs.first { $0.link != nil }?.link?.absoluteString
139 == "https://gitbay.org/krz/org-swift/blob/HEAD/ARCHITECTURE.md")
140
141 // Without repository context there is nothing to resolve against, so an unsafe
142 // scheme reaches the allowlist and the link degrades to its text — as in HTML.
143 let unsafe = OrgAttributedStringRenderer().inline(OrgParser.parseInline("[[javascript:alert(1)][tap]]"))
144 #expect(String(unsafe.characters) == "tap")
145 #expect(unsafe.runs.allSatisfy { $0.link == nil })
146 #expect(!OrgRenderer.renderToHTML("[[javascript:alert(1)][tap]]").lowercased().contains("javascript:"))
147 }
148
149 /// Footnote numbering is document-wide and order-of-use, and now shared with HTML, so
150 /// both renderers agree on which note is number 1.
151 @Test
152 func attributedStringNumbersFootnotesInReferenceOrder() {
153 let doc = OrgParser.parse("""
154 First[fn:b] then second[fn:a] then first again[fn:b].
155
156 [fn:a] note a
157 [fn:b] note b
158 """)
159 guard case .paragraph(let objects) = doc.elements[0] else {
160 Issue.record("expected a paragraph"); return
161 }
162 var notes = OrgFootnotes(document: doc)
163 let text = String(OrgAttributedStringRenderer().inline(objects, &notes).characters)
164 #expect(text == "First[1] then second[2] then first again[1].")
165 #expect(notes.notes.map(\.label) == ["b", "a"])
166 }
167
168 /// Emphasis nests in org, and the HTML renderer nests it (`<strong>b <em>i</em></strong>`).
169 /// Assigning the intent over the whole range instead of merging would flatten the inner
170 /// one away.
171 @Test
172 func nestedEmphasisKeepsBothIntents() {
173 let attributed = OrgAttributedStringRenderer().inline(
174 OrgParser.parseInline("*bold /italic/ tail*")
175 )
176 func intent(_ text: String) -> InlinePresentationIntent? {
177 attributed.runs.first { String(attributed[$0.range].characters).contains(text) }?
178 .inlinePresentationIntent
179 }
180 #expect(intent("bold")?.contains(.stronglyEmphasized) == true)
181 #expect(intent("italic")?.contains(.stronglyEmphasized) == true)
182 #expect(intent("italic")?.contains(.emphasized) == true)
183 // The outer run is bold only.
184 #expect(intent("bold")?.contains(.emphasized) == false)
185 }
186
125 @Test 187 @Test
126 func attributedStringCarriesRolesForNonStandardIntents() { 188 func attributedStringCarriesRolesForNonStandardIntents() {
127 let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>") 189 let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>")
Tests/OrgSwiftUITests/OrgLayoutTests.swift added +193
@@ -0,0 +1,193 @@
1import Foundation
2import SwiftUI
3import Testing
4@testable import OrgSwift
5@testable import OrgSwiftUI
6
7/// Tests for the third renderer over the shared tree: `OrgDocument → OrgLayout`.
8///
9/// The views themselves are layout over this model, so the model is where the org behaviour
10/// lives and where it is worth asserting.
11@MainActor
12struct OrgLayoutTests {
13
14 private func layout(
15 _ source: String,
16 options: OrgRenderOptions = .init(),
17 styler: OrgCodeStyler = PlainOrgCodeStyler()
18 ) -> OrgLayout {
19 OrgLayoutBuilder(options: options, styler: styler).layout(OrgParser.parse(source))
20 }
21
22 private func plain(_ string: AttributedString) -> String { String(string.characters) }
23
24 @Test
25 func blocksCoverTheElementTree() {
26 let blocks = layout("""
27 * Heading
28
29 A paragraph.
30
31 - one
32 - two
33
34 | a | b |
35 |---+---|
36 | 1 | 2 |
37
38 #+begin_src swift
39 let x = 1
40 #+end_src
41
42 #+begin_quote
43 quoted
44 #+end_quote
45
46 -----
47 """).blocks
48
49 func has(_ predicate: (OrgBlock) -> Bool) -> Bool { blocks.contains(where: predicate) }
50 #expect(has { if case .heading = $0 { true } else { false } })
51 #expect(has { if case .paragraph = $0 { true } else { false } })
52 #expect(has { if case .list = $0 { true } else { false } })
53 #expect(has { if case .table = $0 { true } else { false } })
54 #expect(has { if case .code = $0 { true } else { false } })
55 #expect(has { if case .quote = $0 { true } else { false } })
56 #expect(has { if case .rule = $0 { true } else { false } })
57 }
58
59 /// Numbering is document-wide and the definitions leave the body, exactly as in HTML.
60 @Test
61 func footnotesAreNumberedAndMovedToTheSection() {
62 let result = layout("""
63 First[fn:b] and second[fn:a].
64
65 [fn:a] note a
66 [fn:b] note b
67 """)
68
69 #expect(result.footnotes.map(\.number) == [1, 2])
70 #expect(plain(result.footnotes[0].text) == "note b")
71 #expect(plain(result.footnotes[1].text) == "note a")
72
73 guard case .paragraph(let text) = result.blocks[0] else {
74 Issue.record("expected a paragraph"); return
75 }
76 #expect(plain(text) == "First[1] and second[2].")
77 // The definitions are not also blocks in the body.
78 #expect(result.blocks.count == 1)
79 }
80
81 @Test
82 func nestedListsRecurse() {
83 guard case .list(let list) = layout("""
84 - outer
85 - inner
86 - deepest
87 """).blocks.first else {
88 Issue.record("expected a list"); return
89 }
90 #expect(list.kind == .unordered)
91 #expect(plain(list.items[0].paragraphs[0]) == "outer")
92 let inner = list.items[0].sublist
93 #expect(plain(inner?.items[0].paragraphs[0] ?? "") == "inner")
94 #expect(plain(inner?.items[0].sublist?.items[0].paragraphs[0] ?? "") == "deepest")
95 }
96
97 @Test
98 func checkboxesAndDescriptionListsSurvive() {
99 guard case .list(let tasks) = layout("- [X] done\n- [ ] todo").blocks.first else {
100 Issue.record("expected a list"); return
101 }
102 #expect(tasks.items.map(\.checkbox) == [.on, .off])
103
104 guard case .list(let described) = layout("- term :: definition").blocks.first else {
105 Issue.record("expected a description list"); return
106 }
107 #expect(described.kind == .description)
108 #expect(plain(described.items[0].term ?? "") == "term")
109 #expect(plain(described.items[0].paragraphs[0]) == "definition")
110 }
111
112 /// Rule rows are structure, not content: they set the header band and then disappear.
113 @Test
114 func tableDropsRuleRowsAndKeepsAlignments() {
115 guard case .table(let table) = layout("""
116 | name | count |
117 |------+------:|
118 | a | 1 |
119 | b | 2 |
120 """).blocks.first else {
121 Issue.record("expected a table"); return
122 }
123 #expect(table.rows.count == 3)
124 #expect(table.headerRowCount == 1)
125 #expect(table.columnCount == 2)
126 #expect(table.alignment(column: 1) == .right)
127 #expect(plain(table.rows[0].cells[0].text) == "name")
128 #expect(plain(table.rows[2].cells[1].text) == "2")
129 }
130
131 @Test
132 func imagesResolveRelativeSourcesAndDegradeWhenUnsafe() {
133 let options = OrgRenderOptions(
134 host: "gitbay.org", owner: "krz", repositoryName: "org-swift",
135 ref: "HEAD", readmePath: "README.org", imagePathSegment: "raw"
136 )
137 guard case .image(let source, _, _) = layout("[[file:docs/logo.png]]", options: options)
138 .blocks.first else {
139 Issue.record("expected an image"); return
140 }
141 #expect(source.absoluteString == "https://gitbay.org/krz/org-swift/raw/HEAD/docs/logo.png")
142
143 // With no repository context the relative path cannot be made absolute, so it shows
144 // as text rather than as a broken image.
145 guard case .paragraph(let text) = layout("[[file:docs/logo.png]]").blocks.first else {
146 Issue.record("expected a paragraph fallback"); return
147 }
148 #expect(plain(text) == "docs/logo.png")
149 }
150
151 @Test
152 func codeStylerIsUsedWhenItAnswers() {
153 struct Stub: OrgCodeStyler {
154 func highlighted(code: String, language: String?) -> AttributedString? {
155 language == "swift" ? AttributedString("STYLED:\(code.count)") : nil
156 }
157 }
158 guard case .code(let styled) = layout(
159 "#+begin_src swift\nlet x = 1\n#+end_src", styler: Stub()
160 ).blocks.first else {
161 Issue.record("expected a code block"); return
162 }
163 #expect(plain(styled).hasPrefix("STYLED:"))
164
165 // An unknown language falls back to plain monospaced text, never to nothing.
166 guard case .code(let plainCode) = layout(
167 "#+begin_src brainfuck\n+++\n#+end_src", styler: Stub()
168 ).blocks.first else {
169 Issue.record("expected a code block"); return
170 }
171 #expect(plain(plainCode) == "+++")
172 }
173
174 /// HTML export has nowhere to go natively, so it shows as the literal block it is; other
175 /// backends drop, as they do in HTML.
176 @Test
177 func exportBlocksShowHTMLLiterallyAndDropOtherBackends() {
178 #expect(layout("#+begin_export html\n<b>hi</b>\n#+end_export").blocks.count == 1)
179 #expect(layout("#+begin_export latex\n\\emph{hi}\n#+end_export").blocks.isEmpty)
180 }
181
182 /// Emphasis nests, and the styler turns intents into fonts rather than dropping them.
183 @Test
184 func inlineStylingCarriesNestedEmphasis() {
185 guard case .paragraph(let text) = layout("*bold /italic/* and ~code~").blocks.first else {
186 Issue.record("expected a paragraph"); return
187 }
188 #expect(plain(text) == "bold italic and code")
189 // Every emphasised or code run got a concrete font; unstyled runs inherit the view's.
190 let fonts = text.runs.compactMap(\.font)
191 #expect(fonts.count >= 3)
192 }
193}