Emphasis border rule, and caller-supplied TODO/DONE colors !4
merged
merged by cmc on 2026-08-31 18:32 UTC
· krz/org-swift:org-fixes into main
8 files changed, +102 −7
Layout: unified · split
ARCHITECTURE.md
+6
| @@ -25,6 +25,7 @@ Sources/OrgSwiftUI/ depends on OrgSwift |
| 25 | 25 | OrgInlineStyler.swift [OrgObject] → styled AttributedString |
| 26 | 26 | OrgBlockView.swift, OrgListView.swift, OrgTableView.swift |
| 27 | 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 | 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 | 105 | `CodeHighlighter` — returning `AttributedString` where the other returns HTML. Keeping it a |
| 105 | 106 | protocol is what lets the package stay dependency-free. |
| 106 | 107 | |
| 108 | Color follows the same principle. Everything the renderer draws uses a semantic color, so it |
| 109 | inherits 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 |
| 111 | package that picks concrete hues puts colors on screen that match nothing around them. |
| 112 | |
| 107 | 113 | One behaviour deliberately differs from HTML. An image source that will not resolve to an |
| 108 | 114 | **absolute** URL degrades to its alt text: HTML may emit a relative `src` for a document base |
| 109 | 115 | URL to resolve, and a view has no base, so a relative source is unloadable. |
README.md
+8
| @@ -89,6 +89,14 @@ struct Highlighter: OrgCodeStyler { |
| 89 | 89 | OrgView(orgSource, options: options, styler: Highlighter()) |
| 90 | 90 | ``` |
| 91 | 91 | |
| 92 | Org's `TODO`/`DONE` keywords default to semantic colors, because a package |
| 93 | cannot know your palette. Pass your own: |
| 94 | |
| 95 | ```swift |
| 96 | OrgView(orgSource, options: options, |
| 97 | keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone)) |
| 98 | ``` |
| 99 | |
| 92 | 100 | ### Title and heading level |
| 93 | 101 | |
| 94 | 102 | Two presentation knobs, both defaulting to how Hutch rendered: |
Sources/OrgSwift/AST/OrgInlineParser.swift
+8 −2
| @@ -142,13 +142,19 @@ extension OrgParser { |
| 142 | 142 | |
| 143 | 143 | private static func scanEmphasis(_ chars: [Character], from start: Int, marker: Character) |
| 144 | 144 | -> (body: String, next: Int)? { |
| 145 | // org's border rule: the body may not begin with whitespace, so an opener followed by |
| 146 | // a space is not an opener at all. |
| 147 | guard start + 1 < chars.count, !chars[start + 1].isWhitespace else { return nil } |
| 148 | |
| 145 | 149 | var j = start + 1 |
| 146 | 150 | var body = "" |
| 147 | 151 | while j < chars.count { |
| 148 | | if chars[j] == marker { |
| 152 | // The body may not end with whitespace either — which is what makes the closer of |
| 153 | // `=ssh.mode = "system"=` the last `=` and not the middle one. |
| 154 | if chars[j] == marker, let last = body.last, !last.isWhitespace { |
| 149 | 155 | // The closer must end the run or be followed by space/punctuation. |
| 150 | 156 | let after = j + 1 < chars.count ? chars[j + 1] : " " |
| 151 | | if !body.isEmpty, after.isWhitespace || ".,;:!?)]}'\"".contains(after) || j + 1 == chars.count { |
| 157 | if after.isWhitespace || ".,;:!?)]}'\"".contains(after) || j + 1 == chars.count { |
| 152 | 158 | return (body, j + 1) |
| 153 | 159 | } |
| 154 | 160 | } |
Sources/OrgSwiftUI/OrgKeywordStyle.swift
added
+23
| @@ -0,0 +1,23 @@ |
| 1 | import 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. |
| 10 | public 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 |
| 11 | 11 | struct OrgLayoutBuilder { |
| 12 | 12 | var options: OrgRenderOptions |
| 13 | 13 | var styler: OrgCodeStyler |
| 14 | var keywords: OrgKeywordStyle |
| 14 | 15 | |
| 15 | 16 | private var inline: OrgInlineStyler { OrgInlineStyler(options: options) } |
| 16 | 17 | private var resolver: OrgURLResolver { OrgURLResolver(options: options) } |
| @@ -42,7 +43,7 @@ struct OrgLayoutBuilder { |
| 42 | 43 | var title = AttributedString() |
| 43 | 44 | if let todo = heading.todo { |
| 44 | 45 | var keyword = AttributedString(todo + " ") |
| 45 | | keyword.foregroundColor = todo == "DONE" ? .green : .orange |
| 46 | keyword.foregroundColor = keywords.color(for: todo) |
| 46 | 47 | title += keyword |
| 47 | 48 | } |
| 48 | 49 | if let priority = heading.priority { |
Sources/OrgSwiftUI/OrgView.swift
+5 −2
| @@ -17,6 +17,7 @@ public struct OrgView: View { |
| 17 | 17 | private let source: String |
| 18 | 18 | private let options: OrgRenderOptions |
| 19 | 19 | private let styler: OrgCodeStyler |
| 20 | private let keywords: OrgKeywordStyle |
| 20 | 21 | |
| 21 | 22 | @Environment(\.colorScheme) private var colorScheme |
| 22 | 23 | @State private var layout = OrgLayout() |
| @@ -24,11 +25,13 @@ public struct OrgView: View { |
| 24 | 25 | public init( |
| 25 | 26 | _ source: String, |
| 26 | 27 | options: OrgRenderOptions = .init(), |
| 27 | | styler: OrgCodeStyler = PlainOrgCodeStyler() |
| 28 | styler: OrgCodeStyler = PlainOrgCodeStyler(), |
| 29 | keywords: OrgKeywordStyle = .init() |
| 28 | 30 | ) { |
| 29 | 31 | self.source = source |
| 30 | 32 | self.options = options |
| 31 | 33 | self.styler = styler |
| 34 | self.keywords = keywords |
| 32 | 35 | } |
| 33 | 36 | |
| 34 | 37 | public var body: some View { |
| @@ -41,7 +44,7 @@ public struct OrgView: View { |
| 41 | 44 | .frame(maxWidth: .infinity, alignment: .leading) |
| 42 | 45 | .textSelection(.enabled) |
| 43 | 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 | 48 | .layout(OrgParser.parse(source)) |
| 46 | 49 | } |
| 47 | 50 | } |
Tests/OrgSwiftTests/OrgTreeTests.swift
+21
| @@ -323,6 +323,27 @@ struct OrgRenderOptionsTests { |
| 323 | 323 | #expect(without.contains("<p>Body.</p>")) |
| 324 | 324 | } |
| 325 | 325 | |
| 326 | /// org's border rule: an emphasis body may not begin or end with whitespace. Without the |
| 327 | /// trailing half, `=ssh.mode = "system"=` closed at the middle `=` and rendered as two |
| 328 | /// verbatim spans with the quoted value loose between them. |
| 329 | @Test |
| 330 | func emphasisBodyCannotBeginOrEndWithWhitespace() { |
| 331 | let objects = OrgParser.parseInline(#"=ssh.mode = "system"= to run"#) |
| 332 | let verbatims = objects.compactMap { object -> String? in |
| 333 | if case .verbatim(let text) = object { return text } else { return nil } |
| 334 | } |
| 335 | #expect(verbatims == [#"ssh.mode = "system""#]) |
| 336 | |
| 337 | // A leading space means the marker never opens. |
| 338 | #expect(OrgParser.parseInline("= not verbatim=") == [.text("= not verbatim=")]) |
| 339 | |
| 340 | // The ordinary cases still work. |
| 341 | #expect(OrgParser.parseInline("=plain=") == [.verbatim("plain")]) |
| 342 | let bold = OrgParser.parseInline("*bold* and *two words*") |
| 343 | #expect(bold.contains(.bold([.text("bold")]))) |
| 344 | #expect(bold.contains(.bold([.text("two words")]))) |
| 345 | } |
| 346 | |
| 326 | 347 | @Test |
| 327 | 348 | func rejectsUnsafeSchemes() { |
| 328 | 349 | let html = OrgRenderer.renderToHTML("[[javascript:alert(1)][click]]") |
Tests/OrgSwiftUITests/OrgLayoutTests.swift
+29 −2
| @@ -14,9 +14,11 @@ struct OrgLayoutTests { |
| 14 | 14 | private func layout( |
| 15 | 15 | _ source: String, |
| 16 | 16 | options: OrgRenderOptions = .init(), |
| 17 | | styler: OrgCodeStyler = PlainOrgCodeStyler() |
| 17 | styler: OrgCodeStyler = PlainOrgCodeStyler(), |
| 18 | keywords: OrgKeywordStyle = .init() |
| 18 | 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 | 24 | private func plain(_ string: AttributedString) -> String { String(string.characters) } |
| @@ -179,6 +181,31 @@ struct OrgLayoutTests { |
| 179 | 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 | 209 | /// Every run carries a font, not only the emphasised ones. A plain heading run with no |
| 183 | 210 | /// font would inherit the surrounding view's and render at body size. |
| 184 | 211 | @Test |