krz/org-swift

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

Commit d143408780

d14340878015b48d12c9abcfa5341700050ae68c

parent: 58fdc6e3a9

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-30 03:05 UTC

Add a textTransform hook to OrgView

OrgView(_:options:styler:keywords:textTransform:) runs the closure on each
styled run of prose (paragraphs, headings, list terms, table cells, verse,
captions, footnotes), never on a code block. Code and verbatim runs keep the
.code intent and links keep .link, so a caller can skip them. Lets an app add
its own links, e.g. issue references, without re-implementing the layout.

Ref krz/gitbay-ios

Layout: unified · split

README.md +10
@@ -108,6 +108,16 @@ OrgView(orgSource, options: options,
108 keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone)) 108 keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone))
109``` 109```
110 110
111To add links or styling of your own to prose, pass a `textTransform`. It gets each
112styled run of prose, never a code block; code and verbatim runs carry the `.code`
113inline presentation intent and links carry `.link`:
114
115```swift
116OrgView(orgSource, options: options, textTransform: { text in
117 // e.g. link issue references outside code and existing links
118})
119```
120
111### Title and heading level 121### Title and heading level
112 122
113Two presentation knobs, both defaulting to how Hutch rendered: 123Two presentation knobs, both defaulting to how Hutch rendered:
Sources/OrgSwiftUI/OrgLayoutBuilder.swift +4 −1
@@ -12,6 +12,7 @@ struct OrgLayoutBuilder {
12 var options: OrgRenderOptions 12 var options: OrgRenderOptions
13 var styler: OrgCodeStyler 13 var styler: OrgCodeStyler
14 var keywords: OrgKeywordStyle 14 var keywords: OrgKeywordStyle
15 var textTransform: ((inout AttributedString) -> Void)? = nil
15 16
16 private var inline: OrgInlineStyler { OrgInlineStyler(options: options) } 17 private var inline: OrgInlineStyler { OrgInlineStyler(options: options) }
17 private var resolver: OrgURLResolver { OrgURLResolver(options: options) } 18 private var resolver: OrgURLResolver { OrgURLResolver(options: options) }
@@ -32,7 +33,9 @@ struct OrgLayoutBuilder {
32 _ objects: [OrgObject], _ notes: inout OrgFootnotes, 33 _ objects: [OrgObject], _ notes: inout OrgFootnotes,
33 style: Font.TextStyle = .body, bold: Bool = false 34 style: Font.TextStyle = .body, bold: Bool = false
34 ) -> AttributedString { 35 ) -> AttributedString {
35 inline.attributed(objects, &notes, style: style, bold: bold) 36 var string = inline.attributed(objects, &notes, style: style, bold: bold)
37 textTransform?(&string)
38 return string
36 } 39 }
37 40
38 /// One element to zero or more blocks. Zero for a footnote definition (it moves to the 41 /// One element to zero or more blocks. Zero for a footnote definition (it moves to the
Sources/OrgSwiftUI/OrgView.swift +11 −2
@@ -18,20 +18,28 @@ public struct OrgView: View {
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 private let keywords: OrgKeywordStyle
21 private let textTransform: ((inout AttributedString) -> Void)?
21 22
22 @Environment(\.colorScheme) private var colorScheme 23 @Environment(\.colorScheme) private var colorScheme
23 @State private var layout = OrgLayout() 24 @State private var layout = OrgLayout()
24 25
26 /// - Parameter textTransform: runs on each run of prose after styling: paragraphs,
27 /// headings, list terms, table cells, verse, captions and footnotes, never a code
28 /// block. Code and verbatim runs carry the `.code` inline presentation intent and
29 /// links carry `.link`, so a transform can leave them alone. It is not part of the
30 /// layout key; changing it alone does not re-render.
25 public init( 31 public init(
26 _ source: String, 32 _ source: String,
27 options: OrgRenderOptions = .init(), 33 options: OrgRenderOptions = .init(),
28 styler: OrgCodeStyler = PlainOrgCodeStyler(), 34 styler: OrgCodeStyler = PlainOrgCodeStyler(),
29 keywords: OrgKeywordStyle = .init() 35 keywords: OrgKeywordStyle = .init(),
36 textTransform: ((inout AttributedString) -> Void)? = nil
30 ) { 37 ) {
31 self.source = source 38 self.source = source
32 self.options = options 39 self.options = options
33 self.styler = styler 40 self.styler = styler
34 self.keywords = keywords 41 self.keywords = keywords
42 self.textTransform = textTransform
35 } 43 }
36 44
37 public var body: some View { 45 public var body: some View {
@@ -44,7 +52,8 @@ public struct OrgView: View {
44 .frame(maxWidth: .infinity, alignment: .leading) 52 .frame(maxWidth: .infinity, alignment: .leading)
45 .textSelection(.enabled) 53 .textSelection(.enabled)
46 .task(id: LayoutKey(source: source, colorScheme: colorScheme)) { 54 .task(id: LayoutKey(source: source, colorScheme: colorScheme)) {
47 layout = OrgLayoutBuilder(options: options, styler: styler, keywords: keywords) 55 layout = OrgLayoutBuilder(options: options, styler: styler, keywords: keywords,
56 textTransform: textTransform)
48 .layout(OrgParser.parse(source)) 57 .layout(OrgParser.parse(source))
49 } 58 }
50 } 59 }
Tests/OrgSwiftUITests/OrgLayoutTests.swift +33
@@ -343,4 +343,37 @@ struct OrgBadgeRowTests {
343 func anUnresolvableSourceIsNotABadgeRow() { 343 func anUnresolvableSourceIsNotABadgeRow() {
344 #expect(badges("[[https://example.com][file:badge.png]]") == nil) 344 #expect(badges("[[https://example.com][file:badge.png]]") == nil)
345 } 345 }
346
347 /// The transform sees each run of prose after styling, with code and links still marked,
348 /// and never sees a code block.
349 @Test
350 func textTransformRunsOnProseOnly() {
351 var seen: [String] = []
352 let blocks = OrgLayoutBuilder(
353 options: .init(), styler: PlainOrgCodeStyler(), keywords: .init(),
354 textTransform: { text in
355 seen.append(String(text.characters))
356 for run in text.runs where run.inlinePresentationIntent?.contains(.code) == true {
357 text[run.range].foregroundColor = .red
358 }
359 }
360 ).layout(OrgParser.parse("""
361 * Title
362
363 Some =x= here.
364
365 | cell |
366
367 #+begin_src sh
368 echo hi
369 #+end_src
370 """)).blocks
371
372 #expect(seen == ["Title", "Some x here.", "cell"])
373 guard case .paragraph(let text) = blocks[1] else {
374 Issue.record("expected a paragraph")
375 return
376 }
377 #expect(text.runs.contains { $0.foregroundColor == .red && String(text[$0.range].characters) == "x" })
378 }
346} 379}