krz/org-swift

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

Commit 1873fef702

1873fef70216e46ea03a1e72575fd83dd8b40cf2

parent: 77bb69d3d0

Unsigned

cmc <hello@cleberg.net> · 2026-08-31 18:32 UTC

Take TODO/DONE colors from the caller

The renderer hardcoded green for DONE and orange for TODO, which match no
embedding app's palette — everything else it draws is semantic and inherits
the app's appearance. OrgKeywordStyle carries the two colors and defaults
to semantic, so the package invents no hues of its own.

OrgRenderOptions stays Foundation-only, so this lives in OrgSwiftUI.

Layout: unified · split

ARCHITECTURE.md +6
@@ -25,6 +25,7 @@ Sources/OrgSwiftUI/ depends on OrgSwift
25 OrgInlineStyler.swift [OrgObject] → styled AttributedString 25 OrgInlineStyler.swift [OrgObject] → styled AttributedString
26 OrgBlockView.swift, OrgListView.swift, OrgTableView.swift 26 OrgBlockView.swift, OrgListView.swift, OrgTableView.swift
27 OrgCodeStyler.swift the app's syntax highlighter, as a protocol 27 OrgCodeStyler.swift the app's syntax highlighter, as a protocol
28 OrgKeywordStyle.swift the app's colors for TODO/DONE
28``` 29```
29 30
30`OrgRenderer.renderToHTML` is a thin façade over `OrgParser.parse` + `OrgHTMLTreeRenderer`. 31`OrgRenderer.renderToHTML` is a thin façade over `OrgParser.parse` + `OrgHTMLTreeRenderer`.
@@ -104,6 +105,11 @@ Syntax highlighting arrives through `OrgCodeStyler`, the native counterpart to
104`CodeHighlighter` — returning `AttributedString` where the other returns HTML. Keeping it a 105`CodeHighlighter` — returning `AttributedString` where the other returns HTML. Keeping it a
105protocol is what lets the package stay dependency-free. 106protocol is what lets the package stay dependency-free.
106 107
108Color follows the same principle. Everything the renderer draws uses a semantic color, so it
109inherits the embedding app's appearance; the one place org carries status rather than prose —
110`TODO`/`DONE` — is configurable through `OrgKeywordStyle` and defaults to semantic too. A
111package that picks concrete hues puts colors on screen that match nothing around them.
112
107One behaviour deliberately differs from HTML. An image source that will not resolve to an 113One 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 114**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. 115URL to resolve, and a view has no base, so a relative source is unloadable.
README.md +8
@@ -89,6 +89,14 @@ struct Highlighter: OrgCodeStyler {
89OrgView(orgSource, options: options, styler: Highlighter()) 89OrgView(orgSource, options: options, styler: Highlighter())
90``` 90```
91 91
92Org's `TODO`/`DONE` keywords default to semantic colors, because a package
93cannot know your palette. Pass your own:
94
95```swift
96OrgView(orgSource, options: options,
97 keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone))
98```
99
92### Title and heading level 100### Title and heading level
93 101
94Two presentation knobs, both defaulting to how Hutch rendered: 102Two presentation knobs, both defaulting to how Hutch rendered:
Sources/OrgSwiftUI/OrgKeywordStyle.swift added +23
@@ -0,0 +1,23 @@
1import SwiftUI
2
3/// Colors for the part of a heading that is status rather than prose: org's `TODO` and
4/// `DONE` keywords.
5///
6/// The defaults are deliberately semantic rather than literal. A package cannot know the
7/// palette of the app embedding it, and picking concrete hues — green for done, orange for
8/// todo — puts colors on screen that belong to no design system and match nothing around
9/// them. Apps pass their own.
10public struct OrgKeywordStyle: Sendable {
11 public var todo: Color
12 public var done: Color
13
14 public init(todo: Color = .secondary, done: Color = .secondary) {
15 self.todo = todo
16 self.done = done
17 }
18
19 /// The keyword's color, or nil for a keyword that is neither.
20 func color(for keyword: String) -> Color {
21 keyword == "DONE" ? done : todo
22 }
23}
Sources/OrgSwiftUI/OrgLayoutBuilder.swift +2 −1
@@ -11,6 +11,7 @@ import SwiftUI
11struct OrgLayoutBuilder { 11struct OrgLayoutBuilder {
12 var options: OrgRenderOptions 12 var options: OrgRenderOptions
13 var styler: OrgCodeStyler 13 var styler: OrgCodeStyler
14 var keywords: OrgKeywordStyle
14 15
15 private var inline: OrgInlineStyler { OrgInlineStyler(options: options) } 16 private var inline: OrgInlineStyler { OrgInlineStyler(options: options) }
16 private var resolver: OrgURLResolver { OrgURLResolver(options: options) } 17 private var resolver: OrgURLResolver { OrgURLResolver(options: options) }
@@ -42,7 +43,7 @@ struct OrgLayoutBuilder {
42 var title = AttributedString() 43 var title = AttributedString()
43 if let todo = heading.todo { 44 if let todo = heading.todo {
44 var keyword = AttributedString(todo + " ") 45 var keyword = AttributedString(todo + " ")
45 keyword.foregroundColor = todo == "DONE" ? .green : .orange 46 keyword.foregroundColor = keywords.color(for: todo)
46 title += keyword 47 title += keyword
47 } 48 }
48 if let priority = heading.priority { 49 if let priority = heading.priority {
Sources/OrgSwiftUI/OrgView.swift +5 −2
@@ -17,6 +17,7 @@ public struct OrgView: View {
17 private let source: String 17 private let source: String
18 private let options: OrgRenderOptions 18 private let options: OrgRenderOptions
19 private let styler: OrgCodeStyler 19 private let styler: OrgCodeStyler
20 private let keywords: OrgKeywordStyle
20 21
21 @Environment(\.colorScheme) private var colorScheme 22 @Environment(\.colorScheme) private var colorScheme
22 @State private var layout = OrgLayout() 23 @State private var layout = OrgLayout()
@@ -24,11 +25,13 @@ public struct OrgView: View {
24 public init( 25 public init(
25 _ source: String, 26 _ source: String,
26 options: OrgRenderOptions = .init(), 27 options: OrgRenderOptions = .init(),
27 styler: OrgCodeStyler = PlainOrgCodeStyler() 28 styler: OrgCodeStyler = PlainOrgCodeStyler(),
29 keywords: OrgKeywordStyle = .init()
28 ) { 30 ) {
29 self.source = source 31 self.source = source
30 self.options = options 32 self.options = options
31 self.styler = styler 33 self.styler = styler
34 self.keywords = keywords
32 } 35 }
33 36
34 public var body: some View { 37 public var body: some View {
@@ -41,7 +44,7 @@ public struct OrgView: View {
41 .frame(maxWidth: .infinity, alignment: .leading) 44 .frame(maxWidth: .infinity, alignment: .leading)
42 .textSelection(.enabled) 45 .textSelection(.enabled)
43 .task(id: LayoutKey(source: source, colorScheme: colorScheme)) { 46 .task(id: LayoutKey(source: source, colorScheme: colorScheme)) {
44 layout = OrgLayoutBuilder(options: options, styler: styler) 47 layout = OrgLayoutBuilder(options: options, styler: styler, keywords: keywords)
45 .layout(OrgParser.parse(source)) 48 .layout(OrgParser.parse(source))
46 } 49 }
47 } 50 }
Tests/OrgSwiftUITests/OrgLayoutTests.swift +29 −2
@@ -14,9 +14,11 @@ struct OrgLayoutTests {
14 private func layout( 14 private func layout(
15 _ source: String, 15 _ source: String,
16 options: OrgRenderOptions = .init(), 16 options: OrgRenderOptions = .init(),
17 styler: OrgCodeStyler = PlainOrgCodeStyler() 17 styler: OrgCodeStyler = PlainOrgCodeStyler(),
18 keywords: OrgKeywordStyle = .init()
18 ) -> OrgLayout { 19 ) -> OrgLayout {
19 OrgLayoutBuilder(options: options, styler: styler).layout(OrgParser.parse(source)) 20 OrgLayoutBuilder(options: options, styler: styler, keywords: keywords)
21 .layout(OrgParser.parse(source))
20 } 22 }
21 23
22 private func plain(_ string: AttributedString) -> String { String(string.characters) } 24 private func plain(_ string: AttributedString) -> String { String(string.characters) }
@@ -179,6 +181,31 @@ struct OrgLayoutTests {
179 #expect(layout("#+begin_export latex\n\\emph{hi}\n#+end_export").blocks.isEmpty) 181 #expect(layout("#+begin_export latex\n\\emph{hi}\n#+end_export").blocks.isEmpty)
180 } 182 }
181 183
184 /// Keyword colors come from the caller. The package defaults to semantic colors rather
185 /// than inventing hues that match no app's palette.
186 @Test
187 func todoKeywordsTakeTheirColorFromTheCaller() {
188 let style = OrgKeywordStyle(todo: .pink, done: .brown)
189
190 guard case .heading(let todo, _) = layout("* TODO Ship it", keywords: style)
191 .blocks.first else {
192 Issue.record("expected a heading"); return
193 }
194 #expect(todo.runs.contains { $0.foregroundColor == .pink })
195
196 guard case .heading(let done, _) = layout("* DONE Shipped", keywords: style)
197 .blocks.first else {
198 Issue.record("expected a heading"); return
199 }
200 #expect(done.runs.contains { $0.foregroundColor == .brown })
201
202 // The default invents nothing.
203 guard case .heading(let plain, _) = layout("* TODO Ship it").blocks.first else {
204 Issue.record("expected a heading"); return
205 }
206 #expect(plain.runs.allSatisfy { $0.foregroundColor != .green && $0.foregroundColor != .orange })
207 }
208
182 /// Every run carries a font, not only the emphasised ones. A plain heading run with no 209 /// Every run carries a font, not only the emphasised ones. A plain heading run with no
183 /// font would inherit the surrounding view's and render at body size. 210 /// font would inherit the surrounding view's and render at body size.
184 @Test 211 @Test