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
44output format is a walk over the result.
55
66```
7Sources/OrgSwift/
7Sources/OrgSwift/ Foundation only
88 AST/
99 OrgDocument.swift the element tree
1010 OrgParser.swift source → OrgDocument (blocks)
1111 OrgInlineParser.swift text → [OrgObject] (inlines)
1212 OrgHTMLTreeRenderer.swift OrgDocument → HTML
1313 OrgAttributedStringRenderer.swift [OrgObject] → AttributedString
14 OrgFootnotes.swift document-wide numbering, shared by renderers
15 OrgURLResolver.swift typed target → safe URL, shared by renderers
1416 OrgRenderer.swift the public entry point, plus OrgRenderOptions
1517 Escaping.swift, Links.swift, … shared helpers: escaping, URL sanitising and
1618 repository-relative resolution, line predicates
1719 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
1828```
1929
2030`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.
2939
3040With a tree, `OrgAttributedStringRenderer` is roughly 140 lines and shares the parse
3141entirely. 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.
3348
3449The types mirror orgo's `model.rs` — `OrgElement`/`OrgObject` against orgo's
3550`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
7085on activeness, so the new behaviour is the more correct one. It is asserted directly in
7186`joinsInactiveTimestampRanges`.
7287
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
73114## Next
74115
75A SwiftUI renderer belongs in a **separate product** depending on this one, so the parser and
76HTML renderer stay Foundation-only and callers who want HTML never import SwiftUI. Tables are
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.
116The corpus measures HTML only. Tree-level conformance would let `OrgLayout` be measured
117against the same goldens instead of only its own unit tests.
Package.swift +9 −2
@@ -8,13 +8,20 @@ let package = Package(
88 .iOS(.v16)
99 ],
1010 products: [
11 .library(name: "OrgSwift", targets: ["OrgSwift"])
11 .library(name: "OrgSwift", targets: ["OrgSwift"]),
12 .library(name: "OrgSwiftUI", targets: ["OrgSwiftUI"])
1213 ],
1314 targets: [
1415 .target(
1516 name: "OrgSwift",
1617 swiftSettings: [.enableUpcomingFeature("BareSlashRegexLiterals")]
1718 ),
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"])
1926 ]
2027)
README.md +33 −2
@@ -1,8 +1,11 @@
11# OrgSwift
22
33A dependency-free Swift library that renders a practical subset of
4[org-mode](https://orgmode.org) to sanitized HTML. Pure Foundation — no
5SwiftUI, WebKit, UIKit, or third-party packages.
4[org-mode](https://orgmode.org). Two products:
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.
69
710Originally extracted from the hand-rolled renderer in the Hutch iOS client so it
811could be shared across apps, and since rebuilt around an element tree: org is
@@ -58,6 +61,34 @@ let html = OrgRenderer.renderToHTML(
5861`./images/badge.svg` then resolves to
5962`https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg`.
6063
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
6192### Title and heading level
6293
6394Two presentation knobs, both defaulting to how Hutch rendered:
Sources/OrgSwift/AST/OrgAttributedStringRenderer.swift +53 −38
@@ -7,11 +7,26 @@ import Foundation
77/// not a second parser. It stays Foundation-only (no SwiftUI), so it is usable anywhere; a
88/// SwiftUI block renderer would sit on top, using this for the inline runs inside each block.
99public struct OrgAttributedStringRenderer: Sendable {
10 /// Resolution options, so repository-relative links resolve exactly as they do in HTML.
11 public var options: OrgRenderOptions
1012
11 public init() {}
13 public init(options: OrgRenderOptions = .init()) {
14 self.options = options
15 }
16
17 private var resolver: OrgURLResolver { OrgURLResolver(options: options) }
1218
1319 /// 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.
1424 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 {
1530 var result = AttributedString()
1631 for object in objects {
1732 switch object {
@@ -19,38 +34,26 @@ public struct OrgAttributedStringRenderer: Sendable {
1934 result += AttributedString(text)
2035
2136 case .bold(let children):
22 var part = inline(children)
23 part.inlinePresentationIntent = .stronglyEmphasized
24 result += part
37 result += applying(.stronglyEmphasized, to: inline(children, &notes))
2538
2639 case .italic(let children):
27 var part = inline(children)
28 part.inlinePresentationIntent = .emphasized
29 result += part
40 result += applying(.emphasized, to: inline(children, &notes))
3041
3142 // Underline and strikethrough have no Foundation-portable attribute (the
3243 // underlineStyle/strikethroughStyle keys live in the UIKit/AppKit scopes), so they
3344 // travel as roles the UI layer applies.
3445 case .underline(let children):
35 var part = inline(children)
36 part.orgRole = .underline
37 result += part
46 result += applying(.underline, to: inline(children, &notes))
3847
3948 case .strikeThrough(let children):
40 var part = inline(children)
41 part.orgRole = .strikeThrough
42 result += part
49 result += applying(.strikeThrough, to: inline(children, &notes))
4350
4451 case .verbatim(let text), .code(let text):
45 var part = AttributedString(text)
46 part.inlinePresentationIntent = .code
47 result += part
52 result += applying(.code, to: AttributedString(text))
4853
4954 case .superscript(let children):
5055 // 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
56 result += applying(.superscript, to: inline(children, &notes))
5457
5558 case .lineBreak:
5659 result += AttributedString("\n")
@@ -60,8 +63,8 @@ public struct OrgAttributedStringRenderer: Sendable {
6063 part.orgRole = .timestamp
6164 result += part
6265
63 case .footnoteRef(let label, _):
64 var part = AttributedString("[\(label)]")
66 case .footnoteRef(let label, let inlineNote):
67 var part = AttributedString("[\(notes.number(for: label, inline: inlineNote))]")
6568 part.orgRole = .footnoteReference
6669 result += part
6770
@@ -71,8 +74,9 @@ public struct OrgAttributedStringRenderer: Sendable {
7174 result += part
7275
7376 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)) {
77 var part = link.description.map { inline($0, &notes) }
78 ?? AttributedString(resolver.displayValue(link.target))
79 if let href = resolver.href(for: link.target), let url = URL(string: href) {
7680 part.link = url
7781 }
7882 result += part
@@ -81,6 +85,32 @@ public struct OrgAttributedStringRenderer: Sendable {
8185 return result
8286 }
8387
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
84114 /// Flatten a whole document to attributed paragraphs — a convenience for callers that
85115 /// want text without building block views (a share sheet, a plain-text export).
86116 public func paragraphs(_ document: OrgDocument) -> [AttributedString] {
@@ -93,21 +123,6 @@ public struct OrgAttributedStringRenderer: Sendable {
93123 }
94124 }
95125
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 }
111126}
112127
113128// 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
55/// The point of the prototype: this produces the same shape of output as the shipped
66/// single-pass ``OrgRenderer``, but from a parsed tree rather than from source, so a second
77/// 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 {
913 /// The same options the shipped ``OrgRenderer`` takes, so this renderer is a drop-in for it.
1014 public var options: OrgRenderOptions
1115 public var highlighter: CodeHighlighter
@@ -16,10 +20,10 @@ public struct OrgHTMLTreeRenderer: Sendable {
1620 }
1721
1822 public func render(_ document: OrgDocument) -> String {
19 var footnotes = FootnoteNumbering(document: document)
23 var footnotes = OrgFootnotes(document: document)
2024 var html = metadataHeader(document)
2125 html += document.elements.map { element($0, &footnotes) }.joined()
22 html += footnotes.renderSection(self)
26 html += renderFootnoteSection(&footnotes)
2327 return html
2428 }
2529
@@ -42,29 +46,12 @@ public struct OrgHTMLTreeRenderer: Sendable {
4246
4347 // MARK: - URL resolution
4448
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 }
49 /// Link and image resolution is shared with the other renderers; see ``OrgURLResolver``.
50 private var resolver: OrgURLResolver { OrgURLResolver(options: options) }
6451
6552 // MARK: - Blocks
6653
67 private func element(_ element: OrgElement, _ notes: inout FootnoteNumbering) -> String {
54 private func element(_ element: OrgElement, _ notes: inout OrgFootnotes) -> String {
6855 switch element {
6956 case .heading(let heading):
7057 let level = min(6, max(1, heading.level + options.headingLevelOffset))
@@ -149,7 +136,7 @@ public struct OrgHTMLTreeRenderer: Sendable {
149136 }
150137 }
151138
152 private func renderList(_ list: OrgList, _ notes: inout FootnoteNumbering) -> String {
139 private func renderList(_ list: OrgList, _ notes: inout OrgFootnotes) -> String {
153140 if list.kind == .description {
154141 var html = "<dl>\n"
155142 for item in list.items {
@@ -187,7 +174,7 @@ public struct OrgHTMLTreeRenderer: Sendable {
187174 return html + "</\(tag)>\n"
188175 }
189176
190 private func renderTable(_ table: OrgTable, _ notes: inout FootnoteNumbering) -> String {
177 private func renderTable(_ table: OrgTable, _ notes: inout OrgFootnotes) -> String {
191178 var html = "<table>\n"
192179 var wroteHeader = false
193180 var inBody = false
@@ -218,7 +205,7 @@ public struct OrgHTMLTreeRenderer: Sendable {
218205 /// The `<img>` for a figure, or nil when the source cannot be resolved to a safe URL —
219206 /// in which case the caller falls back to text rather than pointing at something unsafe.
220207 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 }
222209 let alt = figure.alt ?? caption.map { plainText($0) } ?? ""
223210 var html = #"<img src="\#(source)" alt="\#(escapeHTMLAttribute(alt))""#
224211 for (key, value) in figure.attributes where key != "alt" {
@@ -229,7 +216,7 @@ public struct OrgHTMLTreeRenderer: Sendable {
229216
230217 // MARK: - Inline
231218
232 func renderInline(_ objects: [OrgObject], _ notes: inout FootnoteNumbering) -> String {
219 func renderInline(_ objects: [OrgObject], _ notes: inout OrgFootnotes) -> String {
233220 var html = ""
234221 for object in objects {
235222 switch object {
@@ -258,9 +245,9 @@ public struct OrgHTMLTreeRenderer: Sendable {
258245 html += ##"<sup class="footnote-ref"><a id="fnr-\##(number)" href="#fn-\##(number)">\##(number)</a></sup>"##
259246 case .link(let link):
260247 let text = link.description.map { renderInline($0, &notes) }
261 ?? escapeHTML(displayValue(link.target))
248 ?? escapeHTML(resolver.displayValue(link.target))
262249 // 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) {
264251 html += #"<a href="\#(href)">\#(text)</a>"#
265252 } else {
266253 html += text
@@ -270,14 +257,6 @@ public struct OrgHTMLTreeRenderer: Sendable {
270257 return html
271258 }
272259
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
281260 /// Inline objects reduced to plain text, for an `alt` attribute.
282261 func plainText(_ objects: [OrgObject]) -> String {
283262 objects.map { object in
@@ -286,7 +265,7 @@ public struct OrgHTMLTreeRenderer: Sendable {
286265 case .verbatim(let text), .code(let text): return text
287266 case .bold(let c), .italic(let c), .underline(let c), .strikeThrough(let c), .superscript(let c):
288267 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)
290269 case .timestamp(let stamp): return stamp.displayValue
291270 case .image(let figure): return figure.alt ?? ""
292271 case .footnoteRef, .lineBreak: return ""
@@ -295,47 +274,21 @@ public struct OrgHTMLTreeRenderer: Sendable {
295274 }
296275}
297276
298// MARK: - Footnote numbering
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 }
277// MARK: - Footnote section
326278
327 mutating func renderSection(_ renderer: OrgHTMLTreeRenderer) -> String {
328 guard !order.isEmpty else { return "" }
279private extension OrgHTMLTreeRenderer {
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 "" }
329284 var html = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n"
330 for label in order {
331 let n = numbers[label] ?? 0
285 for note in collected {
286 let n = note.number
332287 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
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"
288 let body = renderInline(note.content, &notes)
289 if note.isInline {
290 html += "<li id=\"fn-\(n)\">\(body) \(back)</li>\n"
337291 } else {
338 let body = definitions[label].map { renderer.renderInline($0, &self) } ?? ""
339292 html += "<li id=\"fn-\(n)\"><p>\(body)</p>\n \(back)</li>\n"
340293 }
341294 }
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 {
1919/// sources are rewritten to `{host}/{owner}/{repositoryName}/blob/{ref}/...`.
2020/// When either is nil, relative links are left as-is (and dropped by the
2121/// URL-scheme allowlist, which only permits absolute http/https/mailto).
22public struct OrgRenderOptions {
22public struct OrgRenderOptions: Sendable {
2323 /// Host used to build absolute URLs for repository-relative links.
2424 public var host: String
2525 /// 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 {
122122 #expect(linkRun?.link?.absoluteString == "https://example.com")
123123 }
124124
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
125187 @Test
126188 func attributedStringCarriesRolesForNonStandardIntents() {
127189 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}