krz/org-swift

A dependency-free Swift library that renders org-mode to sanitized HTML. html library org-mode swift

Commit 032cfe34d5

032cfe34d52200909d6ebcdc67c28898457c8710

parent: dedfbafd51

Unsigned

cmc <hello@cleberg.net> · 2026-08-31 17:38 UTC

Document OrgSwiftUI

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.
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: