krz/org-swift

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

Commit dedfbafd51

dedfbafd51cf69d6edd6496f4d050e14602d699a

parent: cc7163b03d

Unsigned

cmc <hello@cleberg.net> · 2026-08-31 17:37 UTC

Add OrgSwiftUI, a native renderer for the shared tree

A second product, so OrgSwift stays Foundation-only and callers wanting
HTML never import SwiftUI. OrgLayoutBuilder walks OrgDocument into a
display model with inline runs styled, footnotes numbered, URLs resolved
and code highlighted; the views are layout over that. The walk runs off
`body` in a task keyed to the source, since numbering is sequential and
highlighting is expensive.

Covers every OrgElement case: recursive lists with checkboxes and
description terms, tables in a Grid with org's column alignments and a
horizontally scrolling overflow, AsyncImage figures, and the footnote
section. Syntax highlighting arrives through OrgCodeStyler, the native
counterpart to CodeHighlighter, so the package keeps no dependencies.

One deliberate divergence from HTML: an image source that will not resolve
to an absolute URL degrades to its alt text. HTML can emit a relative src
for a document base to resolve; a view has no base.

Layout: unified · split

Package.swift +9 −2
@@ -8,13 +8,20 @@ let package = Package(
88 .iOS(.v16)
99 ],
1010 products: [
11 .library(name: "OrgSwift", targets: ["OrgSwift"])
11 .library(name: "OrgSwift", targets: ["OrgSwift"]),
12 .library(name: "OrgSwiftUI", targets: ["OrgSwiftUI"])
1213 ],
1314 targets: [
1415 .target(
1516 name: "OrgSwift",
1617 swiftSettings: [.enableUpcomingFeature("BareSlashRegexLiterals")]
1718 ),
18 .testTarget(name: "OrgSwiftTests", dependencies: ["OrgSwift"])
19 .target(
20 name: "OrgSwiftUI",
21 dependencies: ["OrgSwift"],
22 swiftSettings: [.enableUpcomingFeature("BareSlashRegexLiterals")]
23 ),
24 .testTarget(name: "OrgSwiftTests", dependencies: ["OrgSwift"]),
25 .testTarget(name: "OrgSwiftUITests", dependencies: ["OrgSwiftUI"])
1926 ]
2027)
Sources/OrgSwiftUI/OrgBlockView.swift added +108
@@ -0,0 +1,108 @@
1import OrgSwift
2import SwiftUI
3
4/// Renders prepared blocks. All the org work happened in ``OrgLayoutBuilder``; this is
5/// layout only.
6struct OrgBlockListView: View {
7 let blocks: [OrgBlock]
8
9 var body: some View {
10 VStack(alignment: .leading, spacing: 12) {
11 ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
12 OrgBlockView(block: block)
13 }
14 }
15 .frame(maxWidth: .infinity, alignment: .leading)
16 }
17}
18
19struct OrgBlockView: View {
20 let block: OrgBlock
21
22 var body: some View {
23 switch block {
24 case .heading(let text, let level):
25 Text(text)
26 .padding(.top, level <= 2 ? 8 : 2)
27 .fixedSize(horizontal: false, vertical: true)
28
29 case .paragraph(let text):
30 Text(text)
31 .fixedSize(horizontal: false, vertical: true)
32
33 case .code(let code):
34 ScrollView(.horizontal) {
35 Text(code)
36 .textSelection(.enabled)
37 .padding(10)
38 }
39 .background(
40 Color.secondary.opacity(0.12),
41 in: RoundedRectangle(cornerRadius: 4)
42 )
43
44 case .quote(let blocks):
45 HStack(spacing: 10) {
46 RoundedRectangle(cornerRadius: 2)
47 .fill(.tertiary)
48 .frame(width: 3)
49 OrgBlockListView(blocks: blocks)
50 .foregroundStyle(.secondary)
51 }
52 .fixedSize(horizontal: false, vertical: true)
53
54 case .center(let blocks):
55 OrgBlockListView(blocks: blocks)
56 .multilineTextAlignment(.center)
57 .frame(maxWidth: .infinity, alignment: .center)
58
59 case .verse(let lines):
60 // Verse keeps its line breaks; that is the whole point of the block.
61 VStack(alignment: .leading, spacing: 0) {
62 ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
63 Text(line).fixedSize(horizontal: false, vertical: true)
64 }
65 }
66
67 case .special(_, let blocks):
68 OrgBlockListView(blocks: blocks)
69
70 case .list(let list):
71 OrgListView(list: list)
72
73 case .table(let table):
74 OrgTableView(table: table)
75
76 case .image(let source, let alt, let caption):
77 VStack(alignment: .leading, spacing: 4) {
78 AsyncImage(url: source) { phase in
79 switch phase {
80 case .success(let image):
81 image.resizable().scaledToFit()
82 case .failure:
83 Text(alt.isEmpty ? source.lastPathComponent : alt)
84 .font(.footnote)
85 .foregroundStyle(.secondary)
86 default:
87 ProgressView()
88 }
89 }
90 .accessibilityLabel(alt)
91 if let caption {
92 Text(caption).foregroundStyle(.secondary)
93 }
94 }
95
96 case .captioned(let caption, let blocks):
97 VStack(alignment: .leading, spacing: 4) {
98 OrgBlockListView(blocks: blocks)
99 if let caption {
100 Text(caption).foregroundStyle(.secondary)
101 }
102 }
103
104 case .rule:
105 Divider()
106 }
107 }
108}
Sources/OrgSwiftUI/OrgCodeStyler.swift added +20
@@ -0,0 +1,20 @@
1import SwiftUI
2
3/// Syntax highlighting for `#+begin_src` blocks, supplied by the app.
4///
5/// The native counterpart to ``CodeHighlighter``: that one returns HTML for the HTML
6/// renderer, this returns an `AttributedString`. Keeping it a protocol is what lets
7/// OrgSwiftUI stay dependency-free — a highlighting engine is the app's business.
8///
9/// Returning nil means "show it as plain monospaced text", which is a fine outcome for an
10/// unknown language, never an error.
11@MainActor
12public protocol OrgCodeStyler {
13 func highlighted(code: String, language: String?) -> AttributedString?
14}
15
16/// The default: no highlighting.
17public struct PlainOrgCodeStyler: OrgCodeStyler {
18 nonisolated public init() {}
19 public func highlighted(code _: String, language _: String?) -> AttributedString? { nil }
20}
Sources/OrgSwiftUI/OrgInlineStyler.swift added +84
@@ -0,0 +1,84 @@
1import OrgSwift
2import SwiftUI
3
4/// Turns a run of inline org objects into a styled `AttributedString`.
5///
6/// The parse and the attribute intents come from ``OrgAttributedStringRenderer``; this adds
7/// only presentation. Everything is expressed in semantic `Font.TextStyle`s and semantic
8/// colors, so the result scales with Dynamic Type and follows light/dark on its own.
9struct OrgInlineStyler {
10 var renderer: OrgAttributedStringRenderer
11
12 init(options: OrgRenderOptions) {
13 renderer = OrgAttributedStringRenderer(options: options)
14 }
15
16 /// - Parameter style: the text style of the block this run sits in, so emphasis inside a
17 /// heading bolds the heading's font rather than the body font.
18 func attributed(
19 _ objects: [OrgObject], _ notes: inout OrgFootnotes, style: Font.TextStyle = .body
20 ) -> AttributedString {
21 var string = renderer.inline(objects, &notes)
22
23 for range in string.runs.map(\.range) {
24 let intents = string[range].inlinePresentationIntent ?? []
25 let role = string[range][OrgRoleAttribute.self]
26
27 var textStyle = style
28 var monospaced = false
29 var bold = false
30 var italic = false
31
32 if intents.contains(.stronglyEmphasized) { bold = true }
33 if intents.contains(.emphasized) { italic = true }
34 if intents.contains(.code) {
35 monospaced = true
36 string[range].backgroundColor = .secondary.opacity(0.12)
37 }
38
39 switch role {
40 case .underline:
41 string[range].underlineStyle = .single
42 case .strikeThrough:
43 string[range].strikethroughStyle = .single
44 case .superscript, .footnoteReference:
45 // No portable superscript attribute, so raise it and step the style down.
46 textStyle = Self.smaller(textStyle)
47 string[range].baselineOffset = 5
48 if role == .footnoteReference { string[range].foregroundColor = .accentColor }
49 case .timestamp:
50 string[range].foregroundColor = .secondary
51 case .image:
52 // An inline image inside a paragraph shows its alt text; the block-level
53 // `.figure` case is where an image is actually loaded.
54 italic = true
55 string[range].foregroundColor = .secondary
56 case nil:
57 break
58 }
59
60 if bold || italic || monospaced || textStyle != style {
61 var font = Font.system(textStyle, design: monospaced ? .monospaced : .default)
62 if bold { font = font.bold() }
63 if italic { font = font.italic() }
64 string[range].font = font
65 }
66 }
67 return string
68 }
69
70 /// One step down the semantic scale, for superscripts and footnote markers.
71 private static func smaller(_ style: Font.TextStyle) -> Font.TextStyle {
72 switch style {
73 case .largeTitle: .title
74 case .title: .title2
75 case .title2: .title3
76 case .title3: .headline
77 case .headline, .body: .footnote
78 case .callout: .caption
79 case .subheadline: .caption
80 case .footnote, .caption, .caption2: .caption2
81 @unknown default: .caption
82 }
83 }
84}
Sources/OrgSwiftUI/OrgLayout.swift added +77
@@ -0,0 +1,77 @@
1import OrgSwift
2import SwiftUI
3
4/// A document prepared for display: inline runs already styled, footnotes already numbered,
5/// image URLs already resolved, code already highlighted.
6///
7/// The walk happens once, ahead of `body`, because three things it does must not happen per
8/// layout pass — footnote numbering is sequential and document-wide, syntax highlighting is
9/// expensive, and both would otherwise re-run every time SwiftUI recomputes a view.
10struct OrgLayout {
11 var blocks: [OrgBlock] = []
12 var footnotes: [OrgFootnote] = []
13}
14
15struct OrgFootnote: Identifiable {
16 var number: Int
17 var text: AttributedString
18 var id: Int { number }
19}
20
21/// A block ready to render. Mirrors ``OrgElement`` minus the cases that resolve away:
22/// footnote definitions move to ``OrgLayout/footnotes``, and an export block for another
23/// backend drops entirely.
24indirect enum OrgBlock: Identifiable {
25 case heading(text: AttributedString, level: Int)
26 case paragraph(AttributedString)
27 case code(AttributedString)
28 case quote([OrgBlock])
29 case center([OrgBlock])
30 case verse([AttributedString])
31 case special(name: String, blocks: [OrgBlock])
32 case list(OrgListLayout)
33 case table(OrgTableLayout)
34 case image(source: URL, alt: String, caption: AttributedString?)
35 case captioned(caption: AttributedString?, blocks: [OrgBlock])
36 case rule
37
38 /// Positional identity. Blocks are rebuilt wholesale when the source changes, so their
39 /// index in the layout is stable for as long as the layout is.
40 var id: String { String(describing: self).prefix(64).description }
41}
42
43struct OrgListLayout {
44 var kind: OrgListKind
45 var items: [Item]
46
47 struct Item: Identifiable {
48 var checkbox: OrgCheckbox?
49 var term: AttributedString?
50 var paragraphs: [AttributedString]
51 var sublist: OrgListLayout?
52 var id = UUID()
53 }
54}
55
56struct OrgTableLayout {
57 /// Cell rows only; org's `|---|` rule rows are structure, carried by ``headerRowCount``.
58 var rows: [Row]
59 var headerRowCount: Int
60 var alignments: [OrgAlignment?]
61
62 struct Row: Identifiable {
63 var cells: [Cell]
64 var id = UUID()
65 }
66
67 struct Cell: Identifiable {
68 var text: AttributedString
69 var id = UUID()
70 }
71
72 var columnCount: Int { rows.map(\.cells.count).max() ?? 0 }
73
74 func alignment(column: Int) -> OrgAlignment? {
75 column < alignments.count ? alignments[column] : nil
76 }
77}
Sources/OrgSwiftUI/OrgLayoutBuilder.swift added +177
@@ -0,0 +1,177 @@
1import OrgSwift
2import SwiftUI
3
4/// Walks an ``OrgDocument`` into an ``OrgLayout``.
5///
6/// The third renderer over the shared tree, alongside HTML and `AttributedString`. It
7/// resolves URLs through the same ``OrgURLResolver`` and numbers footnotes through the same
8/// ``OrgFootnotes``, so a link that works in the HTML output works here and note 1 is note 1
9/// in both.
10@MainActor
11struct OrgLayoutBuilder {
12 var options: OrgRenderOptions
13 var styler: OrgCodeStyler
14
15 private var inline: OrgInlineStyler { OrgInlineStyler(options: options) }
16 private var resolver: OrgURLResolver { OrgURLResolver(options: options) }
17
18 func layout(_ document: OrgDocument) -> OrgLayout {
19 var notes = OrgFootnotes(document: document)
20 var layout = OrgLayout()
21 layout.blocks = document.elements.flatMap { block($0, &notes) }
22 // Numbering is complete only after the whole body is walked, so the notes section is
23 // built last — exactly as the HTML renderer does it.
24 layout.footnotes = notes.notes.map {
25 OrgFootnote(number: $0.number, text: text($0.content, &notes, style: .footnote))
26 }
27 return layout
28 }
29
30 private func text(
31 _ objects: [OrgObject], _ notes: inout OrgFootnotes, style: Font.TextStyle = .body
32 ) -> AttributedString {
33 inline.attributed(objects, &notes, style: style)
34 }
35
36 /// One element to zero or more blocks. Zero for a footnote definition (it moves to the
37 /// notes section) and for an export block aimed at another backend.
38 private func block(_ element: OrgElement, _ notes: inout OrgFootnotes) -> [OrgBlock] {
39 switch element {
40 case .heading(let heading):
41 var title = AttributedString()
42 if let todo = heading.todo {
43 var keyword = AttributedString(todo + " ")
44 keyword.foregroundColor = todo == "DONE" ? .green : .orange
45 title += keyword
46 }
47 if let priority = heading.priority {
48 var cookie = AttributedString("[#\(priority)] ")
49 cookie.foregroundColor = .secondary
50 title += cookie
51 }
52 let level = min(6, max(1, heading.level + options.headingLevelOffset))
53 title += text(heading.title, &notes, style: Self.headingStyle(level))
54 for tag in heading.tags {
55 var chip = AttributedString(" \(tag)")
56 chip.foregroundColor = .secondary
57 chip.font = .system(.caption, design: .monospaced)
58 title += chip
59 }
60 return [.heading(text: title, level: level)]
61
62 case .paragraph(let objects):
63 return [.paragraph(text(objects, &notes))]
64
65 case .list(let list):
66 return [.list(listLayout(list, &notes))]
67
68 case .table(let table):
69 return [.table(tableLayout(table, &notes))]
70
71 case .srcBlock(let language, let code):
72 return [.code(styled(code: code, language: language))]
73
74 case .exampleBlock(let raw):
75 return [.code(styled(code: raw, language: nil))]
76
77 case .quoteBlock(let children):
78 return [.quote(children.flatMap { block($0, &notes) })]
79
80 case .centerBlock(let children):
81 return [.center(children.flatMap { block($0, &notes) })]
82
83 case .verseBlock(let lines):
84 return [.verse(lines.map { text($0, &notes) })]
85
86 case .specialBlock(let name, let children):
87 return [.special(name: name, blocks: children.flatMap { block($0, &notes) })]
88
89 case .exportBlock(let backend, let raw):
90 // HTML passes through verbatim in the HTML renderer; natively there is nothing to
91 // pass it to, so it shows as the literal block it is rather than vanishing.
92 return backend == "html" ? [.code(styled(code: raw, language: nil))] : []
93
94 case .figure(let figure):
95 return [imageBlock(figure, &notes)]
96
97 case .captioned(_, let caption, let content):
98 return [.captioned(
99 caption: caption.map { text($0, &notes, style: .footnote) },
100 blocks: block(content, &notes)
101 )]
102
103 case .horizontalRule:
104 return [.rule]
105
106 case .footnoteDefinition:
107 return [] // collected into the notes section
108 }
109 }
110
111 /// An image whose source will not resolve to a safe absolute URL degrades to its alt
112 /// text, the same trade the HTML renderer makes rather than pointing at something unsafe.
113 ///
114 /// The absolute requirement is the one place this diverges from HTML, which may emit a
115 /// relative `src` for a document base URL to resolve. A view has no base, so a relative
116 /// source is unloadable and showing the text beats showing a broken image.
117 private func imageBlock(_ figure: OrgFigure, _ notes: inout OrgFootnotes) -> OrgBlock {
118 let caption = figure.caption.map { text($0, &notes, style: .footnote) }
119 guard let string = resolver.imageSource(figure.source),
120 let url = URL(string: string), url.scheme != nil else {
121 return .paragraph(AttributedString(figure.alt ?? figure.source))
122 }
123 let alt = figure.alt ?? figure.caption.map { OrgParser.plain($0) } ?? ""
124 return .image(source: url, alt: alt, caption: caption)
125 }
126
127 private func styled(code: String, language: String?) -> AttributedString {
128 if let highlighted = styler.highlighted(code: code, language: language) {
129 return highlighted
130 }
131 var plain = AttributedString(code)
132 plain.font = .system(.footnote, design: .monospaced)
133 return plain
134 }
135
136 private func listLayout(_ list: OrgList, _ notes: inout OrgFootnotes) -> OrgListLayout {
137 var items: [OrgListLayout.Item] = []
138 for item in list.items {
139 items.append(OrgListLayout.Item(
140 checkbox: item.checkbox,
141 term: item.term.map { term in
142 var styled = text(term, &notes)
143 styled.font = .body.bold()
144 return styled
145 },
146 paragraphs: item.content.map { text($0, &notes) },
147 sublist: item.sublist.map { listLayout($0, &notes) }
148 ))
149 }
150 return OrgListLayout(kind: list.kind, items: items)
151 }
152
153 private func tableLayout(_ table: OrgTable, _ notes: inout OrgFootnotes) -> OrgTableLayout {
154 var rows: [OrgTableLayout.Row] = []
155 for row in table.rows {
156 guard case .cells(let cells) = row else { continue }
157 rows.append(OrgTableLayout.Row(cells: cells.map {
158 OrgTableLayout.Cell(text: text($0, &notes, style: .subheadline))
159 }))
160 }
161 return OrgTableLayout(
162 rows: rows,
163 headerRowCount: table.headerRowCount,
164 alignments: table.alignments
165 )
166 }
167
168 private static func headingStyle(_ level: Int) -> Font.TextStyle {
169 switch level {
170 case 1: .title
171 case 2: .title2
172 case 3: .title3
173 case 4: .headline
174 default: .subheadline
175 }
176 }
177}
Sources/OrgSwiftUI/OrgListView.swift added +88
@@ -0,0 +1,88 @@
1import OrgSwift
2import SwiftUI
3
4/// A list, recursing into its own sublists.
5///
6/// SwiftUI has no list-layout primitive, so the marker sits in its own column and the content
7/// hangs beside it — wrapped lines line up under the text, not under the bullet.
8struct OrgListView: View {
9 let list: OrgListLayout
10 /// Ordered numbering restarts at each nesting level, as in org.
11 var body: some View {
12 VStack(alignment: .leading, spacing: 6) {
13 ForEach(Array(list.items.enumerated()), id: \.element.id) { index, item in
14 if list.kind == .description {
15 descriptionItem(item)
16 } else {
17 markedItem(item, marker: marker(at: index))
18 }
19 }
20 }
21 }
22
23 private func marker(at index: Int) -> String {
24 list.kind == .ordered ? "\(index + 1)." : "•"
25 }
26
27 private func markedItem(_ item: OrgListLayout.Item, marker: String) -> some View {
28 HStack(alignment: .firstTextBaseline, spacing: 8) {
29 Text(marker)
30 .monospacedDigit()
31 .foregroundStyle(.secondary)
32 VStack(alignment: .leading, spacing: 6) {
33 if let checkbox = item.checkbox {
34 HStack(alignment: .firstTextBaseline, spacing: 6) {
35 Image(systemName: Self.symbol(for: checkbox))
36 .foregroundStyle(checkbox == .on ? Color.accentColor : .secondary)
37 .accessibilityLabel(Self.label(for: checkbox))
38 paragraphs(item)
39 }
40 } else {
41 paragraphs(item)
42 }
43 if let sublist = item.sublist {
44 OrgListView(list: sublist)
45 }
46 }
47 }
48 }
49
50 /// `term :: definition` — the term above, the definition indented under it.
51 private func descriptionItem(_ item: OrgListLayout.Item) -> some View {
52 VStack(alignment: .leading, spacing: 2) {
53 if let term = item.term {
54 Text(term).fixedSize(horizontal: false, vertical: true)
55 }
56 paragraphs(item).padding(.leading, 16)
57 if let sublist = item.sublist {
58 OrgListView(list: sublist).padding(.leading, 16)
59 }
60 }
61 }
62
63 private func paragraphs(_ item: OrgListLayout.Item) -> some View {
64 VStack(alignment: .leading, spacing: 6) {
65 ForEach(Array(item.paragraphs.enumerated()), id: \.offset) { _, paragraph in
66 Text(paragraph)
67 .fixedSize(horizontal: false, vertical: true)
68 .frame(maxWidth: .infinity, alignment: .leading)
69 }
70 }
71 }
72
73 private static func symbol(for checkbox: OrgCheckbox) -> String {
74 switch checkbox {
75 case .on: "checkmark.square.fill"
76 case .off: "square"
77 case .partial: "minus.square"
78 }
79 }
80
81 private static func label(for checkbox: OrgCheckbox) -> String {
82 switch checkbox {
83 case .on: "Done"
84 case .off: "Not done"
85 case .partial: "Partly done"
86 }
87 }
88}
Sources/OrgSwiftUI/OrgTableView.swift added +56
@@ -0,0 +1,56 @@
1import OrgSwift
2import SwiftUI
3
4/// A table, in a `Grid` that scrolls sideways when it is wider than the screen.
5///
6/// Column alignment comes from org's `|---+:---:+---|` separator row. `Grid` takes it from
7/// one cell per column, so it is set on the first row and inherited down.
8struct OrgTableView: View {
9 let table: OrgTableLayout
10
11 var body: some View {
12 ScrollView(.horizontal, showsIndicators: false) {
13 Grid(alignment: .topLeading, horizontalSpacing: 14, verticalSpacing: 7) {
14 ForEach(Array(table.rows.enumerated()), id: \.element.id) { index, row in
15 GridRow {
16 ForEach(0 ..< table.columnCount, id: \.self) { column in
17 cell(row, column: column, isHeader: index < table.headerRowCount)
18 }
19 }
20 // The rule row separating the header band from the body.
21 if index + 1 == table.headerRowCount {
22 Divider().gridCellColumns(table.columnCount)
23 }
24 }
25 }
26 .padding(.vertical, 2)
27 }
28 }
29
30 @ViewBuilder
31 private func cell(_ row: OrgTableLayout.Row, column: Int, isHeader: Bool) -> some View {
32 // Org tolerates ragged rows; a missing cell is empty, not a crash.
33 let text = column < row.cells.count ? row.cells[column].text : AttributedString()
34 Text(text)
35 .fontWeight(isHeader ? .semibold : .regular)
36 .multilineTextAlignment(Self.textAlignment(table.alignment(column: column)))
37 .fixedSize(horizontal: false, vertical: true)
38 .gridColumnAlignment(Self.gridAlignment(table.alignment(column: column)))
39 }
40
41 private static func gridAlignment(_ alignment: OrgAlignment?) -> HorizontalAlignment {
42 switch alignment {
43 case .center: .center
44 case .right: .trailing
45 case .left, nil: .leading
46 }
47 }
48
49 private static func textAlignment(_ alignment: OrgAlignment?) -> TextAlignment {
50 switch alignment {
51 case .center: .center
52 case .right: .trailing
53 case .left, nil: .leading
54 }
55 }
56}
Sources/OrgSwiftUI/OrgView.swift added +70
@@ -0,0 +1,70 @@
1import OrgSwift
2import SwiftUI
3
4/// Renders org-mode as native SwiftUI.
5///
6/// ```swift
7/// OrgView(source, options: OrgRenderOptions(host: "gitbay.org", owner: "krz",
8/// repositoryName: "org-swift"))
9/// ```
10///
11/// The parse, footnote numbering, URL resolution and syntax highlighting run once off
12/// `body`, in a task keyed to the source, so scrolling and Dynamic Type changes do not
13/// re-derive them. Text is selectable, links open through the environment's `openURL`, and
14/// everything scales with Dynamic Type.
15public struct OrgView: View {
16 private let source: String
17 private let options: OrgRenderOptions
18 private let styler: OrgCodeStyler
19
20 @State private var layout = OrgLayout()
21
22 public init(
23 _ source: String,
24 options: OrgRenderOptions = .init(),
25 styler: OrgCodeStyler = PlainOrgCodeStyler()
26 ) {
27 self.source = source
28 self.options = options
29 self.styler = styler
30 }
31
32 public var body: some View {
33 VStack(alignment: .leading, spacing: 12) {
34 OrgBlockListView(blocks: layout.blocks)
35 if !layout.footnotes.isEmpty {
36 OrgFootnotesView(footnotes: layout.footnotes)
37 }
38 }
39 .frame(maxWidth: .infinity, alignment: .leading)
40 .textSelection(.enabled)
41 .task(id: source) {
42 layout = OrgLayoutBuilder(options: options, styler: styler)
43 .layout(OrgParser.parse(source))
44 }
45 }
46}
47
48/// The notes section org's exporter puts at the end of a document.
49struct OrgFootnotesView: View {
50 let footnotes: [OrgFootnote]
51
52 var body: some View {
53 VStack(alignment: .leading, spacing: 6) {
54 Divider()
55 ForEach(footnotes) { note in
56 HStack(alignment: .firstTextBaseline, spacing: 6) {
57 Text("\(note.number).")
58 .font(.footnote)
59 .monospacedDigit()
60 .foregroundStyle(.secondary)
61 Text(note.text)
62 .fixedSize(horizontal: false, vertical: true)
63 .frame(maxWidth: .infinity, alignment: .leading)
64 }
65 }
66 }
67 .padding(.top, 8)
68 .accessibilityLabel("Footnotes")
69 }
70}
Tests/OrgSwiftUITests/OrgLayoutTests.swift added +193
@@ -0,0 +1,193 @@
1import Foundation
2import SwiftUI
3import Testing
4@testable import OrgSwift
5@testable import OrgSwiftUI
6
7/// Tests for the third renderer over the shared tree: `OrgDocument → OrgLayout`.
8///
9/// The views themselves are layout over this model, so the model is where the org behaviour
10/// lives and where it is worth asserting.
11@MainActor
12struct OrgLayoutTests {
13
14 private func layout(
15 _ source: String,
16 options: OrgRenderOptions = .init(),
17 styler: OrgCodeStyler = PlainOrgCodeStyler()
18 ) -> OrgLayout {
19 OrgLayoutBuilder(options: options, styler: styler).layout(OrgParser.parse(source))
20 }
21
22 private func plain(_ string: AttributedString) -> String { String(string.characters) }
23
24 @Test
25 func blocksCoverTheElementTree() {
26 let blocks = layout("""
27 * Heading
28
29 A paragraph.
30
31 - one
32 - two
33
34 | a | b |
35 |---+---|
36 | 1 | 2 |
37
38 #+begin_src swift
39 let x = 1
40 #+end_src
41
42 #+begin_quote
43 quoted
44 #+end_quote
45
46 -----
47 """).blocks
48
49 func has(_ predicate: (OrgBlock) -> Bool) -> Bool { blocks.contains(where: predicate) }
50 #expect(has { if case .heading = $0 { true } else { false } })
51 #expect(has { if case .paragraph = $0 { true } else { false } })
52 #expect(has { if case .list = $0 { true } else { false } })
53 #expect(has { if case .table = $0 { true } else { false } })
54 #expect(has { if case .code = $0 { true } else { false } })
55 #expect(has { if case .quote = $0 { true } else { false } })
56 #expect(has { if case .rule = $0 { true } else { false } })
57 }
58
59 /// Numbering is document-wide and the definitions leave the body, exactly as in HTML.
60 @Test
61 func footnotesAreNumberedAndMovedToTheSection() {
62 let result = layout("""
63 First[fn:b] and second[fn:a].
64
65 [fn:a] note a
66 [fn:b] note b
67 """)
68
69 #expect(result.footnotes.map(\.number) == [1, 2])
70 #expect(plain(result.footnotes[0].text) == "note b")
71 #expect(plain(result.footnotes[1].text) == "note a")
72
73 guard case .paragraph(let text) = result.blocks[0] else {
74 Issue.record("expected a paragraph"); return
75 }
76 #expect(plain(text) == "First[1] and second[2].")
77 // The definitions are not also blocks in the body.
78 #expect(result.blocks.count == 1)
79 }
80
81 @Test
82 func nestedListsRecurse() {
83 guard case .list(let list) = layout("""
84 - outer
85 - inner
86 - deepest
87 """).blocks.first else {
88 Issue.record("expected a list"); return
89 }
90 #expect(list.kind == .unordered)
91 #expect(plain(list.items[0].paragraphs[0]) == "outer")
92 let inner = list.items[0].sublist
93 #expect(plain(inner?.items[0].paragraphs[0] ?? "") == "inner")
94 #expect(plain(inner?.items[0].sublist?.items[0].paragraphs[0] ?? "") == "deepest")
95 }
96
97 @Test
98 func checkboxesAndDescriptionListsSurvive() {
99 guard case .list(let tasks) = layout("- [X] done\n- [ ] todo").blocks.first else {
100 Issue.record("expected a list"); return
101 }
102 #expect(tasks.items.map(\.checkbox) == [.on, .off])
103
104 guard case .list(let described) = layout("- term :: definition").blocks.first else {
105 Issue.record("expected a description list"); return
106 }
107 #expect(described.kind == .description)
108 #expect(plain(described.items[0].term ?? "") == "term")
109 #expect(plain(described.items[0].paragraphs[0]) == "definition")
110 }
111
112 /// Rule rows are structure, not content: they set the header band and then disappear.
113 @Test
114 func tableDropsRuleRowsAndKeepsAlignments() {
115 guard case .table(let table) = layout("""
116 | name | count |
117 |------+------:|
118 | a | 1 |
119 | b | 2 |
120 """).blocks.first else {
121 Issue.record("expected a table"); return
122 }
123 #expect(table.rows.count == 3)
124 #expect(table.headerRowCount == 1)
125 #expect(table.columnCount == 2)
126 #expect(table.alignment(column: 1) == .right)
127 #expect(plain(table.rows[0].cells[0].text) == "name")
128 #expect(plain(table.rows[2].cells[1].text) == "2")
129 }
130
131 @Test
132 func imagesResolveRelativeSourcesAndDegradeWhenUnsafe() {
133 let options = OrgRenderOptions(
134 host: "gitbay.org", owner: "krz", repositoryName: "org-swift",
135 ref: "HEAD", readmePath: "README.org", imagePathSegment: "raw"
136 )
137 guard case .image(let source, _, _) = layout("[[file:docs/logo.png]]", options: options)
138 .blocks.first else {
139 Issue.record("expected an image"); return
140 }
141 #expect(source.absoluteString == "https://gitbay.org/krz/org-swift/raw/HEAD/docs/logo.png")
142
143 // With no repository context the relative path cannot be made absolute, so it shows
144 // as text rather than as a broken image.
145 guard case .paragraph(let text) = layout("[[file:docs/logo.png]]").blocks.first else {
146 Issue.record("expected a paragraph fallback"); return
147 }
148 #expect(plain(text) == "docs/logo.png")
149 }
150
151 @Test
152 func codeStylerIsUsedWhenItAnswers() {
153 struct Stub: OrgCodeStyler {
154 func highlighted(code: String, language: String?) -> AttributedString? {
155 language == "swift" ? AttributedString("STYLED:\(code.count)") : nil
156 }
157 }
158 guard case .code(let styled) = layout(
159 "#+begin_src swift\nlet x = 1\n#+end_src", styler: Stub()
160 ).blocks.first else {
161 Issue.record("expected a code block"); return
162 }
163 #expect(plain(styled).hasPrefix("STYLED:"))
164
165 // An unknown language falls back to plain monospaced text, never to nothing.
166 guard case .code(let plainCode) = layout(
167 "#+begin_src brainfuck\n+++\n#+end_src", styler: Stub()
168 ).blocks.first else {
169 Issue.record("expected a code block"); return
170 }
171 #expect(plain(plainCode) == "+++")
172 }
173
174 /// HTML export has nowhere to go natively, so it shows as the literal block it is; other
175 /// backends drop, as they do in HTML.
176 @Test
177 func exportBlocksShowHTMLLiterallyAndDropOtherBackends() {
178 #expect(layout("#+begin_export html\n<b>hi</b>\n#+end_export").blocks.count == 1)
179 #expect(layout("#+begin_export latex\n\\emph{hi}\n#+end_export").blocks.isEmpty)
180 }
181
182 /// Emphasis nests, and the styler turns intents into fonts rather than dropping them.
183 @Test
184 func inlineStylingCarriesNestedEmphasis() {
185 guard case .paragraph(let text) = layout("*bold /italic/* and ~code~").blocks.first else {
186 Issue.record("expected a paragraph"); return
187 }
188 #expect(plain(text) == "bold italic and code")
189 // Every emphasised or code run got a concrete font; unstyled runs inherit the view's.
190 let fonts = text.runs.compactMap(\.font)
191 #expect(fonts.count >= 3)
192 }
193}