Sources/OrgSwift/OrgRenderer.swift
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}