krz/org-swift

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

Sources/OrgSwift/OrgRenderer.swift

v0.7.0
org-swift/Sources/OrgSwift/OrgRenderer.swift history · blame · raw

151 lines · 6477 bytes

  1import Foundation
  2
  3/// Produces highlighted HTML for a fenced code block. Returning `nil` makes the
  4/// renderer fall back to an escaped `<pre><code>` block.
  5public protocol CodeHighlighter {
  6    func highlightedHTML(code: String, language: String?) -> String?
  7}
  8
  9/// A highlighter that performs no highlighting. The renderer escapes the code
 10/// and wraps it in `<pre><code>` when this is used.
 11public struct PlainCodeHighlighter: CodeHighlighter {
 12    public init() {}
 13    public func highlightedHTML(code: String, language: String?) -> String? { nil }
 14}
 15
 16/// Options controlling how relative links and images are resolved.
 17///
 18/// When `owner` and `repositoryName` are both non-nil, relative links and image
 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
 21/// URL-scheme allowlist, which only permits absolute http/https/mailto).
 22public struct OrgRenderOptions: Sendable {
 23    /// Host used to build absolute URLs for repository-relative links.
 24    public var host: String
 25    /// Repository owner (e.g. `~ccleberg`). Nil disables relative-link resolution.
 26    public var owner: String?
 27    /// Repository name. Nil disables relative-link resolution.
 28    public var repositoryName: String?
 29    /// Git ref used in the `<segment>/<ref>` path segment.
 30    public var ref: String
 31    /// Path of the README being rendered, used to resolve paths relative to it.
 32    public var readmePath: String?
 33    /// Path segment for a resolved relative *image* URL: `<host>/<owner>/<repo>/<segment>/…`.
 34    /// Defaults to `blob`; forges that serve raw bytes on a different route (gitbay uses
 35    /// `raw`) set this so an `<img src>` points at the file, not its HTML viewer page.
 36    public var imagePathSegment: String
 37    /// Path segment for a resolved relative *link* URL. Defaults to `blob` — a link should
 38    /// open the file's page, not its raw bytes.
 39    public var linkPathSegment: String
 40    /// Emit a leading `<div class="org-metadata">` block for `#+TITLE`/`#+AUTHOR`/`#+DATE`.
 41    /// Apps that show a README title want this; orgo treats those keywords as document
 42    /// metadata carried by the page template, not body content, so the conformance corpus
 43    /// renders with this off.
 44    public var metadataHeader: Bool
 45    /// Added to every heading's star count before it becomes an `<hN>` level (clamped to
 46    /// 1...6). Default 0 renders `*` as `<h1>`. orgo offsets by 1 (`*` → `<h2>`) because the
 47    /// document title occupies `<h1>`; set this to 1 to match orgo.
 48    public var headingLevelOffset: Int
 49
 50    public init(
 51        host: String = "git.sr.ht",
 52        owner: String? = nil,
 53        repositoryName: String? = nil,
 54        ref: String = "HEAD",
 55        readmePath: String? = nil,
 56        metadataHeader: Bool = true,
 57        headingLevelOffset: Int = 0,
 58        imagePathSegment: String = "blob",
 59        linkPathSegment: String = "blob"
 60    ) {
 61        self.host = host
 62        self.owner = owner
 63        self.repositoryName = repositoryName
 64        self.ref = ref
 65        self.readmePath = readmePath
 66        self.metadataHeader = metadataHeader
 67        self.headingLevelOffset = headingLevelOffset
 68        self.imagePathSegment = imagePathSegment
 69        self.linkPathSegment = linkPathSegment
 70    }
 71
 72    /// Resolve a README *link* target to a safe absolute URL string, or nil when it cannot
 73    /// be made safe. Absolute `http(s)`/`mailto:` targets and `#fragment`s pass through; a
 74    /// repository-relative path resolves against ``readmePath`` under ``linkPathSegment``.
 75    ///
 76    /// Public because a README is not always org. An app that renders the Markdown README of
 77    /// the same repository has the same relative targets to resolve, and resolving them in a
 78    /// second place is how two surfaces of one repository drift apart.
 79    public func resolvedLinkURL(_ target: String) -> String? {
 80        OrgURLResolver(options: self).href(for: .file(normalizeOrgLinkTarget(target)))
 81    }
 82
 83    /// The image counterpart of ``resolvedLinkURL(_:)``, resolving under ``imagePathSegment``.
 84    /// Stricter: only `http(s)` sources survive.
 85    public func resolvedImageURL(_ source: String) -> String? {
 86        OrgURLResolver(options: self).imageSource(normalizeOrgLinkTarget(source))
 87    }
 88
 89    func imageURLResolver() -> ((String) -> String?)? {
 90        guard let owner, let repositoryName else { return nil }
 91        let host = host
 92        let ref = ref
 93        let readmePath = readmePath
 94        let segment = imagePathSegment
 95        return { source in
 96            resolveRepositoryAssetURL(
 97                source,
 98                host: host,
 99                owner: owner,
100                repositoryName: repositoryName,
101                ref: ref,
102                readmePath: readmePath,
103                pathSegment: segment
104            )
105        }
106    }
107
108    func linkURLResolver() -> ((String) -> String?)? {
109        guard let owner, let repositoryName else { return nil }
110        let host = host
111        let ref = ref
112        let readmePath = readmePath
113        let segment = linkPathSegment
114        return { source in
115            resolveRepositoryLinkURL(
116                source,
117                host: host,
118                owner: owner,
119                repositoryName: repositoryName,
120                ref: ref,
121                readmePath: readmePath,
122                pathSegment: segment
123            )
124        }
125    }
126}
127
128/// Renders org-mode source to sanitized HTML.
129public enum OrgRenderer {
130    public static func renderToHTML(
131        _ source: String,
132        options: OrgRenderOptions = .init(),
133        highlighter: CodeHighlighter = PlainCodeHighlighter()
134    ) -> String {
135        OrgHTMLTreeRenderer(options: options, highlighter: highlighter)
136            .render(OrgParser.parse(source))
137    }
138}
139
140// MARK: - Org-mode to HTML
141
142/// Split an org heading's trailing `:tag1:tag2:` off its title. Tags are the final
143/// whitespace-separated run of colon-delimited words; a heading without them returns its
144/// text unchanged and no tags.
145func splitHeadingTags(_ heading: String) -> (title: String, tags: [String]) {
146    guard let match = heading.firstMatch(of: /^(.*?)\s+(:(?:[A-Za-z0-9_@#%]+:)+)$/) else {
147        return (heading, [])
148    }
149    let tags = String(match.2).split(separator: ":").map(String.init).filter { !$0.isEmpty }
150    return (String(match.1).trimmingCharacters(in: .whitespaces), tags)
151}