Sources/OrgSwift/OrgRenderer.swift
151 lines · 6477 bytes
12 symbols in this file
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}