import Foundation
/// Document metadata from `#+TITLE`, `#+AUTHOR` and `#+DATE`.
public struct ExportMetadata: Sendable, Equatable {
public var title: String?
public var author: String?
public var date: String?
}
/// HTML export. The body follows the shape orgo produces, which the org-conformance corpus
/// holds as the reference: headings one level down (`*` is `
`), timestamps as ``,
/// footnotes collected at the end, captioned images as figures.
public enum HTMLExport {
public static func body(_ text: String, settings: OrgSettings = .default) -> String {
let renderer = HTMLRenderer(settings: settings)
return renderer.document(text)
}
public static func metadata(_ text: String, settings: OrgSettings = .default) -> ExportMetadata {
let tree = OrgParser.parse(text, defaults: settings)
var meta = ExportMetadata()
for node in tree.root.children where node.kind == .zerothSection {
for child in node.children where child.kind == .keyword {
let line = child.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard let colon = line.firstIndex(of: ":") else { continue }
let key = line.dropFirst(2).prefix { $0 != ":" }.uppercased()
let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
switch key {
case "TITLE": meta.title = value
case "AUTHOR": meta.author = value
case "DATE": meta.date = value
default: break
}
}
}
return meta
}
/// A standalone page: the body under the document's title, with a small stylesheet.
public static func page(_ text: String, fallbackTitle: String, settings: OrgSettings = .default) -> String {
let meta = metadata(text, settings: settings)
let title = meta.title ?? fallbackTitle
let renderer = HTMLRenderer(settings: settings)
let titleHTML = renderer.inlineString(title)
var header = "\(titleHTML) \n"
let byline = [meta.author, meta.date].compactMap { $0 }.filter { !$0.isEmpty }
if !byline.isEmpty { header += "\(byline.map(HTMLRenderer.escape).joined(separator: " · "))
\n" }
return """
\(HTMLRenderer.escape(title))
\(header)\(renderer.document(text))
"""
}
}
final class HTMLRenderer {
let settings: OrgSettings
private var footnoteNumbers: [String: Int] = [:]
private var footnoteOrder: [(label: String, content: FootnoteContent)] = []
private var definitions: [String: String] = [:]
private var anonymous = 0
private var figures = 0
private var ids: Set = []
enum FootnoteContent {
case definition(String)
case inline(String)
}
init(settings: OrgSettings) {
self.settings = settings
}
static func escape(_ s: String) -> String {
var out = ""
for c in s {
switch c {
case "&": out += "&"
case "<": out += "<"
case ">": out += ">"
case "\"": out += """
default: out.append(c)
}
}
return out
}
// MARK: - Document
func document(_ text: String) -> String {
let tree = OrgParser.parse(text, defaults: settings)
collectDefinitions(tree.root)
var out = ""
for child in tree.root.children { out += element(child) }
out += footnoteSection()
return out
}
private func collectDefinitions(_ node: SyntaxNode) {
for child in node.children {
if child.kind == .footnoteDefinition {
let text = child.text
if let m = text.firstMatch(of: /^\[fn:([^\]]+)\][ \t]*/) {
definitions[String(m.1)] = String(text[m.range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
}
} else {
collectDefinitions(child)
}
}
}
private func footnoteSection() -> String {
guard !footnoteOrder.isEmpty else { return "" }
var out = "\n"
}
/// Org text rendered as elements, for block and footnote contents.
func blocks(_ text: String) -> String {
let tree = OrgParser.parse(text, defaults: settings)
var out = ""
for child in tree.root.children { out += element(child) }
return out
}
/// One line of org text rendered inline.
func inlineString(_ text: String) -> String {
let tree = OrgParser.parse(text, defaults: settings)
guard let paragraph = tree.root.descendants().first(where: { $0.kind == .paragraph }) else { return Self.escape(text) }
return inline(paragraph).trimmingCharacters(in: .whitespacesAndNewlines)
}
// MARK: - Elements
private var affiliated: [String: String] = [:]
/// `:exports` of the last src block: its results are exported only for `results` and
/// `both`; nothing runs.
private var lastExports = "code"
func element(_ node: SyntaxNode) -> String {
defer { if node.kind != .affiliatedKeyword { affiliated = [:] } }
if node.kind != .affiliatedKeyword, affiliated["RESULTS"] != nil {
defer { lastExports = "code" }
if lastExports != "results", lastExports != "both" { return "" }
}
if node.kind == .block, let exports = Self.srcExports(node) {
lastExports = exports
if exports == "results" || exports == "none" { return "" }
}
switch node.kind {
case .zerothSection, .section, .document:
var out = ""
for child in node.children { out += element(child) }
return out
case .heading:
return heading(node)
case .affiliatedKeyword:
let line = node.text.trimmingCharacters(in: .whitespacesAndNewlines)
if let m = line.firstMatch(of: /^#\+([A-Za-z_]+):[ \t]*(.*)$/) {
affiliated[m.1.uppercased()] = String(m.2)
}
return ""
case .paragraph:
return paragraph(node)
case .plainList:
return list(node)
case .table:
return table(node)
case .block:
return block(node)
case .horizontalRule:
return " \n"
case .fixedWidth:
let lines = node.text.split(separator: "\n", omittingEmptySubsequences: false).map { line -> String in
let trimmed = line.drop { $0 == " " || $0 == "\t" }
return trimmed.hasPrefix(": ") ? String(trimmed.dropFirst(2)) : String(trimmed.dropFirst(trimmed.hasPrefix(":") ? 1 : 0))
}
return "" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + " \n"
case .dynamicBlock:
let lines = node.text.components(separatedBy: "\n")
return blocks(lines.dropFirst().dropLast(2).joined(separator: "\n"))
default:
// Keywords, comments, drawers, planning, clocks, footnote definitions, formulas.
return ""
}
}
/// The `:exports` value of a src block, or nil for other blocks.
static func srcExports(_ node: SyntaxNode) -> String? {
guard DocumentModel.isSrcBlock(node) else { return nil }
let first = node.text.components(separatedBy: "\n").first ?? ""
if let m = first.firstMatch(of: /:exports[ \t]+(\S+)/) { return m.1.lowercased() }
return "code"
}
private func slug(_ text: String) -> String {
var slug = ""
var dash = false
for c in text.lowercased() {
if c.isLetter || c.isNumber {
if dash, !slug.isEmpty { slug.append("-") }
slug.append(c)
dash = false
} else {
dash = true
}
}
if slug.isEmpty { slug = "section" }
var candidate = slug
var n = 1
while ids.contains(candidate) {
candidate = "\(slug)-\(n)"
n += 1
}
ids.insert(candidate)
return candidate
}
private func heading(_ node: SyntaxNode) -> String {
let tokens = node.tokens
let level = tokens.first { $0.kind == .stars }?.text.count ?? 1
let title = node.firstChild(.title)
let titleText = title?.text.trimmingCharacters(in: .whitespaces) ?? ""
// `CUSTOM_ID` from the property drawer that follows.
var customID: String?
if let section = node.parent, let drawer = section.firstChild(.propertyDrawer) {
for property in drawer.children where property.kind == .nodeProperty {
if let m = property.text.firstMatch(of: /^[ \t]*:CUSTOM_ID:[ \t]*(\S+)/) { customID = String(m.1) }
}
}
let id = customID ?? slug(titleText)
if customID != nil { ids.insert(id) }
var parts: [String] = []
if let todo = tokens.first(where: { $0.kind == .todoKeyword })?.text {
parts.append("\(Self.escape(todo)) ")
}
if let priority = tokens.first(where: { $0.kind == .priority })?.text {
parts.append("\(Self.escape(priority)) ")
}
if let title, !titleText.isEmpty { parts.append(inline(title).trimmingCharacters(in: .whitespaces)) }
if let tags = tokens.first(where: { $0.kind == .tags })?.text {
parts += tags.split(separator: ":").map { "\(Self.escape(String($0))) " }
}
let h = min(level + 1, 6)
return "\(parts.joined(separator: " ")) \n"
}
private func paragraph(_ node: SyntaxNode) -> String {
let body = inline(node).trimmingCharacters(in: .whitespacesAndNewlines)
if let image = soleImage(node), affiliated["CAPTION"] != nil || affiliated["ATTR_HTML"] != nil {
var out = "\n" + image + "\n"
if let caption = affiliated["CAPTION"] {
figures += 1
out += "Figure \(figures): \(inlineString(caption)) \n"
}
return out + " \n"
}
return "\(body)
\n"
}
/// The ` ` of a paragraph that is only an image link.
private func soleImage(_ node: SyntaxNode) -> String? {
let links = node.children.filter { $0.kind == .link }
guard links.count == 1, node.children.count == 1,
node.tokens.allSatisfy({ $0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else { return nil }
let html = link(links[0])
return html.hasPrefix(" Bool {
let bullet = item.tokens.first { $0.kind == .bullet }?.text ?? "-"
return bullet.first.map { $0.isNumber || $0.isLetter } ?? false
}
/// As orgo does, a change between ordered and unordered bullets starts another list.
private func list(_ node: SyntaxNode) -> String {
var runs: [[SyntaxNode]] = []
for item in node.children where item.kind == .item {
if let last = runs.last?.last, isOrdered(last) == isOrdered(item) {
runs[runs.count - 1].append(item)
} else {
runs.append([item])
}
}
return runs.map(list).joined()
}
private func list(_ items: [SyntaxNode]) -> String {
guard let first = items.first else { return "" }
let ordered = isOrdered(first)
let description = !ordered && firstLine(first).contains(" :: ")
if description {
var out = "\n"
for item in items {
let (term, rest) = descriptionParts(item)
out += "\(term) \n\(rest) \n"
}
return out + " \n"
}
let tag = ordered ? "ol" : "ul"
var out = "<\(tag)>\n"
for item in items { out += listItem(item) }
return out + "\(tag)>\n"
}
private func firstLine(_ item: SyntaxNode) -> String {
item.firstChild(.paragraph)?.text.components(separatedBy: "\n").first ?? ""
}
private func descriptionParts(_ item: SyntaxNode) -> (String, String) {
guard let paragraph = item.firstChild(.paragraph) else { return ("", "") }
let text = paragraph.text
guard let separator = text.range(of: " :: ") ?? text.range(of: " ::\n") else { return ("", inline(paragraph)) }
let term = inlineString(String(text[.. String {
let tree = OrgParser.parse(text, defaults: settings)
let paragraphs = tree.root.descendants().filter { $0.kind == .paragraph }
guard !paragraphs.isEmpty else { return Self.escape(text.trimmingCharacters(in: .whitespacesAndNewlines)) }
return paragraphs.map { inline($0).trimmingCharacters(in: .whitespacesAndNewlines) }.joined(separator: "\n")
}
private func listItem(_ item: SyntaxNode) -> String {
var open = ""
var prefix = ""
if let box = item.tokens.first(where: { $0.kind == .checkbox })?.text {
switch box {
case "[X]", "[x]":
open = " "
prefix = "[X] "
case "[-]":
open = " "
prefix = "[-] "
default:
open = " "
prefix = "[ ] "
}
}
let paragraphs = item.children.filter { $0.kind == .paragraph }.count
var body = ""
for child in item.children {
if child.kind == .paragraph {
let content = inline(child).trimmingCharacters(in: .whitespacesAndNewlines)
body += paragraphs >= 2 ? "\(content)
\n" : content
} else {
body += element(child)
}
}
if paragraphs >= 2 {
body = prefix + body
} else {
body = prefix + body
}
return open + body + (body.hasSuffix("\n") ? "" : "") + " \n"
}
// MARK: - Tables
private func table(_ node: SyntaxNode) -> String {
let rows = node.children.filter { $0.kind == .tableRow }
var groups: [[SyntaxNode]] = [[]]
for row in rows {
if row.children.isEmpty, row.text.trimmingCharacters(in: .whitespaces).hasPrefix("|-") {
if !groups[groups.count - 1].isEmpty { groups.append([]) }
} else {
groups[groups.count - 1].append(row)
}
}
groups.removeAll { $0.isEmpty }
var out = "\n"
if let caption = affiliated["CAPTION"] { out += "\(inlineString(caption)) \n" }
let hasHeader = groups.count > 1
for (index, group) in groups.enumerated() {
let header = hasHeader && index == 0
if header { out += "\n" } else if index == (hasHeader ? 1 : 0) { out += " \n" }
for row in group {
out += "" + row.children.filter { $0.kind == .tableCell }.map { cell in
let content = inline(cell).trimmingCharacters(in: .whitespaces)
return header ? "\(content) " : "\(content) "
}.joined() + " \n"
}
if header { out += "\n" }
}
if !groups.isEmpty, !(hasHeader && groups.count == 1) { out += " \n" }
return out + "
\n"
}
// MARK: - Blocks
private func block(_ node: SyntaxNode) -> String {
var lines = node.text.components(separatedBy: "\n")
if lines.last == "" { lines.removeLast() }
guard let first = lines.first,
let m = first.firstMatch(of: /^[ \t]*#\+(?i:begin)_(\S+)[ \t]*(.*)$/) else { return "" }
let type = m.1.lowercased()
let parameters = String(m.2)
let body = Array(lines.dropFirst().dropLast())
// Commas protect `*` and `#+` lines inside blocks.
let unescaped = body.map { $0.replacingOccurrences(of: "^([ \\t]*),([,*]|#\\+)", with: "$1$2", options: .regularExpression) }
let text = unescaped.joined(separator: "\n")
switch type {
case "src":
let language = parameters.split(separator: " ").first.map(String.init)
let code = Self.escape(dedent(unescaped).joined(separator: "\n"))
let cls = language.map { " class=\"language-\(Self.escape($0))\"" } ?? ""
return "\(code) \n"
case "example":
return "\(Self.escape(dedent(unescaped).joined(separator: "\n"))) \n"
case "quote":
return "\n\(blocks(text)) \n"
case "center":
return "\n\(blocks(text))
\n"
case "verse":
let rendered = unescaped.map { line -> String in
let indent = line.prefix { $0 == " " }.count
return String(repeating: " ", count: indent) + inlineString(String(line.dropFirst(indent)))
}
return "\n" + rendered.joined(separator: " \n") + "\n
\n"
case "export":
return parameters.lowercased().hasPrefix("html") ? text + "\n" : ""
case "comment":
return ""
default:
return "\n\(blocks(text))
\n"
}
}
private func dedent(_ lines: [String]) -> [String] {
let indents = lines.filter { $0.contains(where: { !$0.isWhitespace }) }.map { $0.prefix { $0 == " " }.count }
let common = indents.min() ?? 0
return lines.map { String($0.dropFirst(min(common, $0.prefix { $0 == " " }.count))) }
}
// MARK: - Inline
func inline(_ node: SyntaxNode) -> String {
var out = ""
for element in node.elements {
switch element {
case .token(let token):
switch token.kind {
case .marker, .stars, .bullet, .checkbox, .tags, .todoKeyword, .priority:
continue
default:
out += entities(token.text)
}
case .node(let child):
out += object(child)
}
}
return out
}
/// `\alpha` and friends as characters.
private func entities(_ text: String) -> String {
guard text.contains("\\") else { return Self.escape(text) }
var out = ""
var rest = Substring(text)
while let m = rest.firstMatch(of: /\\([a-zA-Z]+)(\{\})?/) {
out += Self.escape(String(rest[.. String {
switch node.kind {
case .bold: return "\(inline(node)) "
case .italic: return "\(inline(node)) "
case .underline: return "\(inline(node)) "
case .strikeThrough: return "\(inline(node))"
case .verbatim: return "\(inline(node))"
case .code: return "\(inline(node))"
case .link: return link(node)
case .linkDescription: return inline(node)
case .timestamp: return timestamp(node.text)
case .footnoteReference: return footnote(node.text)
case .superscript:
let text = node.text
let body = text.dropFirst()
let inner = body.hasPrefix("{") && body.hasSuffix("}") ? String(body.dropFirst().dropLast()) : String(body)
return text.hasPrefix("_") ? "\(Self.escape(inner)) " : "\(Self.escape(inner)) "
case .lineBreak: return " \n"
case .target, .macro: return ""
case .inlineSourceBlock:
if let m = node.text.firstMatch(of: /\{(.*)\}$/) { return "\(Self.escape(String(m.1)))" }
return Self.escape(node.text)
case .statisticsCookie, .latexFragment: return Self.escape(node.text)
default: return inline(node)
}
}
static let imageExtensions: Set = ["png", "jpg", "jpeg", "gif", "svg", "webp", "bmp", "tif", "tiff", "avif"]
private func link(_ node: SyntaxNode) -> String {
let path = node.tokens.first { $0.kind == .linkPath }?.text
let description = node.firstChild(.linkDescription)
guard let path else {
// A plain or angle link.
var target = node.text
if target.hasPrefix("<"), target.hasSuffix(">") { target = String(target.dropFirst().dropLast()) }
return "\(Self.escape(target)) "
}
var href = path
var label = path
if path.hasPrefix("file:") {
href = String(path.dropFirst(5))
label = href
} else if path.hasPrefix("id:") {
href = "#" + path.dropFirst(3)
label = String(path.dropFirst(3))
} else if path.hasPrefix("*") {
href = "#" + slugOnly(String(path.dropFirst()))
} else if !path.hasPrefix("#"), !path.contains(":"), !path.contains("/"), !path.contains(".") {
href = "#" + slugOnly(path)
}
let isFile = path.hasPrefix("file:") || (!path.contains(":") && (path.contains("/") || path.contains(".")))
if isFile, href.lowercased().hasSuffix(".org") { href = String(href.dropLast(4)) + ".html" }
if isFile, let range = href.range(of: ".org::") { href = String(href[.."
}
let text = description.map(inline) ?? Self.escape(label)
return "\(text) "
}
private func slugOnly(_ text: String) -> String {
var slug = ""
var dash = false
for c in text.lowercased() {
if c.isLetter || c.isNumber {
if dash, !slug.isEmpty { slug.append("-") }
slug.append(c)
dash = false
} else {
dash = true
}
}
return slug
}
/// `:key value :other "quoted value"`.
private func parseAttributes(_ text: String) -> [(String, String)] {
var pairs: [(String, String)] = []
let regex = try! NSRegularExpression(pattern: ":([A-Za-z_-]+)[ \\t]+(\"[^\"]*\"|[^:\\s][^\\s]*)")
let ns = text as NSString
for m in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
var value = ns.substring(with: m.range(at: 2))
if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 { value = String(value.dropFirst().dropLast()) }
pairs.append((ns.substring(with: m.range(at: 1)), value))
}
return pairs
}
private func timestamp(_ text: String) -> String {
guard let stamp = Timestamp.parse(text) else { return Self.escape(text) }
func date(_ p: Timestamp.Point) -> String { String(format: "%04d-%02d-%02d", p.year, p.month, p.day) }
func time(_ p: Timestamp.Point) -> String? {
guard let h = p.hour, let m = p.minute else { return nil }
return String(format: "%02d:%02d", h, m)
}
func element(_ p: Timestamp.Point, full: Bool) -> String {
let datetime = date(p) + (time(p).map { "T" + $0 } ?? "")
let shown = full ? date(p) + (time(p).map { " " + $0 } ?? "") : (time(p) ?? date(p))
return "\(shown) "
}
guard let end = stamp.end else { return element(stamp.start, full: true) }
let sameDay = end.year == stamp.start.year && end.month == stamp.start.month && end.day == stamp.start.day
return element(stamp.start, full: true) + "–" + element(end, full: !sameDay)
}
private func footnote(_ text: String) -> String {
guard let m = text.firstMatch(of: /^\[fn:([^:\]]*)(?::(.*))?\]$/) else { return Self.escape(text) }
var label = String(m.1)
let inlineText = m.2.map(String.init)
if label.isEmpty {
anonymous += 1
label = "anonymous-\(anonymous)"
}
let number: Int
if let existing = footnoteNumbers[label] {
number = existing
} else {
let content: FootnoteContent
if let inlineText {
content = .inline(inlineText)
} else if let definition = definitions[label] {
content = .definition(definition)
} else {
return Self.escape(text)
}
footnoteOrder.append((label, content))
number = footnoteOrder.count
footnoteNumbers[label] = number
}
return ""
}
}