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 | OrgInlineStyler.swift [OrgObject] → styled AttributedString |
25 | OrgInlineStyler.swift [OrgObject] → styled AttributedString |
| 26 | OrgBlockView.swift, OrgListView.swift, OrgTableView.swift |
26 | OrgBlockView.swift, OrgListView.swift, OrgTableView.swift |
| 27 | OrgCodeStyler.swift the app's syntax highlighter, as a protocol |
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 | `OrgRenderer.renderToHTML` is a thin façade over `OrgParser.parse` + `OrgHTMLTreeRenderer`. |
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 | `CodeHighlighter` — returning `AttributedString` where the other returns HTML. Keeping it a |
105 | `CodeHighlighter` — returning `AttributedString` where the other returns HTML. Keeping it a |
| 105 | protocol is what lets the package stay dependency-free. |
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 | One behaviour deliberately differs from HTML. An image source that will not resolve to an |
113 | One behaviour deliberately differs from HTML. An image source that will not resolve to an |
| 108 | **absolute** URL degrades to its alt text: HTML may emit a relative `src` for a document base |
114 | **absolute** URL degrades to its alt text: HTML may emit a relative `src` for a document base |
| 109 | URL to resolve, and a view has no base, so a relative source is unloadable. |
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 | OrgView(orgSource, options: options, styler: Highlighter()) |
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 | ### Title and heading level |
100 | ### Title and heading level |
| 93 | |
101 | |
| 94 | Two presentation knobs, both defaulting to how Hutch rendered: |
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 | private static func scanEmphasis(_ chars: [Character], from start: Int, marker: Character) |
143 | private static func scanEmphasis(_ chars: [Character], from start: Int, marker: Character) |
| 144 | -> (body: String, next: Int)? { |
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 | var j = start + 1 |
149 | var j = start + 1 |
| 146 | var body = "" |
150 | var body = "" |
| 147 | while j < chars.count { |
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 | // The closer must end the run or be followed by space/punctuation. |
155 | // The closer must end the run or be followed by space/punctuation. |
| 150 | let after = j + 1 < chars.count ? chars[j + 1] : " " |
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 | return (body, j + 1) |
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 | struct OrgLayoutBuilder { |
11 | struct OrgLayoutBuilder { |
| 12 | var options: OrgRenderOptions |
12 | var options: OrgRenderOptions |
| 13 | var styler: OrgCodeStyler |
13 | var styler: OrgCodeStyler |
| |
14 | var keywords: OrgKeywordStyle |
| 14 | |
15 | |
| 15 | private var inline: OrgInlineStyler { OrgInlineStyler(options: options) } |
16 | private var inline: OrgInlineStyler { OrgInlineStyler(options: options) } |
| 16 | private var resolver: OrgURLResolver { OrgURLResolver(options: options) } |
17 | private var resolver: OrgURLResolver { OrgURLResolver(options: options) } |
| @@ -42,7 +43,7 @@ struct OrgLayoutBuilder { |
| 42 | var title = AttributedString() |
43 | var title = AttributedString() |
| 43 | if let todo = heading.todo { |
44 | if let todo = heading.todo { |
| 44 | var keyword = AttributedString(todo + " ") |
45 | var keyword = AttributedString(todo + " ") |
| 45 | keyword.foregroundColor = todo == "DONE" ? .green : .orange |
46 | keyword.foregroundColor = keywords.color(for: todo) |
| 46 | title += keyword |
47 | title += keyword |
| 47 | } |
48 | } |
| 48 | if let priority = heading.priority { |
49 | if let priority = heading.priority { |
Sources/OrgSwiftUI/OrgView.swift
+5 −2
| @@ -17,6 +17,7 @@ public struct OrgView: View { |
| 17 | private let source: String |
17 | private let source: String |
| 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 | |
21 | |
| 21 | @Environment(\.colorScheme) private var colorScheme |
22 | @Environment(\.colorScheme) private var colorScheme |
| 22 | @State private var layout = OrgLayout() |
23 | @State private var layout = OrgLayout() |
| @@ -24,11 +25,13 @@ public struct OrgView: View { |
| 24 | public init( |
25 | public init( |
| 25 | _ source: String, |
26 | _ source: String, |
| 26 | options: OrgRenderOptions = .init(), |
27 | options: OrgRenderOptions = .init(), |
| 27 | styler: OrgCodeStyler = PlainOrgCodeStyler() |
28 | styler: OrgCodeStyler = PlainOrgCodeStyler(), |
| |
29 | keywords: OrgKeywordStyle = .init() |
| 28 | ) { |
30 | ) { |
| 29 | self.source = source |
31 | self.source = source |
| 30 | self.options = options |
32 | self.options = options |
| 31 | self.styler = styler |
33 | self.styler = styler |
| |
34 | self.keywords = keywords |
| 32 | } |
35 | } |
| 33 | |
36 | |
| 34 | public var body: some View { |
37 | public var body: some View { |
| @@ -41,7 +44,7 @@ public struct OrgView: View { |
| 41 | .frame(maxWidth: .infinity, alignment: .leading) |
44 | .frame(maxWidth: .infinity, alignment: .leading) |
| 42 | .textSelection(.enabled) |
45 | .textSelection(.enabled) |
| 43 | .task(id: LayoutKey(source: source, colorScheme: colorScheme)) { |
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 | .layout(OrgParser.parse(source)) |
48 | .layout(OrgParser.parse(source)) |
| 46 | } |
49 | } |
| 47 | } |
50 | } |
Tests/OrgSwiftTests/OrgTreeTests.swift
+21
| @@ -323,6 +323,27 @@ struct OrgRenderOptionsTests { |
| 323 | #expect(without.contains("<p>Body.</p>")) |
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 | @Test |
347 | @Test |
| 327 | func rejectsUnsafeSchemes() { |
348 | func rejectsUnsafeSchemes() { |
| 328 | let html = OrgRenderer.renderToHTML("[[javascript:alert(1)][click]]") |
349 | let html = OrgRenderer.renderToHTML("[[javascript:alert(1)][click]]") |
Tests/OrgSwiftUITests/OrgLayoutTests.swift
+29 −2
| @@ -14,9 +14,11 @@ struct OrgLayoutTests { |
| 14 | private func layout( |
14 | private func layout( |
| 15 | _ source: String, |
15 | _ source: String, |
| 16 | options: OrgRenderOptions = .init(), |
16 | options: OrgRenderOptions = .init(), |
| 17 | styler: OrgCodeStyler = PlainOrgCodeStyler() |
17 | styler: OrgCodeStyler = PlainOrgCodeStyler(), |
| |
18 | keywords: OrgKeywordStyle = .init() |
| 18 | ) -> OrgLayout { |
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 | private func plain(_ string: AttributedString) -> String { String(string.characters) } |
24 | private func plain(_ string: AttributedString) -> String { String(string.characters) } |
| @@ -179,6 +181,31 @@ struct OrgLayoutTests { |
| 179 | #expect(layout("#+begin_export latex\n\\emph{hi}\n#+end_export").blocks.isEmpty) |
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 | /// Every run carries a font, not only the emphasised ones. A plain heading run with no |
209 | /// Every run carries a font, not only the emphasised ones. A plain heading run with no |
| 183 | /// font would inherit the surrounding view's and render at body size. |
210 | /// font would inherit the surrounding view's and render at body size. |
| 184 | @Test |
211 | @Test |