krz/org-swift

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

Sources/OrgSwiftUI/OrgLayoutBuilder.swift

v0.2.0
org-swift/Sources/OrgSwiftUI/OrgLayoutBuilder.swift history · blame · raw

178 lines · 7282 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
 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,
 32        style: Font.TextStyle = .body, bold: Bool = false
 33    ) -> AttributedString {
 34        inline.attributed(objects, &notes, style: style, bold: bold)
 35    }
 36
 37    /// One element to zero or more blocks. Zero for a footnote definition (it moves to the
 38    /// notes section) and for an export block aimed at another backend.
 39    private func block(_ element: OrgElement, _ notes: inout OrgFootnotes) -> [OrgBlock] {
 40        switch element {
 41        case .heading(let heading):
 42            var title = AttributedString()
 43            if let todo = heading.todo {
 44                var keyword = AttributedString(todo + " ")
 45                keyword.foregroundColor = todo == "DONE" ? .green : .orange
 46                title += keyword
 47            }
 48            if let priority = heading.priority {
 49                var cookie = AttributedString("[#\(priority)] ")
 50                cookie.foregroundColor = .secondary
 51                title += cookie
 52            }
 53            let level = min(6, max(1, heading.level + options.headingLevelOffset))
 54            title += text(heading.title, &notes, style: Self.headingStyle(level), bold: true)
 55            for tag in heading.tags {
 56                var chip = AttributedString("  \(tag)")
 57                chip.foregroundColor = .secondary
 58                chip.font = .system(.caption, design: .monospaced)
 59                title += chip
 60            }
 61            return [.heading(text: title, level: level)]
 62
 63        case .paragraph(let objects):
 64            return [.paragraph(text(objects, &notes))]
 65
 66        case .list(let list):
 67            return [.list(listLayout(list, &notes))]
 68
 69        case .table(let table):
 70            return [.table(tableLayout(table, &notes))]
 71
 72        case .srcBlock(let language, let code):
 73            return [.code(styled(code: code, language: language))]
 74
 75        case .exampleBlock(let raw):
 76            return [.code(styled(code: raw, language: nil))]
 77
 78        case .quoteBlock(let children):
 79            return [.quote(children.flatMap { block($0, &notes) })]
 80
 81        case .centerBlock(let children):
 82            return [.center(children.flatMap { block($0, &notes) })]
 83
 84        case .verseBlock(let lines):
 85            return [.verse(lines.map { text($0, &notes) })]
 86
 87        case .specialBlock(let name, let children):
 88            return [.special(name: name, blocks: children.flatMap { block($0, &notes) })]
 89
 90        case .exportBlock(let backend, let raw):
 91            // HTML passes through verbatim in the HTML renderer; natively there is nothing to
 92            // pass it to, so it shows as the literal block it is rather than vanishing.
 93            return backend == "html" ? [.code(styled(code: raw, language: nil))] : []
 94
 95        case .figure(let figure):
 96            return [imageBlock(figure, &notes)]
 97
 98        case .captioned(_, let caption, let content):
 99            return [.captioned(
100                caption: caption.map { text($0, &notes, style: .footnote) },
101                blocks: block(content, &notes)
102            )]
103
104        case .horizontalRule:
105            return [.rule]
106
107        case .footnoteDefinition:
108            return []  // collected into the notes section
109        }
110    }
111
112    /// An image whose source will not resolve to a safe absolute URL degrades to its alt
113    /// text, the same trade the HTML renderer makes rather than pointing at something unsafe.
114    ///
115    /// The absolute requirement is the one place this diverges from HTML, which may emit a
116    /// relative `src` for a document base URL to resolve. A view has no base, so a relative
117    /// source is unloadable and showing the text beats showing a broken image.
118    private func imageBlock(_ figure: OrgFigure, _ notes: inout OrgFootnotes) -> OrgBlock {
119        let caption = figure.caption.map { text($0, &notes, style: .footnote) }
120        guard let string = resolver.imageSource(figure.source),
121              let url = URL(string: string), url.scheme != nil else {
122            return .paragraph(AttributedString(figure.alt ?? figure.source))
123        }
124        let alt = figure.alt ?? figure.caption.map { OrgParser.plain($0) } ?? ""
125        return .image(source: url, alt: alt, caption: caption)
126    }
127
128    private func styled(code: String, language: String?) -> AttributedString {
129        if let highlighted = styler.highlighted(code: code, language: language) {
130            return highlighted
131        }
132        var plain = AttributedString(code)
133        plain.font = .system(.footnote, design: .monospaced)
134        return plain
135    }
136
137    private func listLayout(_ list: OrgList, _ notes: inout OrgFootnotes) -> OrgListLayout {
138        var items: [OrgListLayout.Item] = []
139        for item in list.items {
140            items.append(OrgListLayout.Item(
141                checkbox: item.checkbox,
142                term: item.term.map { term in
143                    var styled = text(term, &notes)
144                    styled.font = .body.bold()
145                    return styled
146                },
147                paragraphs: item.content.map { text($0, &notes) },
148                sublist: item.sublist.map { listLayout($0, &notes) }
149            ))
150        }
151        return OrgListLayout(kind: list.kind, items: items)
152    }
153
154    private func tableLayout(_ table: OrgTable, _ notes: inout OrgFootnotes) -> OrgTableLayout {
155        var rows: [OrgTableLayout.Row] = []
156        for row in table.rows {
157            guard case .cells(let cells) = row else { continue }
158            rows.append(OrgTableLayout.Row(cells: cells.map {
159                OrgTableLayout.Cell(text: text($0, &notes, style: .subheadline))
160            }))
161        }
162        return OrgTableLayout(
163            rows: rows,
164            headerRowCount: table.headerRowCount,
165            alignments: table.alignments
166        )
167    }
168
169    private static func headingStyle(_ level: Int) -> Font.TextStyle {
170        switch level {
171        case 1: .title
172        case 2: .title2
173        case 3: .title3
174        case 4: .headline
175        default: .subheadline
176        }
177    }
178}