Add OrgSwiftUI, a native renderer for the shared tree !2
18 files changed, +1201 −125
Layout: unified · split
ARCHITECTURE.md +45 −7
| @@ -4,17 +4,27 @@ OrgSwift parses org into an element tree and renders that tree. Parsing happens | |||
| 4 | output format is a walk over the result. | 4 | output format is a walk over the result. |
| 5 | 5 | ||
| 6 | ``` | 6 | ``` |
| 7 | Sources/OrgSwift/ | 7 | Sources/OrgSwift/ Foundation only |
| 8 | AST/ | 8 | AST/ |
| 9 | OrgDocument.swift the element tree | 9 | OrgDocument.swift the element tree |
| 10 | OrgParser.swift source → OrgDocument (blocks) | 10 | OrgParser.swift source → OrgDocument (blocks) |
| 11 | OrgInlineParser.swift text → [OrgObject] (inlines) | 11 | OrgInlineParser.swift text → [OrgObject] (inlines) |
| 12 | OrgHTMLTreeRenderer.swift OrgDocument → HTML | 12 | OrgHTMLTreeRenderer.swift OrgDocument → HTML |
| 13 | OrgAttributedStringRenderer.swift [OrgObject] → AttributedString | 13 | OrgAttributedStringRenderer.swift [OrgObject] → AttributedString |
| 14 | OrgFootnotes.swift document-wide numbering, shared by renderers | ||
| 15 | OrgURLResolver.swift typed target → safe URL, shared by renderers | ||
| 14 | OrgRenderer.swift the public entry point, plus OrgRenderOptions | 16 | OrgRenderer.swift the public entry point, plus OrgRenderOptions |
| 15 | Escaping.swift, Links.swift, … shared helpers: escaping, URL sanitising and | 17 | Escaping.swift, Links.swift, … shared helpers: escaping, URL sanitising and |
| 16 | repository-relative resolution, line predicates | 18 | repository-relative resolution, line predicates |
| 17 | Skeleton.swift the HTML → semantic-skeleton reduction used by tests | 19 | Skeleton.swift the HTML → semantic-skeleton reduction used by tests |
| 20 | |||
| 21 | Sources/OrgSwiftUI/ depends on OrgSwift | ||
| 22 | OrgView.swift the public entry point | ||
| 23 | OrgLayout.swift the display model | ||
| 24 | OrgLayoutBuilder.swift OrgDocument → OrgLayout | ||
| 25 | OrgInlineStyler.swift [OrgObject] → styled AttributedString | ||
| 26 | OrgBlockView.swift, OrgListView.swift, OrgTableView.swift | ||
| 27 | OrgCodeStyler.swift the app's syntax highlighter, as a protocol | ||
| 18 | ``` | 28 | ``` |
| 19 | 29 | ||
| 20 | `OrgRenderer.renderToHTML` is a thin façade over `OrgParser.parse` + `OrgHTMLTreeRenderer`. | 30 | `OrgRenderer.renderToHTML` is a thin façade over `OrgParser.parse` + `OrgHTMLTreeRenderer`. |
| @@ -29,7 +39,12 @@ format would have meant re-deriving the parse rather than reusing it. | |||
| 29 | 39 | ||
| 30 | With a tree, `OrgAttributedStringRenderer` is roughly 140 lines and shares the parse | 40 | With a tree, `OrgAttributedStringRenderer` is roughly 140 lines and shares the parse |
| 31 | entirely. It is **Foundation-only** — no SwiftUI — so it works server-side, in a CLI, | 41 | entirely. It is **Foundation-only** — no SwiftUI — so it works server-side, in a CLI, |
| 32 | anywhere; a SwiftUI layer would sit on top of it for the inline runs inside each block. | 42 | anywhere. `OrgSwiftUI` sits on top of it for the inline runs inside each block, and is a |
| 43 | separate product so callers who want HTML never import SwiftUI. | ||
| 44 | |||
| 45 | Three renderers now share one parse, one URL resolver (`OrgURLResolver`) and one footnote | ||
| 46 | numbering (`OrgFootnotes`), both `package`-scoped. That sharing is the point: a link that | ||
| 47 | resolves in HTML resolves natively, and note 1 is note 1 in both. | ||
| 33 | 48 | ||
| 34 | The types mirror orgo's `model.rs` — `OrgElement`/`OrgObject` against orgo's | 49 | The types mirror orgo's `model.rs` — `OrgElement`/`OrgObject` against orgo's |
| 35 | `Element`/`Object`, `OrgTableRow.{cells,rule}` against `TableRow::{Cells,Rule}`, the same | 50 | `Element`/`Object`, `OrgTableRow.{cells,rule}` against `TableRow::{Cells,Rule}`, the same |
| @@ -70,10 +85,33 @@ text. orgo applies the `--` rule to both bracket kinds, requiring only that the | |||
| 70 | on activeness, so the new behaviour is the more correct one. It is asserted directly in | 85 | on activeness, so the new behaviour is the more correct one. It is asserted directly in |
| 71 | `joinsInactiveTimestampRanges`. | 86 | `joinsInactiveTimestampRanges`. |
| 72 | 87 | ||
| 88 | ## OrgSwiftUI | ||
| 89 | |||
| 90 | `OrgView(source, options:styler:)` renders org as native SwiftUI: selectable text, Dynamic | ||
| 91 | Type, VoiceOver, and no web view. | ||
| 92 | |||
| 93 | The walk runs **off `body`**, in a `.task` keyed to the source, producing an `OrgLayout` the | ||
| 94 | views then render as pure layout. Three things force that. Footnote numbering is sequential | ||
| 95 | and document-wide, so it cannot happen in a `@ViewBuilder`. Syntax highlighting is expensive | ||
| 96 | and must not re-run per layout pass. And SwiftUI re-initialises views freely, so `init` is | ||
| 97 | not a safe place either. | ||
| 98 | |||
| 99 | Tables are the interesting part: `Grid`/`GridRow` with `.gridColumnAlignment()` set on the | ||
| 100 | first row and inherited down, wrapped in a horizontal `ScrollView` for phone-width overflow. | ||
| 101 | `OrgTable` already carried the rows, the rule position and the per-column alignments. | ||
| 102 | |||
| 103 | Syntax highlighting arrives through `OrgCodeStyler`, the native counterpart to | ||
| 104 | `CodeHighlighter` — returning `AttributedString` where the other returns HTML. Keeping it a | ||
| 105 | protocol is what lets the package stay dependency-free. | ||
| 106 | |||
| 107 | 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 | ||
| 109 | URL to resolve, and a view has no base, so a relative source is unloadable. | ||
| 110 | |||
| 111 | Not covered natively: `#+BEGIN_EXPORT html` passes through verbatim in HTML and has nowhere | ||
| 112 | to go in a view, so it renders as the literal block it is. | ||
| 113 | |||
| 73 | ## Next | 114 | ## Next |
| 74 | 115 | ||
| 75 | A SwiftUI renderer belongs in a **separate product** depending on this one, so the parser and | 116 | The corpus measures HTML only. Tree-level conformance would let `OrgLayout` be measured |
| 76 | HTML renderer stay Foundation-only and callers who want HTML never import SwiftUI. Tables are | 117 | against the same goldens instead of only its own unit tests. |
| 77 | the interesting part: `Grid`/`GridRow` with `.gridColumnAlignment()`, wrapped in a horizontal | ||
| 78 | `ScrollView` for phone-width overflow — the approach MarkdownUI uses. `OrgTable` already | ||
| 79 | carries the rows, the rule position, and the per-column alignments that needs. | ||
Package.swift +9 −2
| @@ -8,13 +8,20 @@ let package = Package( | |||
| 8 | .iOS(.v16) | 8 | .iOS(.v16) |
| 9 | ], | 9 | ], |
| 10 | products: [ | 10 | products: [ |
| 11 | .library(name: "OrgSwift", targets: ["OrgSwift"]) | 11 | .library(name: "OrgSwift", targets: ["OrgSwift"]), |
| 12 | .library(name: "OrgSwiftUI", targets: ["OrgSwiftUI"]) | ||
| 12 | ], | 13 | ], |
| 13 | targets: [ | 14 | targets: [ |
| 14 | .target( | 15 | .target( |
| 15 | name: "OrgSwift", | 16 | name: "OrgSwift", |
| 16 | swiftSettings: [.enableUpcomingFeature("BareSlashRegexLiterals")] | 17 | swiftSettings: [.enableUpcomingFeature("BareSlashRegexLiterals")] |
| 17 | ), | 18 | ), |
| 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"]) | ||
| 19 | ] | 26 | ] |
| 20 | ) | 27 | ) |
README.md +33 −2
| @@ -1,8 +1,11 @@ | |||
| 1 | # OrgSwift | 1 | # OrgSwift |
| 2 | 2 | ||
| 3 | A dependency-free Swift library that renders a practical subset of | 3 | A dependency-free Swift library that renders a practical subset of |
| 4 | [org-mode](https://orgmode.org) to sanitized HTML. Pure Foundation — no | 4 | [org-mode](https://orgmode.org). Two products: |
| 5 | SwiftUI, WebKit, UIKit, or third-party packages. | 5 | |
| 6 | - **`OrgSwift`** — org to sanitized HTML or `AttributedString`. Pure Foundation, | ||
| 7 | no SwiftUI, WebKit, UIKit, or third-party packages. | ||
| 8 | - **`OrgSwiftUI`** — org to native SwiftUI views, no web view involved. | ||
| 6 | 9 | ||
| 7 | Originally extracted from the hand-rolled renderer in the Hutch iOS client so it | 10 | Originally extracted from the hand-rolled renderer in the Hutch iOS client so it |
| 8 | could be shared across apps, and since rebuilt around an element tree: org is | 11 | could be shared across apps, and since rebuilt around an element tree: org is |
| @@ -58,6 +61,34 @@ let html = OrgRenderer.renderToHTML( | |||
| 58 | `./images/badge.svg` then resolves to | 61 | `./images/badge.svg` then resolves to |
| 59 | `https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg`. | 62 | `https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg`. |
| 60 | 63 | ||
| 64 | ### Native SwiftUI | ||
| 65 | |||
| 66 | `OrgSwiftUI` renders the same parse as views rather than HTML — selectable text, | ||
| 67 | Dynamic Type, and VoiceOver, without a `WKWebView`. | ||
| 68 | |||
| 69 | ```swift | ||
| 70 | import OrgSwiftUI | ||
| 71 | |||
| 72 | OrgView(orgSource, options: options) | ||
| 73 | ``` | ||
| 74 | |||
| 75 | `OrgRenderOptions` means the same thing here, so links and images resolve exactly | ||
| 76 | as they do in HTML. An image whose source will not resolve to an *absolute* URL | ||
| 77 | shows its alt text instead: HTML can lean on a document base URL, a view cannot. | ||
| 78 | |||
| 79 | Syntax highlighting for `#+begin_src` blocks is the app's to supply, which is what | ||
| 80 | keeps this package dependency-free: | ||
| 81 | |||
| 82 | ```swift | ||
| 83 | struct Highlighter: OrgCodeStyler { | ||
| 84 | func highlighted(code: String, language: String?) -> AttributedString? { | ||
| 85 | // nil means "show it as plain monospaced text" | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | OrgView(orgSource, options: options, styler: Highlighter()) | ||
| 90 | ``` | ||
| 91 | |||
| 61 | ### Title and heading level | 92 | ### Title and heading level |
| 62 | 93 | ||
| 63 | Two presentation knobs, both defaulting to how Hutch rendered: | 94 | Two presentation knobs, both defaulting to how Hutch rendered: |
Sources/OrgSwift/AST/OrgAttributedStringRenderer.swift +53 −38
| @@ -7,11 +7,26 @@ import Foundation | |||
| 7 | /// not a second parser. It stays Foundation-only (no SwiftUI), so it is usable anywhere; a | 7 | /// not a second parser. It stays Foundation-only (no SwiftUI), so it is usable anywhere; a |
| 8 | /// SwiftUI block renderer would sit on top, using this for the inline runs inside each block. | 8 | /// SwiftUI block renderer would sit on top, using this for the inline runs inside each block. |
| 9 | public struct OrgAttributedStringRenderer: Sendable { | 9 | public struct OrgAttributedStringRenderer: Sendable { |
| 10 | /// Resolution options, so repository-relative links resolve exactly as they do in HTML. | ||
| 11 | public var options: OrgRenderOptions | ||
| 10 | 12 | ||
| 11 | public init() {} | 13 | public init(options: OrgRenderOptions = .init()) { |
| 14 | self.options = options | ||
| 15 | } | ||
| 16 | |||
| 17 | private var resolver: OrgURLResolver { OrgURLResolver(options: options) } | ||
| 12 | 18 | ||
| 13 | /// Render one run of inline objects, carrying intents a UI layer can style. | 19 | /// Render one run of inline objects, carrying intents a UI layer can style. |
| 20 | /// | ||
| 21 | /// Footnote numbering is document-wide, so a caller rendering a whole document threads | ||
| 22 | /// its own ``OrgFootnotes`` through ``inline(_:_:)``; this overload numbers within the | ||
| 23 | /// run it is given. | ||
| 14 | public func inline(_ objects: [OrgObject]) -> AttributedString { | 24 | public func inline(_ objects: [OrgObject]) -> AttributedString { |
| 25 | var notes = OrgFootnotes(document: OrgDocument()) | ||
| 26 | return inline(objects, ¬es) | ||
| 27 | } | ||
| 28 | |||
| 29 | package func inline(_ objects: [OrgObject], _ notes: inout OrgFootnotes) -> AttributedString { | ||
| 15 | var result = AttributedString() | 30 | var result = AttributedString() |
| 16 | for object in objects { | 31 | for object in objects { |
| 17 | switch object { | 32 | switch object { |
| @@ -19,38 +34,26 @@ public struct OrgAttributedStringRenderer: Sendable { | |||
| 19 | result += AttributedString(text) | 34 | result += AttributedString(text) |
| 20 | 35 | ||
| 21 | case .bold(let children): | 36 | case .bold(let children): |
| 22 | var part = inline(children) | 37 | result += applying(.stronglyEmphasized, to: inline(children, ¬es)) |
| 23 | part.inlinePresentationIntent = .stronglyEmphasized | ||
| 24 | result += part | ||
| 25 | 38 | ||
| 26 | case .italic(let children): | 39 | case .italic(let children): |
| 27 | var part = inline(children) | 40 | result += applying(.emphasized, to: inline(children, ¬es)) |
| 28 | part.inlinePresentationIntent = .emphasized | ||
| 29 | result += part | ||
| 30 | 41 | ||
| 31 | // Underline and strikethrough have no Foundation-portable attribute (the | 42 | // Underline and strikethrough have no Foundation-portable attribute (the |
| 32 | // underlineStyle/strikethroughStyle keys live in the UIKit/AppKit scopes), so they | 43 | // underlineStyle/strikethroughStyle keys live in the UIKit/AppKit scopes), so they |
| 33 | // travel as roles the UI layer applies. | 44 | // travel as roles the UI layer applies. |
| 34 | case .underline(let children): | 45 | case .underline(let children): |
| 35 | var part = inline(children) | 46 | result += applying(.underline, to: inline(children, ¬es)) |
| 36 | part.orgRole = .underline | ||
| 37 | result += part | ||
| 38 | 47 | ||
| 39 | case .strikeThrough(let children): | 48 | case .strikeThrough(let children): |
| 40 | var part = inline(children) | 49 | result += applying(.strikeThrough, to: inline(children, ¬es)) |
| 41 | part.orgRole = .strikeThrough | ||
| 42 | result += part | ||
| 43 | 50 | ||
| 44 | case .verbatim(let text), .code(let text): | 51 | case .verbatim(let text), .code(let text): |
| 45 | var part = AttributedString(text) | 52 | result += applying(.code, to: AttributedString(text)) |
| 46 | part.inlinePresentationIntent = .code | ||
| 47 | result += part | ||
| 48 | 53 | ||
| 49 | case .superscript(let children): | 54 | case .superscript(let children): |
| 50 | // No portable superscript attribute; mark it so a UI layer can raise it. | 55 | // No portable superscript attribute; mark it so a UI layer can raise it. |
| 51 | var part = inline(children) | 56 | result += applying(.superscript, to: inline(children, ¬es)) |
| 52 | part.orgRole = .superscript | ||
| 53 | result += part | ||
| 54 | 57 | ||
| 55 | case .lineBreak: | 58 | case .lineBreak: |
| 56 | result += AttributedString("\n") | 59 | result += AttributedString("\n") |
| @@ -60,8 +63,8 @@ public struct OrgAttributedStringRenderer: Sendable { | |||
| 60 | part.orgRole = .timestamp | 63 | part.orgRole = .timestamp |
| 61 | result += part | 64 | result += part |
| 62 | 65 | ||
| 63 | case .footnoteRef(let label, _): | 66 | case .footnoteRef(let label, let inlineNote): |
| 64 | var part = AttributedString("[\(label)]") | 67 | var part = AttributedString("[\(notes.number(for: label, inline: inlineNote))]") |
| 65 | part.orgRole = .footnoteReference | 68 | part.orgRole = .footnoteReference |
| 66 | result += part | 69 | result += part |
| 67 | 70 | ||
| @@ -71,8 +74,9 @@ public struct OrgAttributedStringRenderer: Sendable { | |||
| 71 | result += part | 74 | result += part |
| 72 | 75 | ||
| 73 | case .link(let link): | 76 | case .link(let link): |
| 74 | var part = link.description.map { inline($0) } ?? AttributedString(displayValue(link.target)) | 77 | var part = link.description.map { inline($0, ¬es) } |
| 75 | if let url = URL(string: hrefValue(link.target)) { | 78 | ?? AttributedString(resolver.displayValue(link.target)) |
| 79 | if let href = resolver.href(for: link.target), let url = URL(string: href) { | ||
| 76 | part.link = url | 80 | part.link = url |
| 77 | } | 81 | } |
| 78 | result += part | 82 | result += part |
| @@ -81,6 +85,32 @@ public struct OrgAttributedStringRenderer: Sendable { | |||
| 81 | return result | 85 | return result |
| 82 | } | 86 | } |
| 83 | 87 | ||
| 88 | // MARK: - Nesting | ||
| 89 | |||
| 90 | /// Add an intent to every run, keeping intents the children already carry. Assigning | ||
| 91 | /// over the whole range instead would flatten `*bold /italic/*` to bold alone. | ||
| 92 | private func applying( | ||
| 93 | _ intent: InlinePresentationIntent, to string: AttributedString | ||
| 94 | ) -> AttributedString { | ||
| 95 | var result = string | ||
| 96 | for range in result.runs.map(\.range) { | ||
| 97 | result[range].inlinePresentationIntent = | ||
| 98 | (result[range].inlinePresentationIntent ?? []).union(intent) | ||
| 99 | } | ||
| 100 | return result | ||
| 101 | } | ||
| 102 | |||
| 103 | /// Mark runs that carry no role yet. A role is a single slot, so the innermost — the more | ||
| 104 | /// specific — wins; org nests two role-bearing markups (`_+both+_`) rarely enough that a | ||
| 105 | /// set is not worth the public API. | ||
| 106 | private func applying(_ role: OrgRole, to string: AttributedString) -> AttributedString { | ||
| 107 | var result = string | ||
| 108 | for range in result.runs.map(\.range) where result[range][OrgRoleAttribute.self] == nil { | ||
| 109 | result[range][OrgRoleAttribute.self] = role | ||
| 110 | } | ||
| 111 | return result | ||
| 112 | } | ||
| 113 | |||
| 84 | /// Flatten a whole document to attributed paragraphs — a convenience for callers that | 114 | /// Flatten a whole document to attributed paragraphs — a convenience for callers that |
| 85 | /// want text without building block views (a share sheet, a plain-text export). | 115 | /// want text without building block views (a share sheet, a plain-text export). |
| 86 | public func paragraphs(_ document: OrgDocument) -> [AttributedString] { | 116 | public func paragraphs(_ document: OrgDocument) -> [AttributedString] { |
| @@ -93,21 +123,6 @@ public struct OrgAttributedStringRenderer: Sendable { | |||
| 93 | } | 123 | } |
| 94 | } | 124 | } |
| 95 | 125 | ||
| 96 | private func hrefValue(_ target: OrgLinkTarget) -> String { | ||
| 97 | switch target { | ||
| 98 | case .external(let url): return url | ||
| 99 | case .file(let path): return path | ||
| 100 | case .id(let identifier): return "#\(identifier)" | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | private func displayValue(_ target: OrgLinkTarget) -> String { | ||
| 105 | switch target { | ||
| 106 | case .external(let url): return url | ||
| 107 | case .file(let path): return path | ||
| 108 | case .id(let identifier): return identifier | ||
| 109 | } | ||
| 110 | } | ||
| 111 | } | 126 | } |
| 112 | 127 | ||
| 113 | // MARK: - Custom attribute | 128 | // MARK: - Custom attribute |
Sources/OrgSwift/AST/OrgFootnotes.swift added +57
| @@ -0,0 +1,57 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// Assigns footnote numbers in first-reference order and holds the notes they resolve to. | ||
| 4 | /// | ||
| 5 | /// Numbering is document-wide and order-of-use, so it belongs to the render pass rather than | ||
| 6 | /// the parse: a renderer walks the tree, calls ``number(for:inline:)`` as it meets each | ||
| 7 | /// reference, then reads ``notes`` to emit the section. It is output-format agnostic — | ||
| 8 | /// the HTML renderer and the SwiftUI one share it. | ||
| 9 | package struct OrgFootnotes { | ||
| 10 | private var numbers: [String: Int] = [:] | ||
| 11 | private var order: [String] = [] | ||
| 12 | /// Reference-style definitions, gathered from the document's `[fn:x] …` lines. | ||
| 13 | private var definitions: [String: [OrgObject]] = [:] | ||
| 14 | /// Inline definitions, gathered from `[fn:x:text]` references as they are rendered. | ||
| 15 | private var inlineDefinitions: [String: [OrgObject]] = [:] | ||
| 16 | package var figureNumber = 0 | ||
| 17 | |||
| 18 | package init(document: OrgDocument) { | ||
| 19 | for element in document.elements { | ||
| 20 | if case .footnoteDefinition(let label, let content) = element { | ||
| 21 | definitions[label] = content | ||
| 22 | } | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | package mutating func number(for label: String, inline: [OrgObject]? = nil) -> Int { | ||
| 27 | if let inline, inlineDefinitions[label] == nil { inlineDefinitions[label] = inline } | ||
| 28 | if let existing = numbers[label] { return existing } | ||
| 29 | let next = order.count + 1 | ||
| 30 | numbers[label] = next | ||
| 31 | order.append(label) | ||
| 32 | return next | ||
| 33 | } | ||
| 34 | |||
| 35 | /// One numbered note, resolved to its content. | ||
| 36 | package struct Note { | ||
| 37 | package var number: Int | ||
| 38 | package var label: String | ||
| 39 | package var content: [OrgObject] | ||
| 40 | /// An inline footnote's text sits directly in the item; a reference-style definition | ||
| 41 | /// is a paragraph, matching org's exporter. | ||
| 42 | package var isInline: Bool | ||
| 43 | } | ||
| 44 | |||
| 45 | /// The referenced notes, in numbering order. Empty when the document has none, which is | ||
| 46 | /// the renderer's cue to omit the section entirely. | ||
| 47 | package var notes: [Note] { | ||
| 48 | order.map { label in | ||
| 49 | Note( | ||
| 50 | number: numbers[label] ?? 0, | ||
| 51 | label: label, | ||
| 52 | content: inlineDefinitions[label] ?? definitions[label] ?? [], | ||
| 53 | isInline: inlineDefinitions[label] != nil | ||
| 54 | ) | ||
| 55 | } | ||
| 56 | } | ||
| 57 | } | ||
Sources/OrgSwift/AST/OrgHTMLTreeRenderer.swift +28 −75
| @@ -5,7 +5,11 @@ import Foundation | |||
| 5 | /// The point of the prototype: this produces the same shape of output as the shipped | 5 | /// The point of the prototype: this produces the same shape of output as the shipped |
| 6 | /// single-pass ``OrgRenderer``, but from a parsed tree rather than from source, so a second | 6 | /// single-pass ``OrgRenderer``, but from a parsed tree rather than from source, so a second |
| 7 | /// renderer (see ``OrgAttributedStringRenderer``) can share the parse instead of re-deriving it. | 7 | /// renderer (see ``OrgAttributedStringRenderer``) can share the parse instead of re-deriving it. |
| 8 | public struct OrgHTMLTreeRenderer: Sendable { | 8 | /// |
| 9 | /// Not `Sendable`: it holds a ``CodeHighlighter``, and a real one wraps a thread-confined | ||
| 10 | /// engine (Highlightr's JavaScriptCore context, say). Sending the renderer across isolation | ||
| 11 | /// domains would call that engine off its thread. Make one where you render. | ||
| 12 | public struct OrgHTMLTreeRenderer { | ||
| 9 | /// The same options the shipped ``OrgRenderer`` takes, so this renderer is a drop-in for it. | 13 | /// The same options the shipped ``OrgRenderer`` takes, so this renderer is a drop-in for it. |
| 10 | public var options: OrgRenderOptions | 14 | public var options: OrgRenderOptions |
| 11 | public var highlighter: CodeHighlighter | 15 | public var highlighter: CodeHighlighter |
| @@ -16,10 +20,10 @@ public struct OrgHTMLTreeRenderer: Sendable { | |||
| 16 | } | 20 | } |
| 17 | 21 | ||
| 18 | public func render(_ document: OrgDocument) -> String { | 22 | public func render(_ document: OrgDocument) -> String { |
| 19 | var footnotes = FootnoteNumbering(document: document) | 23 | var footnotes = OrgFootnotes(document: document) |
| 20 | var html = metadataHeader(document) | 24 | var html = metadataHeader(document) |
| 21 | html += document.elements.map { element($0, &footnotes) }.joined() | 25 | html += document.elements.map { element($0, &footnotes) }.joined() |
| 22 | html += footnotes.renderSection(self) | 26 | html += renderFootnoteSection(&footnotes) |
| 23 | return html | 27 | return html |
| 24 | } | 28 | } |
| 25 | 29 | ||
| @@ -42,29 +46,12 @@ public struct OrgHTMLTreeRenderer: Sendable { | |||
| 42 | 46 | ||
| 43 | // MARK: - URL resolution | 47 | // MARK: - URL resolution |
| 44 | 48 | ||
| 45 | /// Resolve a link target to a safe `href`, or nil when it cannot be made safe. | 49 | /// Link and image resolution is shared with the other renderers; see ``OrgURLResolver``. |
| 46 | /// | 50 | private var resolver: OrgURLResolver { OrgURLResolver(options: options) } |
| 47 | /// Because the tree keeps targets typed, resolution is a renderer concern applied to the | ||
| 48 | /// `.file` case only — no resolver closure has to be threaded through the parse. | ||
| 49 | private func href(for target: OrgLinkTarget) -> String? { | ||
| 50 | switch target { | ||
| 51 | case .id(let identifier): | ||
| 52 | return sanitizedReadmeLinkURLString("#\(identifier)") | ||
| 53 | case .external(let url): | ||
| 54 | return sanitizedReadmeLinkURLString(url) | ||
| 55 | case .file(let path): | ||
| 56 | return sanitizedReadmeLinkURLString(options.linkURLResolver()?(path) ?? path) | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | /// Resolve an image source to a safe `src`, or nil when it cannot be made safe. | ||
| 61 | private func imageSource(_ source: String) -> String? { | ||
| 62 | sanitizedReadmeImageURLString(options.imageURLResolver()?(source) ?? source) | ||
| 63 | } | ||
| 64 | 51 | ||
| 65 | // MARK: - Blocks | 52 | // MARK: - Blocks |
| 66 | 53 | ||
| 67 | private func element(_ element: OrgElement, _ notes: inout FootnoteNumbering) -> String { | 54 | private func element(_ element: OrgElement, _ notes: inout OrgFootnotes) -> String { |
| 68 | switch element { | 55 | switch element { |
| 69 | case .heading(let heading): | 56 | case .heading(let heading): |
| 70 | let level = min(6, max(1, heading.level + options.headingLevelOffset)) | 57 | let level = min(6, max(1, heading.level + options.headingLevelOffset)) |
| @@ -149,7 +136,7 @@ public struct OrgHTMLTreeRenderer: Sendable { | |||
| 149 | } | 136 | } |
| 150 | } | 137 | } |
| 151 | 138 | ||
| 152 | private func renderList(_ list: OrgList, _ notes: inout FootnoteNumbering) -> String { | 139 | private func renderList(_ list: OrgList, _ notes: inout OrgFootnotes) -> String { |
| 153 | if list.kind == .description { | 140 | if list.kind == .description { |
| 154 | var html = "<dl>\n" | 141 | var html = "<dl>\n" |
| 155 | for item in list.items { | 142 | for item in list.items { |
| @@ -187,7 +174,7 @@ public struct OrgHTMLTreeRenderer: Sendable { | |||
| 187 | return html + "</\(tag)>\n" | 174 | return html + "</\(tag)>\n" |
| 188 | } | 175 | } |
| 189 | 176 | ||
| 190 | private func renderTable(_ table: OrgTable, _ notes: inout FootnoteNumbering) -> String { | 177 | private func renderTable(_ table: OrgTable, _ notes: inout OrgFootnotes) -> String { |
| 191 | var html = "<table>\n" | 178 | var html = "<table>\n" |
| 192 | var wroteHeader = false | 179 | var wroteHeader = false |
| 193 | var inBody = false | 180 | var inBody = false |
| @@ -218,7 +205,7 @@ public struct OrgHTMLTreeRenderer: Sendable { | |||
| 218 | /// The `<img>` for a figure, or nil when the source cannot be resolved to a safe URL — | 205 | /// The `<img>` for a figure, or nil when the source cannot be resolved to a safe URL — |
| 219 | /// in which case the caller falls back to text rather than pointing at something unsafe. | 206 | /// in which case the caller falls back to text rather than pointing at something unsafe. |
| 220 | private func imageTag(_ figure: OrgFigure, caption: [OrgObject]?) -> String? { | 207 | private func imageTag(_ figure: OrgFigure, caption: [OrgObject]?) -> String? { |
| 221 | guard let source = imageSource(figure.source) else { return nil } | 208 | guard let source = resolver.imageSource(figure.source) else { return nil } |
| 222 | let alt = figure.alt ?? caption.map { plainText($0) } ?? "" | 209 | let alt = figure.alt ?? caption.map { plainText($0) } ?? "" |
| 223 | var html = #"<img src="\#(source)" alt="\#(escapeHTMLAttribute(alt))""# | 210 | var html = #"<img src="\#(source)" alt="\#(escapeHTMLAttribute(alt))""# |
| 224 | for (key, value) in figure.attributes where key != "alt" { | 211 | for (key, value) in figure.attributes where key != "alt" { |
| @@ -229,7 +216,7 @@ public struct OrgHTMLTreeRenderer: Sendable { | |||
| 229 | 216 | ||
| 230 | // MARK: - Inline | 217 | // MARK: - Inline |
| 231 | 218 | ||
| 232 | func renderInline(_ objects: [OrgObject], _ notes: inout FootnoteNumbering) -> String { | 219 | func renderInline(_ objects: [OrgObject], _ notes: inout OrgFootnotes) -> String { |
| 233 | var html = "" | 220 | var html = "" |
| 234 | for object in objects { | 221 | for object in objects { |
| 235 | switch object { | 222 | switch object { |
| @@ -258,9 +245,9 @@ public struct OrgHTMLTreeRenderer: Sendable { | |||
| 258 | html += ##"<sup class="footnote-ref"><a id="fnr-\##(number)" href="#fn-\##(number)">\##(number)</a></sup>"## | 245 | html += ##"<sup class="footnote-ref"><a id="fnr-\##(number)" href="#fn-\##(number)">\##(number)</a></sup>"## |
| 259 | case .link(let link): | 246 | case .link(let link): |
| 260 | let text = link.description.map { renderInline($0, ¬es) } | 247 | let text = link.description.map { renderInline($0, ¬es) } |
| 261 | ?? escapeHTML(displayValue(link.target)) | 248 | ?? escapeHTML(resolver.displayValue(link.target)) |
| 262 | // An unsafe or unresolvable target degrades to its text, never a bad anchor. | 249 | // An unsafe or unresolvable target degrades to its text, never a bad anchor. |
| 263 | if let href = href(for: link.target) { | 250 | if let href = resolver.href(for: link.target) { |
| 264 | html += #"<a href="\#(href)">\#(text)</a>"# | 251 | html += #"<a href="\#(href)">\#(text)</a>"# |
| 265 | } else { | 252 | } else { |
| 266 | html += text | 253 | html += text |
| @@ -270,14 +257,6 @@ public struct OrgHTMLTreeRenderer: Sendable { | |||
| 270 | return html | 257 | return html |
| 271 | } | 258 | } |
| 272 | 259 | ||
| 273 | private func displayValue(_ target: OrgLinkTarget) -> String { | ||
| 274 | switch target { | ||
| 275 | case .external(let url): return url | ||
| 276 | case .file(let path): return path | ||
| 277 | case .id(let identifier): return identifier | ||
| 278 | } | ||
| 279 | } | ||
| 280 | |||
| 281 | /// Inline objects reduced to plain text, for an `alt` attribute. | 260 | /// Inline objects reduced to plain text, for an `alt` attribute. |
| 282 | func plainText(_ objects: [OrgObject]) -> String { | 261 | func plainText(_ objects: [OrgObject]) -> String { |
| 283 | objects.map { object in | 262 | objects.map { object in |
| @@ -286,7 +265,7 @@ public struct OrgHTMLTreeRenderer: Sendable { | |||
| 286 | case .verbatim(let text), .code(let text): return text | 265 | case .verbatim(let text), .code(let text): return text |
| 287 | case .bold(let c), .italic(let c), .underline(let c), .strikeThrough(let c), .superscript(let c): | 266 | case .bold(let c), .italic(let c), .underline(let c), .strikeThrough(let c), .superscript(let c): |
| 288 | return plainText(c) | 267 | return plainText(c) |
| 289 | case .link(let link): return link.description.map { plainText($0) } ?? displayValue(link.target) | 268 | case .link(let link): return link.description.map { plainText($0) } ?? resolver.displayValue(link.target) |
| 290 | case .timestamp(let stamp): return stamp.displayValue | 269 | case .timestamp(let stamp): return stamp.displayValue |
| 291 | case .image(let figure): return figure.alt ?? "" | 270 | case .image(let figure): return figure.alt ?? "" |
| 292 | case .footnoteRef, .lineBreak: return "" | 271 | case .footnoteRef, .lineBreak: return "" |
| @@ -295,47 +274,21 @@ public struct OrgHTMLTreeRenderer: Sendable { | |||
| 295 | } | 274 | } |
| 296 | } | 275 | } |
| 297 | 276 | ||
| 298 | // MARK: - Footnote numbering | 277 | // MARK: - Footnote section |
| 299 | |||
| 300 | /// Assigns footnote numbers in first-reference order and renders the notes section. | ||
| 301 | struct FootnoteNumbering { | ||
| 302 | private var numbers: [String: Int] = [:] | ||
| 303 | private var order: [String] = [] | ||
| 304 | /// Reference-style definitions, gathered from the document's `[fn:x] …` lines. | ||
| 305 | private var definitions: [String: [OrgObject]] = [:] | ||
| 306 | /// Inline definitions, gathered from `[fn:x:text]` references as they are rendered. | ||
| 307 | private var inlineDefinitions: [String: [OrgObject]] = [:] | ||
| 308 | var figureNumber = 0 | ||
| 309 | |||
| 310 | init(document: OrgDocument) { | ||
| 311 | for element in document.elements { | ||
| 312 | if case .footnoteDefinition(let label, let content) = element { | ||
| 313 | definitions[label] = content | ||
| 314 | } | ||
| 315 | } | ||
| 316 | } | ||
| 317 | |||
| 318 | mutating func number(for label: String, inline: [OrgObject]? = nil) -> Int { | ||
| 319 | if let inline, inlineDefinitions[label] == nil { inlineDefinitions[label] = inline } | ||
| 320 | if let existing = numbers[label] { return existing } | ||
| 321 | let next = order.count + 1 | ||
| 322 | numbers[label] = next | ||
| 323 | order.append(label) | ||
| 324 | return next | ||
| 325 | } | ||
| 326 | 278 | ||
| 327 | mutating func renderSection(_ renderer: OrgHTMLTreeRenderer) -> String { | 279 | private extension OrgHTMLTreeRenderer { |
| 328 | guard !order.isEmpty else { return "" } | 280 | /// The `<section class="footnotes">` org's exporter puts at the end of a document. |
| 281 | func renderFootnoteSection(_ notes: inout OrgFootnotes) -> String { | ||
| 282 | let collected = notes.notes | ||
| 283 | guard !collected.isEmpty else { return "" } | ||
| 329 | var html = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n" | 284 | var html = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n" |
| 330 | for label in order { | 285 | for note in collected { |
| 331 | let n = numbers[label] ?? 0 | 286 | let n = note.number |
| 332 | let back = ##"<a class="footnote-back" href="#fnr-\##(n)" aria-label="Back to reference \##(n)">↩</a>"## | 287 | let back = ##"<a class="footnote-back" href="#fnr-\##(n)" aria-label="Back to reference \##(n)">↩</a>"## |
| 333 | // An inline footnote's text sits directly in the item; a reference-style | 288 | let body = renderInline(note.content, ¬es) |
| 334 | // definition is a paragraph, matching org's exporter. | 289 | if note.isInline { |
| 335 | if let inline = inlineDefinitions[label] { | 290 | html += "<li id=\"fn-\(n)\">\(body) \(back)</li>\n" |
| 336 | html += "<li id=\"fn-\(n)\">\(renderer.renderInline(inline, &self)) \(back)</li>\n" | ||
| 337 | } else { | 291 | } else { |
| 338 | let body = definitions[label].map { renderer.renderInline($0, &self) } ?? "" | ||
| 339 | html += "<li id=\"fn-\(n)\"><p>\(body)</p>\n \(back)</li>\n" | 292 | html += "<li id=\"fn-\(n)\"><p>\(body)</p>\n \(back)</li>\n" |
| 340 | } | 293 | } |
| 341 | } | 294 | } |
Sources/OrgSwift/AST/OrgURLResolver.swift added +40
| @@ -0,0 +1,40 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// Resolves a link target or image source to a safe absolute URL string, or nil when it | ||
| 4 | /// cannot be made safe. | ||
| 5 | /// | ||
| 6 | /// Because the tree keeps targets typed, resolution is a renderer concern applied to the | ||
| 7 | /// `.file` case only — no resolver closure has to be threaded through the parse. Both | ||
| 8 | /// renderers resolve identically, so a link that degrades to text in HTML degrades to text | ||
| 9 | /// natively too. | ||
| 10 | package struct OrgURLResolver: Sendable { | ||
| 11 | package var options: OrgRenderOptions | ||
| 12 | |||
| 13 | package init(options: OrgRenderOptions) { | ||
| 14 | self.options = options | ||
| 15 | } | ||
| 16 | |||
| 17 | package func href(for target: OrgLinkTarget) -> String? { | ||
| 18 | switch target { | ||
| 19 | case .id(let identifier): | ||
| 20 | return sanitizedReadmeLinkURLString("#\(identifier)") | ||
| 21 | case .external(let url): | ||
| 22 | return sanitizedReadmeLinkURLString(url) | ||
| 23 | case .file(let path): | ||
| 24 | return sanitizedReadmeLinkURLString(options.linkURLResolver()?(path) ?? path) | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | package func imageSource(_ source: String) -> String? { | ||
| 29 | sanitizedReadmeImageURLString(options.imageURLResolver()?(source) ?? source) | ||
| 30 | } | ||
| 31 | |||
| 32 | /// The text a target shows when it carries no description. | ||
| 33 | package func displayValue(_ target: OrgLinkTarget) -> String { | ||
| 34 | switch target { | ||
| 35 | case .external(let url): return url | ||
| 36 | case .file(let path): return path | ||
| 37 | case .id(let identifier): return identifier | ||
| 38 | } | ||
| 39 | } | ||
| 40 | } | ||
Sources/OrgSwift/OrgRenderer.swift +1 −1
| @@ -19,7 +19,7 @@ public struct PlainCodeHighlighter: CodeHighlighter { | |||
| 19 | /// sources are rewritten to `{host}/{owner}/{repositoryName}/blob/{ref}/...`. | 19 | /// sources are rewritten to `{host}/{owner}/{repositoryName}/blob/{ref}/...`. |
| 20 | /// When either is nil, relative links are left as-is (and dropped by the | 20 | /// When either is nil, relative links are left as-is (and dropped by the |
| 21 | /// URL-scheme allowlist, which only permits absolute http/https/mailto). | 21 | /// URL-scheme allowlist, which only permits absolute http/https/mailto). |
| 22 | public struct OrgRenderOptions { | 22 | public struct OrgRenderOptions: Sendable { |
| 23 | /// Host used to build absolute URLs for repository-relative links. | 23 | /// Host used to build absolute URLs for repository-relative links. |
| 24 | public var host: String | 24 | public var host: String |
| 25 | /// Repository owner (e.g. `~ccleberg`). Nil disables relative-link resolution. | 25 | /// Repository owner (e.g. `~ccleberg`). Nil disables relative-link resolution. |
Sources/OrgSwiftUI/OrgBlockView.swift added +108
| @@ -0,0 +1,108 @@ | |||
| 1 | import OrgSwift | ||
| 2 | import SwiftUI | ||
| 3 | |||
| 4 | /// Renders prepared blocks. All the org work happened in ``OrgLayoutBuilder``; this is | ||
| 5 | /// layout only. | ||
| 6 | struct 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 | |||
| 19 | struct 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 @@ | |||
| 1 | import 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 | ||
| 12 | public protocol OrgCodeStyler { | ||
| 13 | func highlighted(code: String, language: String?) -> AttributedString? | ||
| 14 | } | ||
| 15 | |||
| 16 | /// The default: no highlighting. | ||
| 17 | public 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 @@ | |||
| 1 | import OrgSwift | ||
| 2 | import 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. | ||
| 9 | struct 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, ¬es) | ||
| 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 @@ | |||
| 1 | import OrgSwift | ||
| 2 | import 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. | ||
| 10 | struct OrgLayout { | ||
| 11 | var blocks: [OrgBlock] = [] | ||
| 12 | var footnotes: [OrgFootnote] = [] | ||
| 13 | } | ||
| 14 | |||
| 15 | struct 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. | ||
| 24 | indirect 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 | |||
| 43 | struct 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 | |||
| 56 | struct 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 @@ | |||
| 1 | import OrgSwift | ||
| 2 | import 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 | ||
| 11 | struct 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, ¬es) } | ||
| 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, ¬es, 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, ¬es, 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, ¬es, 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, ¬es))] | ||
| 64 | |||
| 65 | case .list(let list): | ||
| 66 | return [.list(listLayout(list, ¬es))] | ||
| 67 | |||
| 68 | case .table(let table): | ||
| 69 | return [.table(tableLayout(table, ¬es))] | ||
| 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, ¬es) })] | ||
| 79 | |||
| 80 | case .centerBlock(let children): | ||
| 81 | return [.center(children.flatMap { block($0, ¬es) })] | ||
| 82 | |||
| 83 | case .verseBlock(let lines): | ||
| 84 | return [.verse(lines.map { text($0, ¬es) })] | ||
| 85 | |||
| 86 | case .specialBlock(let name, let children): | ||
| 87 | return [.special(name: name, blocks: children.flatMap { block($0, ¬es) })] | ||
| 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, ¬es)] | ||
| 96 | |||
| 97 | case .captioned(_, let caption, let content): | ||
| 98 | return [.captioned( | ||
| 99 | caption: caption.map { text($0, ¬es, style: .footnote) }, | ||
| 100 | blocks: block(content, ¬es) | ||
| 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, ¬es, 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, ¬es) | ||
| 143 | styled.font = .body.bold() | ||
| 144 | return styled | ||
| 145 | }, | ||
| 146 | paragraphs: item.content.map { text($0, ¬es) }, | ||
| 147 | sublist: item.sublist.map { listLayout($0, ¬es) } | ||
| 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, ¬es, 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 @@ | |||
| 1 | import OrgSwift | ||
| 2 | import 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. | ||
| 8 | struct 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 @@ | |||
| 1 | import OrgSwift | ||
| 2 | import 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. | ||
| 8 | struct 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 @@ | |||
| 1 | import OrgSwift | ||
| 2 | import 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. | ||
| 15 | public 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. | ||
| 49 | struct 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/OrgSwiftTests/OrgTreeTests.swift +62
| @@ -122,6 +122,68 @@ struct ASTRendererTests { | |||
| 122 | #expect(linkRun?.link?.absoluteString == "https://example.com") | 122 | #expect(linkRun?.link?.absoluteString == "https://example.com") |
| 123 | } | 123 | } |
| 124 | 124 | ||
| 125 | /// The native renderer resolves and sanitizes exactly as the HTML one does, so a README | ||
| 126 | /// rendered natively gets the same working links rather than raw relative paths. | ||
| 127 | @Test | ||
| 128 | func attributedStringResolvesRelativeLinksAndRejectsUnsafeOnes() { | ||
| 129 | let renderer = OrgAttributedStringRenderer(options: OrgRenderOptions( | ||
| 130 | host: "gitbay.org", | ||
| 131 | owner: "krz", | ||
| 132 | repositoryName: "org-swift", | ||
| 133 | ref: "HEAD", | ||
| 134 | readmePath: "README.org" | ||
| 135 | )) | ||
| 136 | |||
| 137 | let relative = renderer.inline(OrgParser.parseInline("[[./ARCHITECTURE.md][notes]]")) | ||
| 138 | #expect(relative.runs.first { $0.link != nil }?.link?.absoluteString | ||
| 139 | == "https://gitbay.org/krz/org-swift/blob/HEAD/ARCHITECTURE.md") | ||
| 140 | |||
| 141 | // Without repository context there is nothing to resolve against, so an unsafe | ||
| 142 | // scheme reaches the allowlist and the link degrades to its text — as in HTML. | ||
| 143 | let unsafe = OrgAttributedStringRenderer().inline(OrgParser.parseInline("[[javascript:alert(1)][tap]]")) | ||
| 144 | #expect(String(unsafe.characters) == "tap") | ||
| 145 | #expect(unsafe.runs.allSatisfy { $0.link == nil }) | ||
| 146 | #expect(!OrgRenderer.renderToHTML("[[javascript:alert(1)][tap]]").lowercased().contains("javascript:")) | ||
| 147 | } | ||
| 148 | |||
| 149 | /// Footnote numbering is document-wide and order-of-use, and now shared with HTML, so | ||
| 150 | /// both renderers agree on which note is number 1. | ||
| 151 | @Test | ||
| 152 | func attributedStringNumbersFootnotesInReferenceOrder() { | ||
| 153 | let doc = OrgParser.parse(""" | ||
| 154 | First[fn:b] then second[fn:a] then first again[fn:b]. | ||
| 155 | |||
| 156 | [fn:a] note a | ||
| 157 | [fn:b] note b | ||
| 158 | """) | ||
| 159 | guard case .paragraph(let objects) = doc.elements[0] else { | ||
| 160 | Issue.record("expected a paragraph"); return | ||
| 161 | } | ||
| 162 | var notes = OrgFootnotes(document: doc) | ||
| 163 | let text = String(OrgAttributedStringRenderer().inline(objects, ¬es).characters) | ||
| 164 | #expect(text == "First[1] then second[2] then first again[1].") | ||
| 165 | #expect(notes.notes.map(\.label) == ["b", "a"]) | ||
| 166 | } | ||
| 167 | |||
| 168 | /// Emphasis nests in org, and the HTML renderer nests it (`<strong>b <em>i</em></strong>`). | ||
| 169 | /// Assigning the intent over the whole range instead of merging would flatten the inner | ||
| 170 | /// one away. | ||
| 171 | @Test | ||
| 172 | func nestedEmphasisKeepsBothIntents() { | ||
| 173 | let attributed = OrgAttributedStringRenderer().inline( | ||
| 174 | OrgParser.parseInline("*bold /italic/ tail*") | ||
| 175 | ) | ||
| 176 | func intent(_ text: String) -> InlinePresentationIntent? { | ||
| 177 | attributed.runs.first { String(attributed[$0.range].characters).contains(text) }? | ||
| 178 | .inlinePresentationIntent | ||
| 179 | } | ||
| 180 | #expect(intent("bold")?.contains(.stronglyEmphasized) == true) | ||
| 181 | #expect(intent("italic")?.contains(.stronglyEmphasized) == true) | ||
| 182 | #expect(intent("italic")?.contains(.emphasized) == true) | ||
| 183 | // The outer run is bold only. | ||
| 184 | #expect(intent("bold")?.contains(.emphasized) == false) | ||
| 185 | } | ||
| 186 | |||
| 125 | @Test | 187 | @Test |
| 126 | func attributedStringCarriesRolesForNonStandardIntents() { | 188 | func attributedStringCarriesRolesForNonStandardIntents() { |
| 127 | let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>") | 189 | let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>") |
Tests/OrgSwiftUITests/OrgLayoutTests.swift added +193
| @@ -0,0 +1,193 @@ | |||
| 1 | import Foundation | ||
| 2 | import SwiftUI | ||
| 3 | import 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 | ||
| 12 | struct 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 | } | ||