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, |
| 108 | 108 | keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone)) |
| 109 | 109 | ``` |
| 110 | 110 | |
| 111 | To add links or styling of your own to prose, pass a `textTransform`. It gets each |
| 112 | styled run of prose, never a code block; code and verbatim runs carry the `.code` |
| 113 | inline presentation intent and links carry `.link`: |
| 114 | |
| 115 | ```swift |
| 116 | OrgView(orgSource, options: options, textTransform: { text in |
| 117 | // e.g. link issue references outside code and existing links |
| 118 | }) |
| 119 | ``` |
| 120 | |
| 111 | 121 | ### Title and heading level |
| 112 | 122 | |
| 113 | 123 | Two presentation knobs, both defaulting to how Hutch rendered: |
Sources/OrgSwiftUI/OrgLayoutBuilder.swift
+4 −1
| @@ -12,6 +12,7 @@ struct OrgLayoutBuilder { |
| 12 | 12 | var options: OrgRenderOptions |
| 13 | 13 | var styler: OrgCodeStyler |
| 14 | 14 | var keywords: OrgKeywordStyle |
| 15 | var textTransform: ((inout AttributedString) -> Void)? = nil |
| 15 | 16 | |
| 16 | 17 | private var inline: OrgInlineStyler { OrgInlineStyler(options: options) } |
| 17 | 18 | private var resolver: OrgURLResolver { OrgURLResolver(options: options) } |
| @@ -32,7 +33,9 @@ struct OrgLayoutBuilder { |
| 32 | 33 | _ objects: [OrgObject], _ notes: inout OrgFootnotes, |
| 33 | 34 | style: Font.TextStyle = .body, bold: Bool = false |
| 34 | 35 | ) -> AttributedString { |
| 35 | | inline.attributed(objects, ¬es, style: style, bold: bold) |
| 36 | var string = inline.attributed(objects, ¬es, style: style, bold: bold) |
| 37 | textTransform?(&string) |
| 38 | return string |
| 36 | 39 | } |
| 37 | 40 | |
| 38 | 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 | 18 | private let options: OrgRenderOptions |
| 19 | 19 | private let styler: OrgCodeStyler |
| 20 | 20 | private let keywords: OrgKeywordStyle |
| 21 | private let textTransform: ((inout AttributedString) -> Void)? |
| 21 | 22 | |
| 22 | 23 | @Environment(\.colorScheme) private var colorScheme |
| 23 | 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 | 31 | public init( |
| 26 | 32 | _ source: String, |
| 27 | 33 | options: OrgRenderOptions = .init(), |
| 28 | 34 | styler: OrgCodeStyler = PlainOrgCodeStyler(), |
| 29 | | keywords: OrgKeywordStyle = .init() |
| 35 | keywords: OrgKeywordStyle = .init(), |
| 36 | textTransform: ((inout AttributedString) -> Void)? = nil |
| 30 | 37 | ) { |
| 31 | 38 | self.source = source |
| 32 | 39 | self.options = options |
| 33 | 40 | self.styler = styler |
| 34 | 41 | self.keywords = keywords |
| 42 | self.textTransform = textTransform |
| 35 | 43 | } |
| 36 | 44 | |
| 37 | 45 | public var body: some View { |
| @@ -44,7 +52,8 @@ public struct OrgView: View { |
| 44 | 52 | .frame(maxWidth: .infinity, alignment: .leading) |
| 45 | 53 | .textSelection(.enabled) |
| 46 | 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 | 57 | .layout(OrgParser.parse(source)) |
| 49 | 58 | } |
| 50 | 59 | } |
Tests/OrgSwiftUITests/OrgLayoutTests.swift
+33
| @@ -343,4 +343,37 @@ struct OrgBadgeRowTests { |
| 343 | 343 | func anUnresolvableSourceIsNotABadgeRow() { |
| 344 | 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 | } |