import Foundation
/// Produces highlighted HTML for a fenced code block. Returning `nil` makes the
/// renderer fall back to an escaped `
` block.
public protocol CodeHighlighter {
func highlightedHTML(code: String, language: String?) -> String?
}
/// A highlighter that performs no highlighting. The renderer escapes the code
/// and wraps it in `` when this is used.
public struct PlainCodeHighlighter: CodeHighlighter {
public init() {}
public func highlightedHTML(code: String, language: String?) -> String? { nil }
}
/// Options controlling how relative links and images are resolved.
///
/// When `owner` and `repositoryName` are both non-nil, relative links and image
/// sources are rewritten to `{host}/{owner}/{repositoryName}/blob/{ref}/...`.
/// When either is nil, relative links are left as-is (and dropped by the
/// URL-scheme allowlist, which only permits absolute http/https/mailto).
public struct OrgRenderOptions {
/// Host used to build absolute URLs for repository-relative links.
public var host: String
/// Repository owner (e.g. `~ccleberg`). Nil disables relative-link resolution.
public var owner: String?
/// Repository name. Nil disables relative-link resolution.
public var repositoryName: String?
/// Git ref used in the `/[` path segment.
public var ref: String
/// Path of the README being rendered, used to resolve paths relative to it.
public var readmePath: String?
/// Path segment for a resolved relative *image* URL: `////…`.
/// Defaults to `blob`; forges that serve raw bytes on a different route (gitbay uses
/// `raw`) set this so an `]
` points at the file, not its HTML viewer page.
public var imagePathSegment: String
/// Path segment for a resolved relative *link* URL. Defaults to `blob` — a link should
/// open the file's page, not its raw bytes.
public var linkPathSegment: String
/// Emit a leading `` block for `#+TITLE`/`#+AUTHOR`/`#+DATE`.
/// Apps that show a README title want this; orgo treats those keywords as document
/// metadata carried by the page template, not body content, so the conformance corpus
/// renders with this off.
public var metadataHeader: Bool
/// Added to every heading's star count before it becomes an `` level (clamped to
/// 1...6). Default 0 renders `*` as ``. orgo offsets by 1 (`*` → ``) because the
/// document title occupies ``; set this to 1 to match orgo.
public var headingLevelOffset: Int
public init(
host: String = "git.sr.ht",
owner: String? = nil,
repositoryName: String? = nil,
ref: String = "HEAD",
readmePath: String? = nil,
metadataHeader: Bool = true,
headingLevelOffset: Int = 0,
imagePathSegment: String = "blob",
linkPathSegment: String = "blob"
) {
self.host = host
self.owner = owner
self.repositoryName = repositoryName
self.ref = ref
self.readmePath = readmePath
self.metadataHeader = metadataHeader
self.headingLevelOffset = headingLevelOffset
self.imagePathSegment = imagePathSegment
self.linkPathSegment = linkPathSegment
}
func imageURLResolver() -> ((String) -> String?)? {
guard let owner, let repositoryName else { return nil }
let host = host
let ref = ref
let readmePath = readmePath
let segment = imagePathSegment
return { source in
resolveRepositoryAssetURL(
source,
host: host,
owner: owner,
repositoryName: repositoryName,
ref: ref,
readmePath: readmePath,
pathSegment: segment
)
}
}
func linkURLResolver() -> ((String) -> String?)? {
guard let owner, let repositoryName else { return nil }
let host = host
let ref = ref
let readmePath = readmePath
let segment = linkPathSegment
return { source in
resolveRepositoryLinkURL(
source,
host: host,
owner: owner,
repositoryName: repositoryName,
ref: ref,
readmePath: readmePath,
pathSegment: segment
)
}
}
}
/// Renders org-mode source to sanitized HTML.
public enum OrgRenderer {
public static func renderToHTML(
_ source: String,
options: OrgRenderOptions = .init(),
highlighter: CodeHighlighter = PlainCodeHighlighter()
) -> String {
OrgHTMLTreeRenderer(options: options, highlighter: highlighter)
.render(OrgParser.parse(source))
}
}
// MARK: - Org-mode to HTML
/// Split an org heading's trailing `:tag1:tag2:` off its title. Tags are the final
/// whitespace-separated run of colon-delimited words; a heading without them returns its
/// text unchanged and no tags.
func splitHeadingTags(_ heading: String) -> (title: String, tags: [String]) {
guard let match = heading.firstMatch(of: /^(.*?)\s+(:(?:[A-Za-z0-9_@#%]+:)+)$/) else {
return (heading, [])
}
let tags = String(match.2).split(separator: ":").map(String.init).filter { !$0.isEmpty }
return (String(match.1).trimmingCharacters(in: .whitespaces), tags)
}