krz/org-swift

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

Commit d91c9b5160

d91c9b516062b4fb0742c83edfd0c16b8a7b1b9b

parent: 58fdc6e3a9

Unsigned

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

Add a textTransform hook to OrgView (!9)

`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` inline presentation intent and links keep `.link`, so a caller can skip them.

gitbay-ios uses it to link `#N` / `!N` references in org-format issue and MR bodies.

Additive API; needs a minor release (0.7.0). 92 tests green.

Layout: unified · split

README.md +10
@@ -108,6 +108,16 @@ OrgView(orgSource, options: options,
108108 keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone))
109109```
110110
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
111121### Title and heading level
112122
113123Two presentation knobs, both defaulting to how Hutch rendered:
Sources/OrgSwiftUI/OrgLayoutBuilder.swift +4 −1
@@ -12,6 +12,7 @@ struct OrgLayoutBuilder {
1212 var options: OrgRenderOptions
1313 var styler: OrgCodeStyler
1414 var keywords: OrgKeywordStyle
15 var textTransform: ((inout AttributedString) -> Void)? = nil
1516
1617 private var inline: OrgInlineStyler { OrgInlineStyler(options: options) }
1718 private var resolver: OrgURLResolver { OrgURLResolver(options: options) }
@@ -32,7 +33,9 @@ struct OrgLayoutBuilder {
3233 _ objects: [OrgObject], _ notes: inout OrgFootnotes,
3334 style: Font.TextStyle = .body, bold: Bool = false
3435 ) -> 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
3639 }
3740
3841 /// 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 {
1818 private let options: OrgRenderOptions
1919 private let styler: OrgCodeStyler
2020 private let keywords: OrgKeywordStyle
21 private let textTransform: ((inout AttributedString) -> Void)?
2122
2223 @Environment(\.colorScheme) private var colorScheme
2324 @State private var layout = OrgLayout()
2425
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.
2531 public init(
2632 _ source: String,
2733 options: OrgRenderOptions = .init(),
2834 styler: OrgCodeStyler = PlainOrgCodeStyler(),
29 keywords: OrgKeywordStyle = .init()
35 keywords: OrgKeywordStyle = .init(),
36 textTransform: ((inout AttributedString) -> Void)? = nil
3037 ) {
3138 self.source = source
3239 self.options = options
3340 self.styler = styler
3441 self.keywords = keywords
42 self.textTransform = textTransform
3543 }
3644
3745 public var body: some View {
@@ -44,7 +52,8 @@ public struct OrgView: View {
4452 .frame(maxWidth: .infinity, alignment: .leading)
4553 .textSelection(.enabled)
4654 .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)
4857 .layout(OrgParser.parse(source))
4958 }
5059 }
Tests/OrgSwiftUITests/OrgLayoutTests.swift +33
@@ -343,4 +343,37 @@ struct OrgBadgeRowTests {
343343 func anUnresolvableSourceIsNotABadgeRow() {
344344 #expect(badges("[[https://example.com][file:badge.png]]") == nil)
345345 }
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 }
346379}