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
2525 OrgInlineStyler.swift [OrgObject] → styled AttributedString
2626 OrgBlockView.swift, OrgListView.swift, OrgTableView.swift
2727 OrgCodeStyler.swift the app's syntax highlighter, as a protocol
28 OrgKeywordStyle.swift the app's colors for TODO/DONE
2829```
2930
3031`OrgRenderer.renderToHTML` is a thin façade over `OrgParser.parse` + `OrgHTMLTreeRenderer`.
@@ -104,6 +105,11 @@ Syntax highlighting arrives through `OrgCodeStyler`, the native counterpart to
104105`CodeHighlighter` — returning `AttributedString` where the other returns HTML. Keeping it a
105106protocol is what lets the package stay dependency-free.
106107
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
107113One behaviour deliberately differs from HTML. An image source that will not resolve to an
108114**absolute** URL degrades to its alt text: HTML may emit a relative `src` for a document base
109115URL to resolve, and a view has no base, so a relative source is unloadable.
README.md +8
@@ -89,6 +89,14 @@ struct Highlighter: OrgCodeStyler {
8989OrgView(orgSource, options: options, styler: Highlighter())
9090```
9191
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
92100### Title and heading level
93101
94102Two 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
1111struct OrgLayoutBuilder {
1212 var options: OrgRenderOptions
1313 var styler: OrgCodeStyler
14 var keywords: OrgKeywordStyle
1415
1516 private var inline: OrgInlineStyler { OrgInlineStyler(options: options) }
1617 private var resolver: OrgURLResolver { OrgURLResolver(options: options) }
@@ -42,7 +43,7 @@ struct OrgLayoutBuilder {
4243 var title = AttributedString()
4344 if let todo = heading.todo {
4445 var keyword = AttributedString(todo + " ")
45 keyword.foregroundColor = todo == "DONE" ? .green : .orange
46 keyword.foregroundColor = keywords.color(for: todo)
4647 title += keyword
4748 }
4849 if let priority = heading.priority {
Sources/OrgSwiftUI/OrgView.swift +5 −2
@@ -17,6 +17,7 @@ public struct OrgView: View {
1717 private let source: String
1818 private let options: OrgRenderOptions
1919 private let styler: OrgCodeStyler
20 private let keywords: OrgKeywordStyle
2021
2122 @Environment(\.colorScheme) private var colorScheme
2223 @State private var layout = OrgLayout()
@@ -24,11 +25,13 @@ public struct OrgView: View {
2425 public init(
2526 _ source: String,
2627 options: OrgRenderOptions = .init(),
27 styler: OrgCodeStyler = PlainOrgCodeStyler()
28 styler: OrgCodeStyler = PlainOrgCodeStyler(),
29 keywords: OrgKeywordStyle = .init()
2830 ) {
2931 self.source = source
3032 self.options = options
3133 self.styler = styler
34 self.keywords = keywords
3235 }
3336
3437 public var body: some View {
@@ -41,7 +44,7 @@ public struct OrgView: View {
4144 .frame(maxWidth: .infinity, alignment: .leading)
4245 .textSelection(.enabled)
4346 .task(id: LayoutKey(source: source, colorScheme: colorScheme)) {
44 layout = OrgLayoutBuilder(options: options, styler: styler)
47 layout = OrgLayoutBuilder(options: options, styler: styler, keywords: keywords)
4548 .layout(OrgParser.parse(source))
4649 }
4750 }
Tests/OrgSwiftUITests/OrgLayoutTests.swift +29 −2
@@ -14,9 +14,11 @@ struct OrgLayoutTests {
1414 private func layout(
1515 _ source: String,
1616 options: OrgRenderOptions = .init(),
17 styler: OrgCodeStyler = PlainOrgCodeStyler()
17 styler: OrgCodeStyler = PlainOrgCodeStyler(),
18 keywords: OrgKeywordStyle = .init()
1819 ) -> OrgLayout {
19 OrgLayoutBuilder(options: options, styler: styler).layout(OrgParser.parse(source))
20 OrgLayoutBuilder(options: options, styler: styler, keywords: keywords)
21 .layout(OrgParser.parse(source))
2022 }
2123
2224 private func plain(_ string: AttributedString) -> String { String(string.characters) }
@@ -179,6 +181,31 @@ struct OrgLayoutTests {
179181 #expect(layout("#+begin_export latex\n\\emph{hi}\n#+end_export").blocks.isEmpty)
180182 }
181183
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
182209 /// Every run carries a font, not only the emphasised ones. A plain heading run with no
183210 /// font would inherit the surrounding view's and render at body size.
184211 @Test