Sources/OrgSwiftUI/OrgLayoutBuilder.swift
218 lines · 9175 bytes
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 var keywords: OrgKeywordStyle
15
16 private var inline: OrgInlineStyler { OrgInlineStyler(options: options) }
17 private var resolver: OrgURLResolver { OrgURLResolver(options: options) }
18
19 func layout(_ document: OrgDocument) -> OrgLayout {
20 var notes = OrgFootnotes(document: document)
21 var layout = OrgLayout()
22 layout.blocks = document.elements.flatMap { block($0, ¬es) }
23 // Numbering is complete only after the whole body is walked, so the notes section is
24 // built last — exactly as the HTML renderer does it.
25 layout.footnotes = notes.notes.map {
26 OrgFootnote(number: $0.number, text: text($0.content, ¬es, style: .footnote))
27 }
28 return layout
29 }
30
31 private func text(
32 _ objects: [OrgObject], _ notes: inout OrgFootnotes,
33 style: Font.TextStyle = .body, bold: Bool = false
34 ) -> AttributedString {
35 inline.attributed(objects, ¬es, style: style, bold: bold)
36 }
37
38 /// One element to zero or more blocks. Zero for a footnote definition (it moves to the
39 /// notes section) and for an export block aimed at another backend.
40 private func block(_ element: OrgElement, _ notes: inout OrgFootnotes) -> [OrgBlock] {
41 switch element {
42 case .heading(let heading):
43 var title = AttributedString()
44 if let todo = heading.todo {
45 var keyword = AttributedString(todo + " ")
46 keyword.foregroundColor = keywords.color(for: todo)
47 title += keyword
48 }
49 if let priority = heading.priority {
50 var cookie = AttributedString("[#\(priority)] ")
51 cookie.foregroundColor = .secondary
52 title += cookie
53 }
54 let level = min(6, max(1, heading.level + options.headingLevelOffset))
55 title += text(heading.title, ¬es, style: Self.headingStyle(level), bold: true)
56 for tag in heading.tags {
57 var chip = AttributedString(" \(tag)")
58 chip.foregroundColor = .secondary
59 chip.font = .system(.caption, design: .monospaced)
60 title += chip
61 }
62 return [.heading(text: title, level: level)]
63
64 case .paragraph(let objects):
65 if let badges = badgeRow(objects) { return [.badges(badges)] }
66 return [.paragraph(text(objects, ¬es))]
67
68 case .list(let list):
69 return [.list(listLayout(list, ¬es))]
70
71 case .table(let table):
72 return [.table(tableLayout(table, ¬es))]
73
74 case .srcBlock(let language, let code):
75 return [.code(styled(code: code, language: language))]
76
77 case .exampleBlock(let raw):
78 return [.code(styled(code: raw, language: nil))]
79
80 case .quoteBlock(let children):
81 return [.quote(children.flatMap { block($0, ¬es) })]
82
83 case .centerBlock(let children):
84 return [.center(children.flatMap { block($0, ¬es) })]
85
86 case .verseBlock(let lines):
87 return [.verse(lines.map { text($0, ¬es) })]
88
89 case .specialBlock(let name, let children):
90 return [.special(name: name, blocks: children.flatMap { block($0, ¬es) })]
91
92 case .exportBlock(let backend, let raw):
93 // HTML passes through verbatim in the HTML renderer; natively there is nothing to
94 // pass it to, so it shows as the literal block it is rather than vanishing.
95 return backend == "html" ? [.code(styled(code: raw, language: nil))] : []
96
97 case .figure(let figure):
98 return [imageBlock(figure, ¬es)]
99
100 case .captioned(_, let caption, let content):
101 return [.captioned(
102 caption: caption.map { text($0, ¬es, style: .footnote) },
103 blocks: block(content, ¬es)
104 )]
105
106 case .horizontalRule:
107 return [.rule]
108
109 case .footnoteDefinition:
110 return [] // collected into the notes section
111 }
112 }
113
114 /// A paragraph holding nothing but images and image links is the badge row a README
115 /// opens with. It renders as a row of images at their own size rather than as prose —
116 /// the inline styler can only show an image's alt text, and a figure block would blow a
117 /// 95×20 badge up to the column width.
118 ///
119 /// Anything else in the paragraph, a word of prose included, means it is prose.
120 private func badgeRow(_ objects: [OrgObject]) -> [OrgBadge]? {
121 var badges: [OrgBadge] = []
122 for object in objects {
123 switch object {
124 case .text(let string) where string.trimmingCharacters(in: .whitespaces).isEmpty:
125 continue
126 case .image(let figure):
127 guard let badge = badge(figure, link: nil) else { return nil }
128 badges.append(badge)
129 case .link(let link):
130 guard link.description?.count == 1,
131 case .image(let figure) = link.description?.first
132 else { return nil }
133 // A badge whose link will not resolve is still a badge; it just is not
134 // tappable.
135 let href = resolver.href(for: link.target).flatMap { URL(string: $0) }
136 guard let badge = badge(figure, link: href) else { return nil }
137 badges.append(badge)
138 default:
139 return nil
140 }
141 }
142 return badges.isEmpty ? nil : badges
143 }
144
145 private func badge(_ figure: OrgFigure, link: URL?) -> OrgBadge? {
146 guard let string = resolver.imageSource(figure.source),
147 let url = URL(string: string), url.scheme != nil
148 else { return nil }
149 return OrgBadge(source: url, alt: figure.alt ?? "", link: link)
150 }
151
152 /// An image whose source will not resolve to a safe absolute URL degrades to its alt
153 /// text, the same trade the HTML renderer makes rather than pointing at something unsafe.
154 ///
155 /// The absolute requirement is the one place this diverges from HTML, which may emit a
156 /// relative `src` for a document base URL to resolve. A view has no base, so a relative
157 /// source is unloadable and showing the text beats showing a broken image.
158 private func imageBlock(_ figure: OrgFigure, _ notes: inout OrgFootnotes) -> OrgBlock {
159 let caption = figure.caption.map { text($0, ¬es, style: .footnote) }
160 guard let string = resolver.imageSource(figure.source),
161 let url = URL(string: string), url.scheme != nil else {
162 return .paragraph(AttributedString(figure.alt ?? figure.source))
163 }
164 let alt = figure.alt ?? figure.caption.map { OrgParser.plain($0) } ?? ""
165 return .image(source: url, alt: alt, caption: caption)
166 }
167
168 private func styled(code: String, language: String?) -> AttributedString {
169 if let highlighted = styler.highlighted(code: code, language: language) {
170 return highlighted
171 }
172 var plain = AttributedString(code)
173 plain.font = .system(.footnote, design: .monospaced)
174 return plain
175 }
176
177 private func listLayout(_ list: OrgList, _ notes: inout OrgFootnotes) -> OrgListLayout {
178 var items: [OrgListLayout.Item] = []
179 for item in list.items {
180 items.append(OrgListLayout.Item(
181 checkbox: item.checkbox,
182 term: item.term.map { term in
183 var styled = text(term, ¬es)
184 styled.font = .body.bold()
185 return styled
186 },
187 paragraphs: item.content.map { text($0, ¬es) },
188 sublist: item.sublist.map { listLayout($0, ¬es) }
189 ))
190 }
191 return OrgListLayout(kind: list.kind, items: items)
192 }
193
194 private func tableLayout(_ table: OrgTable, _ notes: inout OrgFootnotes) -> OrgTableLayout {
195 var rows: [OrgTableLayout.Row] = []
196 for row in table.rows {
197 guard case .cells(let cells) = row else { continue }
198 rows.append(OrgTableLayout.Row(cells: cells.map {
199 OrgTableLayout.Cell(text: text($0, ¬es, style: .subheadline))
200 }))
201 }
202 return OrgTableLayout(
203 rows: rows,
204 headerRowCount: table.headerRowCount,
205 alignments: table.alignments
206 )
207 }
208
209 private static func headingStyle(_ level: Int) -> Font.TextStyle {
210 switch level {
211 case 1: .title
212 case 2: .title2
213 case 3: .title3
214 case 4: .headline
215 default: .subheadline
216 }
217 }
218}