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

html library org-mode swift

Sources/OrgSwift/OrgRenderer.swift

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

134 lines · 5467 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 {
 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    func imageURLResolver() -> ((String) -> String?)? {
 73        guard let owner, let repositoryName else { return nil }
 74        let host = host
 75        let ref = ref
 76        let readmePath = readmePath
 77        let segment = imagePathSegment
 78        return { source in
 79            resolveRepositoryAssetURL(
 80                source,
 81                host: host,
 82                owner: owner,
 83                repositoryName: repositoryName,
 84                ref: ref,
 85                readmePath: readmePath,
 86                pathSegment: segment
 87            )
 88        }
 89    }
 90
 91    func linkURLResolver() -> ((String) -> String?)? {
 92        guard let owner, let repositoryName else { return nil }
 93        let host = host
 94        let ref = ref
 95        let readmePath = readmePath
 96        let segment = linkPathSegment
 97        return { source in
 98            resolveRepositoryLinkURL(
 99                source,
100                host: host,
101                owner: owner,
102                repositoryName: repositoryName,
103                ref: ref,
104                readmePath: readmePath,
105                pathSegment: segment
106            )
107        }
108    }
109}
110
111/// Renders org-mode source to sanitized HTML.
112public enum OrgRenderer {
113    public static func renderToHTML(
114        _ source: String,
115        options: OrgRenderOptions = .init(),
116        highlighter: CodeHighlighter = PlainCodeHighlighter()
117    ) -> String {
118        OrgHTMLTreeRenderer(options: options, highlighter: highlighter)
119            .render(OrgParser.parse(source))
120    }
121}
122
123// MARK: - Org-mode to HTML
124
125/// Split an org heading's trailing `:tag1:tag2:` off its title. Tags are the final
126/// whitespace-separated run of colon-delimited words; a heading without them returns its
127/// text unchanged and no tags.
128func splitHeadingTags(_ heading: String) -> (title: String, tags: [String]) {
129    guard let match = heading.firstMatch(of: /^(.*?)\s+(:(?:[A-Za-z0-9_@#%]+:)+)$/) else {
130        return (heading, [])
131    }
132    let tags = String(match.2).split(separator: ":").map(String.init).filter { !$0.isEmpty }
133    return (String(match.1).trimmingCharacters(in: .whitespaces), tags)
134}