Sources/OrgCore/Export/HTMLExport.swift
902 lines · 42577 bytes
1import Foundation
2
3/// Document metadata from `#+TITLE`, `#+AUTHOR` and `#+DATE`.
4public struct ExportMetadata: Sendable, Equatable {
5 public var title: String?
6 public var author: String?
7 public var date: String?
8}
9
10/// HTML export. The body follows the shape orgo produces, which the org-conformance corpus
11/// holds as the reference: headings one level down (`*` is `<h2>`), timestamps as `<time>`,
12/// footnotes collected at the end, captioned images as figures.
13public enum HTMLExport {
14 public static func body(_ text: String, settings: OrgSettings = .default) -> String {
15 let renderer = HTMLRenderer(settings: settings, options: .orgo)
16 return renderer.document(text)
17 }
18
19 public static func metadata(_ text: String, settings: OrgSettings = .default) -> ExportMetadata {
20 let tree = OrgParser.parse(text, defaults: settings)
21 var meta = ExportMetadata()
22 // Setup files' keywords come first, as Org reads them where `#+SETUPFILE` is.
23 let setup = settings.setupKeywords.split(separator: "\n").map(String.init)
24 let own = tree.root.children.filter { $0.kind == .zerothSection }.flatMap { $0.children.filter { $0.kind == .keyword } }.map(\.text)
25 for text in setup + own {
26 let line = text.trimmingCharacters(in: .whitespacesAndNewlines)
27 guard let colon = line.firstIndex(of: ":") else { continue }
28 let key = line.dropFirst(2).prefix { $0 != ":" }.uppercased()
29 let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
30 switch key {
31 case "TITLE": meta.title = value
32 case "AUTHOR": meta.author = value
33 case "DATE": meta.date = value
34 default: break
35 }
36 }
37 return meta
38 }
39
40 /// A standalone page: the body under the document's title, with a small stylesheet, as
41 /// `#+OPTIONS` and the export keywords ask. `path` is the file's, for `#+INCLUDE` and
42 /// `{{{input-file}}}`.
43 public static func page(_ text: String, fallbackTitle: String, settings: OrgSettings = .default, path: String? = nil, now: Date = Date()) -> String {
44 let directory = path.map { URL(fileURLWithPath: $0).deletingLastPathComponent() }
45 let text = ExportPreprocess.macros(ExportPreprocess.includes(text, directory: directory), path: path, now: now, setup: settings.setupKeywords)
46 let options = ExportOptions.parse(settings.setupKeywords + text)
47 let meta = metadata(text, settings: settings)
48 let title = meta.title ?? fallbackTitle
49 let renderer = HTMLRenderer(settings: settings, options: options)
50 // The title isn't part of the document Org processes citations in: they drop out.
51 renderer.inTitle = true
52 let titleHTML = renderer.inlineString(title)
53 renderer.inTitle = false
54 renderer.citations = CiteProcessor(text: text, directory: directory)
55 var header = options.title ? "<h1 class=\"title\">\(titleHTML)</h1>\n" : ""
56 let byline = [options.author ? meta.author : nil, options.date ? meta.date : nil].compactMap { $0 }.filter { !$0.isEmpty }
57 if !byline.isEmpty { header += "<p class=\"byline\">\(byline.map(HTMLRenderer.escape).joined(separator: " · "))</p>\n" }
58 let body = renderer.document(renderer.citations == nil ? text : renderer.citations!.inliningNotes(CiteProcessor.removingNocite(text)))
59 header += renderer.tableOfContents()
60 var head = options.htmlHead.map { $0 + "\n" }.joined()
61 if renderer.hasMath {
62 head += """
63 <script>window.MathJax = { tex: { inlineMath: [['\\\\(', '\\\\)']], displayMath: [['\\\\[', '\\\\]'], ['$$', '$$']] } };</script>
64 <script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
65
66 """
67 }
68 return """
69 <!DOCTYPE html>
70 <html lang="en">
71 <head>
72 <meta charset="utf-8">
73 <meta name="viewport" content="width=device-width, initial-scale=1">
74 <title>\(HTMLRenderer.escape(title))</title>
75 <style>
76 body { font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; max-width: 46em; margin: 2em auto; padding: 0 1em; color: #222; }
77 @media (prefers-color-scheme: dark) { body { background: #1d1d1f; color: #ddd; } a { color: #6cb6ff; } }
78 pre { background: rgba(127, 127, 127, 0.12); padding: 0.75em; overflow-x: auto; }
79 code { font: 0.9em ui-monospace, Menlo, monospace; }
80 table { border-collapse: collapse; } th, td { border: 1px solid rgba(127, 127, 127, 0.4); padding: 0.25em 0.6em; }
81 blockquote { margin-left: 0; padding-left: 1em; border-left: 3px solid rgba(127, 127, 127, 0.4); }
82 .tag { font-size: 0.75em; padding: 0 0.3em; border-radius: 3px; background: rgba(127, 127, 127, 0.18); }
83 .todo { color: #c33; } .done { color: #393; } .byline { color: #888; } .center { text-align: center; }
84 figure { margin: 1em 0; } img { max-width: 100%; }
85 #table-of-contents ul { list-style: none; padding-left: 1.2em; } #table-of-contents > div > ul { padding-left: 0; }
86 </style>
87 \(head)</head>
88 <body>
89 \(header)\(body)</body>
90 </html>
91
92 """
93 }
94}
95
96/// The tree being exported, for what its src blocks, calls and inline blocks export.
97final class ExportSource {
98 let tree: OrgTree
99 let text: NSString
100 private lazy var model = DocumentModel(tree: tree)
101 /// Whether the last inline src block or call lets the `{{{results(…)}}}` after it show;
102 /// nil when nothing asks.
103 var inlineResults: Bool?
104
105 init(tree: OrgTree, text: String) {
106 self.tree = tree
107 self.text = text as NSString
108 }
109
110 func exports(at offset: Int) -> String? {
111 Babel.exports(at: offset, tree: tree, model: model, text: text)
112 }
113
114 /// `{{{results(…)}}}`, Org's `$1` macro, as the inline block or call before it allows:
115 /// nil for other macros, the text to show (or nil) for this one.
116 func resultsMacro(_ node: SyntaxNode) -> String?? {
117 guard let m = node.text.firstMatch(of: /(?i)^\{\{\{results\((.*)\)\}\}\}$/) else { return .none }
118 defer { inlineResults = nil }
119 return inlineResults ?? true ? ExportPreprocess.arguments(String(m.1)).first : .some(nil)
120 }
121}
122
123final class HTMLRenderer {
124 let settings: OrgSettings
125 let options: ExportOptions
126 /// With select tags in the document: the sections to export, by heading start.
127 private var selected: Set<Int>?
128 private var numbers: [Int] = []
129 private var tocEntries: [(level: Int, id: String, html: String)] = []
130 /// A LaTeX fragment was exported, for MathJax.
131 private(set) var hasMath = false
132 private var footnoteNumbers: [String: Int] = [:]
133 private var footnoteOrder: [(label: String, content: FootnoteContent)] = []
134 private var definitions: [String: String] = [:]
135 private var anonymous = 0
136 private var figures = 0
137 private var ids: Set<String> = []
138 /// Citations and the bibliography, as `oc-basic` exports them.
139 var citations: CiteProcessor?
140 /// Rendering the title, where Org exports citations as nothing.
141 var inTitle = false
142
143 enum FootnoteContent {
144 case definition(String)
145 case inline(String)
146 }
147
148 init(settings: OrgSettings, options: ExportOptions = .orgo) {
149 self.settings = settings
150 self.options = options
151 }
152
153 static func escape(_ s: String) -> String {
154 var out = ""
155 for c in s {
156 switch c {
157 case "&": out += "&"
158 case "<": out += "<"
159 case ">": out += ">"
160 case "\"": out += """
161 default: out.append(c)
162 }
163 }
164 return out
165 }
166
167 // MARK: - Document
168
169 func document(_ text: String) -> String {
170 let tree = OrgParser.parse(text, defaults: settings)
171 collectDefinitions(tree.root)
172 selected = Self.selection(tree, tags: options.selectTags)
173 var out = ""
174 rendering(tree, text) {
175 for child in tree.root.children { out += element(child) }
176 }
177 out += footnoteSection()
178 return out
179 }
180
181 private var source: ExportSource?
182
183 /// `body` with `tree` as the source of `:exports`.
184 private func rendering<T>(_ tree: OrgTree, _ text: String, _ body: () -> T) -> T {
185 let saved = source
186 source = ExportSource(tree: tree, text: text)
187 defer { source = saved }
188 return body()
189 }
190
191 private func collectDefinitions(_ node: SyntaxNode) {
192 for child in node.children {
193 if child.kind == .footnoteDefinition {
194 let text = child.text
195 if let m = text.firstMatch(of: /^\[fn:([^\]]+)\][ \t]*/) {
196 definitions[String(m.1)] = String(text[m.range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
197 }
198 } else {
199 collectDefinitions(child)
200 }
201 }
202 }
203
204 private func footnoteSection() -> String {
205 guard !footnoteOrder.isEmpty else { return "" }
206 var out = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n"
207 for (index, note) in footnoteOrder.enumerated() {
208 let n = index + 1
209 let content: String
210 switch note.content {
211 case .definition(let text): content = blocks(text).trimmingCharacters(in: .newlines)
212 case .inline(let text): content = inlineString(text)
213 }
214 out += "<li id=\"fn-\(n)\">\(content)\n <a class=\"footnote-back\" href=\"#fnr-\(n)\" aria-label=\"Back to reference \(n)\">↩</a></li>\n"
215 }
216 return out + "</ol>\n</section>\n"
217 }
218
219 /// Org text rendered as elements, for block and footnote contents.
220 func blocks(_ text: String) -> String {
221 let tree = OrgParser.parse(text, defaults: settings)
222 var out = ""
223 rendering(tree, text) {
224 for child in tree.root.children { out += element(child) }
225 }
226 return out
227 }
228
229 /// One line of org text rendered inline.
230 func inlineString(_ text: String) -> String {
231 let tree = OrgParser.parse(text, defaults: settings)
232 guard let paragraph = tree.root.descendants().first(where: { $0.kind == .paragraph }) else { return Self.escape(text) }
233 return rendering(tree, text) { inline(paragraph).trimmingCharacters(in: .whitespacesAndNewlines) }
234 }
235
236 /// A citation's or a bibliography entry's pieces: text escaped, prefixes and suffixes as
237 /// Org markup with their blanks kept.
238 private func render(_ pieces: [CiteProcessor.Piece]) -> String {
239 pieces.map { piece in
240 switch piece {
241 case .text(let s): Self.escape(s)
242 case .italic(let s): "<i>" + Self.escape(s) + "</i>"
243 case .org(let s) where s.allSatisfy(\.isWhitespace): s
244 case .org(let s):
245 String(s.prefix { $0.isWhitespace }) + inlineString(s) + (s.allSatisfy(\.isWhitespace) ? "" : String(s.reversed().prefix { $0.isWhitespace }))
246 }
247 }.joined()
248 }
249
250 // MARK: - Elements
251
252 private var affiliated: [String: String] = [:]
253 /// `:exports` of the last src block or call: its results are exported only for `results`
254 /// and `both`; nothing runs.
255 private var lastExports = "code"
256
257 func element(_ node: SyntaxNode) -> String {
258 defer { if node.kind != .affiliatedKeyword { affiliated = [:] } }
259 if node.kind != .affiliatedKeyword, affiliated["RESULTS"] != nil {
260 defer { lastExports = "code" }
261 if lastExports != "results", lastExports != "both" { return "" }
262 }
263 if node.kind == .block || node.kind == .keyword, let exports = source?.exports(at: node.range.lowerBound) {
264 lastExports = exports
265 if exports == "results" || exports == "none" { return "" }
266 }
267 switch node.kind {
268 case .section:
269 guard let heading = node.firstChild(.heading) else { fallthrough }
270 switch Self.sectionExport(heading, options: options, selected: selected) {
271 case .nothing: return ""
272 case .heading: return self.heading(heading)
273 case .all: break
274 }
275 var out = ""
276 for child in node.children { out += element(child) }
277 return out
278 case .zerothSection, .document:
279 var out = ""
280 for child in node.children { out += element(child) }
281 return out
282 case .heading:
283 return heading(node)
284 case .affiliatedKeyword:
285 let line = node.text.trimmingCharacters(in: .whitespacesAndNewlines)
286 if let m = line.firstMatch(of: /^#\+([A-Za-z_]+):[ \t]*(.*)$/) {
287 affiliated[m.1.uppercased()] = String(m.2)
288 }
289 return ""
290 case .paragraph:
291 return paragraph(node)
292 case .keyword where citations != nil && node.text.range(of: "^[ \\t]*#\\+print_bibliography:", options: [.regularExpression, .caseInsensitive]) != nil:
293 return citations!.bibliography.map { "<p>" + render($0) + "</p>\n" }.joined()
294 case .plainList:
295 return list(node)
296 case .table:
297 return table(node)
298 case .block:
299 return block(node)
300 case .horizontalRule:
301 return "<hr>\n"
302 case .inlineTask:
303 // As ox-html: the title in bold, then the contents.
304 var out = "<div class=\"inlinetask\">\n"
305 for child in node.children {
306 if child.kind == .heading {
307 guard child.range.lowerBound == node.range.lowerBound else { continue }
308 let title = child.firstChild(.title).map { inline($0).trimmingCharacters(in: .whitespaces) } ?? ""
309 let todo = options.todo ? child.tokens.first { $0.kind == .todoKeyword }.map { "<span class=\"todo \(Self.escape($0.text))\">\(Self.escape($0.text))</span> " } ?? "" : ""
310 out += "<b>\(todo)\(title)</b><br>\n"
311 } else {
312 out += element(child)
313 }
314 }
315 return out + "</div>\n"
316 case .latexEnvironment:
317 // For MathJax, as ox-html leaves it with `tex:t`.
318 guard options.tex else { return "" }
319 hasMath = true
320 return Self.escape(node.text.trimmingCharacters(in: .newlines)) + "\n"
321 case .fixedWidth:
322 let lines = node.text.split(separator: "\n", omittingEmptySubsequences: false).map { line -> String in
323 let trimmed = line.drop { $0 == " " || $0 == "\t" }
324 return trimmed.hasPrefix(": ") ? String(trimmed.dropFirst(2)) : String(trimmed.dropFirst(trimmed.hasPrefix(":") ? 1 : 0))
325 }
326 return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n"
327 case .tableEl:
328 let lines = node.text.components(separatedBy: "\n").filter { !$0.lowercased().contains("#+tblfm") }
329 // As `org-html-table--table.el-table`: cells with their spans, lines kept apart.
330 if let cells = TableEl.cells(lines.joined(separator: "\n")) {
331 var out = "<table class=\"table-el\">\n"
332 for row in Dictionary(grouping: cells, by: \.row).keys.sorted() {
333 out += "<tr>"
334 for cell in cells where cell.row == row {
335 let spans = (cell.colspan > 1 ? " colspan=\"\(cell.colspan)\"" : "") + (cell.rowspan > 1 ? " rowspan=\"\(cell.rowspan)\"" : "")
336 let content = cell.lines.reversed().drop { $0.isEmpty }.reversed().map(Self.escape).joined(separator: "<br>")
337 out += "<td\(spans)>\(content)</td>"
338 }
339 out += "</tr>\n"
340 }
341 return out + "</table>\n"
342 }
343 return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n"
344 case .dynamicBlock:
345 let lines = node.text.components(separatedBy: "\n")
346 return blocks(lines.dropFirst().dropLast(2).joined(separator: "\n"))
347 default:
348 // Keywords, comments, drawers, planning, clocks, footnote definitions, formulas.
349 return ""
350 }
351 }
352
353 private func slug(_ text: String) -> String {
354 var slug = ""
355 var dash = false
356 for c in text.lowercased() {
357 if c.isLetter || c.isNumber {
358 if dash, !slug.isEmpty { slug.append("-") }
359 slug.append(c)
360 dash = false
361 } else {
362 dash = true
363 }
364 }
365 if slug.isEmpty { slug = "section" }
366 var candidate = slug
367 var n = 1
368 while ids.contains(candidate) {
369 candidate = "\(slug)-\(n)"
370 n += 1
371 }
372 ids.insert(candidate)
373 return candidate
374 }
375
376 /// `org-export--selected-trees`: headings with a select tag, everything below them and
377 /// their ancestors; nil when no heading has one.
378 enum SectionExport {
379 case all, heading, nothing
380 }
381
382 /// `org-export--skip-p` for the section under `heading`: excluded tags, COMMENT and
383 /// unselected trees export nothing, archived trees their heading only.
384 static func sectionExport(_ heading: SyntaxNode, options: ExportOptions, selected: Set<Int>?) -> SectionExport {
385 let tags = heading.tokens.first { $0.kind == .tags }?.text.split(separator: ":").map(String.init) ?? []
386 let title = heading.firstChild(.title)?.text.trimmingCharacters(in: .whitespaces) ?? ""
387 if tags.contains(where: options.excludeTags.contains) || title == "COMMENT" || title.hasPrefix("COMMENT ") { return .nothing }
388 if let selected, !selected.contains(heading.range.lowerBound) { return .nothing }
389 return tags.contains("ARCHIVE") ? .heading : .all
390 }
391
392 static func selection(_ tree: OrgTree, tags: [String]) -> Set<Int>? {
393 let model = DocumentModel(tree: tree)
394 let picked = model.headings.indices.filter { model.headings[$0].localTags.contains(where: tags.contains) }
395 guard !picked.isEmpty else { return nil }
396 var set: Set<Int> = []
397 for index in picked {
398 set.insert(model.headings[index].headingRange.lowerBound)
399 for a in model.ancestors(of: index) { set.insert(model.headings[a].headingRange.lowerBound) }
400 let level = model.headings[index].level
401 for d in model.headings[(index + 1)...].prefix(while: { $0.level > level }) { set.insert(d.headingRange.lowerBound) }
402 }
403 return set
404 }
405
406 /// The table of contents, as ox-html writes it, or nothing.
407 func tableOfContents() -> String {
408 guard options.toc != nil, !tocEntries.isEmpty else { return "" }
409 var out = "<div id=\"table-of-contents\" role=\"doc-toc\">\n<h2>Table of Contents</h2>\n<div id=\"text-table-of-contents\" role=\"doc-toc\">\n"
410 var depth = 0
411 var open: [Bool] = []
412 let base = tocEntries.map(\.level).min() ?? 1
413 for entry in tocEntries {
414 let level = entry.level - base + 1
415 if level > depth {
416 while depth < level {
417 out += "\n<ul>\n"
418 depth += 1
419 open.append(false)
420 }
421 } else {
422 out += "</li>\n"
423 while depth > level {
424 out += "</ul>\n</li>\n"
425 depth -= 1
426 open.removeLast()
427 }
428 }
429 out += "<li><a href=\"#\(Self.escape(entry.id))\">\(entry.html)</a>"
430 }
431 out += "</li>\n"
432 while depth > 1 {
433 out += "</ul>\n</li>\n"
434 depth -= 1
435 }
436 return out + "</ul>\n</div>\n</div>\n"
437 }
438
439 private func heading(_ node: SyntaxNode) -> String {
440 let tokens = node.tokens
441 let level = tokens.first { $0.kind == .stars }?.text.count ?? 1
442 let title = node.firstChild(.title)
443 let titleText = title?.text.trimmingCharacters(in: .whitespaces) ?? ""
444 // `CUSTOM_ID` from the property drawer that follows.
445 var customID: String?
446 if let section = node.parent, let drawer = section.firstChild(.propertyDrawer) {
447 for property in drawer.children where property.kind == .nodeProperty {
448 if let m = property.text.firstMatch(of: /^[ \t]*:CUSTOM_ID:[ \t]*(\S+)/) { customID = String(m.1) }
449 }
450 }
451 let id = customID ?? slug(titleText)
452 if customID != nil { ids.insert(id) }
453 var parts: [String] = []
454 let h = min(level + 1, 6)
455 if let num = options.num, level <= num {
456 if numbers.count < level { numbers += Array(repeating: 0, count: level - numbers.count) }
457 numbers = Array(numbers.prefix(level))
458 numbers[level - 1] += 1
459 parts.append("<span class=\"section-number-\(h)\">\(numbers.map(String.init).joined(separator: ".")).</span>")
460 }
461 if options.todo, let todo = tokens.first(where: { $0.kind == .todoKeyword })?.text {
462 parts.append("<span class=\"todo \(settings.isDone(todo) ? "done " : "")\(Self.escape(todo))\">\(Self.escape(todo))</span>")
463 }
464 if options.priority, let priority = tokens.first(where: { $0.kind == .priority })?.text {
465 parts.append("<span class=\"priority\">\(Self.escape(priority))</span>")
466 }
467 if let title, !titleText.isEmpty { parts.append(inline(title).trimmingCharacters(in: .whitespaces)) }
468 var tagParts: [String] = []
469 if options.tags, let tags = tokens.first(where: { $0.kind == .tags })?.text {
470 tagParts = tags.split(separator: ":").map { "<span class=\"tag\">\(Self.escape(String($0)))</span>" }
471 }
472 if let toc = options.toc, level <= toc {
473 tocEntries.append((level, id, (parts + (options.tagsInTOC ? tagParts : [])).joined(separator: " ")))
474 }
475 parts += tagParts
476 return "<h\(h) id=\"\(Self.escape(id))\">\(parts.joined(separator: " "))</h\(h)>\n"
477 }
478
479 private func paragraph(_ node: SyntaxNode) -> String {
480 let body = inline(node).trimmingCharacters(in: .whitespacesAndNewlines)
481 if let image = soleImage(node), affiliated["CAPTION"] != nil || affiliated["ATTR_HTML"] != nil {
482 var out = "<figure>\n" + image + "\n"
483 if let caption = affiliated["CAPTION"] {
484 figures += 1
485 out += "<figcaption>Figure \(figures): \(inlineString(caption))</figcaption>\n"
486 }
487 return out + "</figure>\n"
488 }
489 return "<p>\(body)</p>\n"
490 }
491
492 /// The `<img>` of a paragraph that is only an image link.
493 private func soleImage(_ node: SyntaxNode) -> String? {
494 let links = node.children.filter { $0.kind == .link }
495 guard links.count == 1, node.children.count == 1,
496 node.tokens.allSatisfy({ $0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else { return nil }
497 let html = link(links[0])
498 return html.hasPrefix("<img") ? html : nil
499 }
500
501 // MARK: - Lists
502
503 private func isOrdered(_ item: SyntaxNode) -> Bool {
504 let bullet = item.tokens.first { $0.kind == .bullet }?.text ?? "-"
505 return bullet.first.map { $0.isNumber || $0.isLetter } ?? false
506 }
507
508 /// As orgo does, a change between ordered and unordered bullets starts another list.
509 private func list(_ node: SyntaxNode) -> String {
510 var runs: [[SyntaxNode]] = []
511 for item in node.children where item.kind == .item {
512 if let last = runs.last?.last, isOrdered(last) == isOrdered(item) {
513 runs[runs.count - 1].append(item)
514 } else {
515 runs.append([item])
516 }
517 }
518 return runs.map(list).joined()
519 }
520
521 private func list(_ items: [SyntaxNode]) -> String {
522 guard let first = items.first else { return "" }
523 let ordered = isOrdered(first)
524 // `org-list-get-list-type`: descriptive when the first item has a tag.
525 let description = !ordered && first.firstChild(.itemTag) != nil
526 if description {
527 var out = "<dl>\n"
528 for item in items {
529 let (term, rest) = descriptionParts(item)
530 out += "<dt>\(term)</dt>\n<dd>\(rest)</dd>\n"
531 }
532 return out + "</dl>\n"
533 }
534 let tag = ordered ? "ol" : "ul"
535 var out = "<\(tag)>\n"
536 for item in items { out += listItem(item) }
537 return out + "</\(tag)>\n"
538 }
539
540 /// A description item's term, `(no term)` without one, and the rest of the item.
541 private func descriptionParts(_ item: SyntaxNode) -> (String, String) {
542 var term = item.firstChild(.itemTag).map { inline($0).trimmingCharacters(in: .whitespaces) } ?? "(no term)"
543 if let box = item.tokens.first(where: { $0.kind == .checkbox })?.text {
544 term = "<code>\(box == "[ ]" ? "[ ]" : box.uppercased())</code> " + term
545 }
546 var rest = ""
547 for (index, child) in item.children.filter({ $0.kind != .itemTag }).enumerated() {
548 rest += index == 0 && child.kind == .paragraph ? inline(child).trimmingCharacters(in: .whitespacesAndNewlines) : element(child)
549 }
550 return (term, rest.trimmingCharacters(in: .whitespacesAndNewlines))
551 }
552
553 private func listItem(_ item: SyntaxNode) -> String {
554 // ox-html gives an ordered item's counter as its value.
555 var open = "<li>"
556 if isOrdered(item), let counter = item.tokens.first(where: { $0.kind == .counter })?.text.firstMatch(of: /([0-9]+|[A-Za-z])\]/) {
557 open = "<li value=\"\(counter.1)\">"
558 }
559 var prefix = ""
560 if let box = item.tokens.first(where: { $0.kind == .checkbox })?.text {
561 switch box {
562 case "[X]", "[x]":
563 open = "<li class=\"on\">"
564 prefix = "<code>[X]</code> "
565 case "[-]":
566 open = "<li class=\"trans\">"
567 prefix = "<code>[-]</code> "
568 default:
569 open = "<li class=\"off\">"
570 prefix = "<code>[ ]</code> "
571 }
572 }
573 let paragraphs = item.children.filter { $0.kind == .paragraph }.count
574 var body = ""
575 for child in item.children {
576 if child.kind == .paragraph {
577 let content = inline(child).trimmingCharacters(in: .whitespacesAndNewlines)
578 body += paragraphs >= 2 ? "<p>\(content)</p>\n" : content
579 } else {
580 body += element(child)
581 }
582 }
583 if paragraphs >= 2 {
584 body = prefix + body
585 } else {
586 body = prefix + body
587 }
588 return open + body + (body.hasSuffix("\n") ? "" : "") + "</li>\n"
589 }
590
591 // MARK: - Tables
592
593 private func table(_ node: SyntaxNode) -> String {
594 let rows = node.children.filter { $0.kind == .tableRow }
595 var groups: [[SyntaxNode]] = [[]]
596 for row in rows {
597 if row.children.isEmpty, row.text.trimmingCharacters(in: .whitespaces).hasPrefix("|-") {
598 if !groups[groups.count - 1].isEmpty { groups.append([]) }
599 } else {
600 groups[groups.count - 1].append(row)
601 }
602 }
603 groups.removeAll { $0.isEmpty }
604 var out = "<table>\n"
605 if let caption = affiliated["CAPTION"] { out += "<caption>\(inlineString(caption))</caption>\n" }
606 let hasHeader = groups.count > 1
607 for (index, group) in groups.enumerated() {
608 let header = hasHeader && index == 0
609 if header { out += "<thead>\n" } else if index == (hasHeader ? 1 : 0) { out += "<tbody>\n" }
610 for row in group {
611 out += "<tr>" + row.children.filter { $0.kind == .tableCell }.map { cell in
612 let content = inline(cell).trimmingCharacters(in: .whitespaces)
613 return header ? "<th>\(content)</th>" : "<td>\(content)</td>"
614 }.joined() + "</tr>\n"
615 }
616 if header { out += "</thead>\n" }
617 }
618 if !groups.isEmpty, !(hasHeader && groups.count == 1) { out += "</tbody>\n" }
619 return out + "</table>\n"
620 }
621
622 // MARK: - Blocks
623
624 private func block(_ node: SyntaxNode) -> String {
625 var lines = node.text.components(separatedBy: "\n")
626 if lines.last == "" { lines.removeLast() }
627 guard let first = lines.first,
628 let m = first.firstMatch(of: /^[ \t]*#\+(?i:begin)_(\S+)[ \t]*(.*)$/) else { return "" }
629 let type = m.1.lowercased()
630 let parameters = String(m.2)
631 let body = Array(lines.dropFirst().dropLast())
632 // Commas protect `*` and `#+` lines inside blocks.
633 let unescaped = body.map { $0.replacingOccurrences(of: "^([ \\t]*),([,*]|#\\+)", with: "$1$2", options: .regularExpression) }
634 let text = unescaped.joined(separator: "\n")
635 switch type {
636 case "src":
637 let language = parameters.split(separator: " ").first.map(String.init)
638 let code = Self.escape(dedent(unescaped).joined(separator: "\n"))
639 let cls = language.map { " class=\"language-\(Self.escape($0))\"" } ?? ""
640 return "<pre><code\(cls)>\(code)</code></pre>\n"
641 case "example":
642 return "<pre>\(Self.escape(dedent(unescaped).joined(separator: "\n")))</pre>\n"
643 case "quote":
644 return "<blockquote>\n\(blocks(text))</blockquote>\n"
645 case "center":
646 return "<div class=\"center\">\n\(blocks(text))</div>\n"
647 case "verse":
648 let rendered = unescaped.map { line -> String in
649 let indent = line.prefix { $0 == " " }.count
650 return String(repeating: " ", count: indent) + inlineString(String(line.dropFirst(indent)))
651 }
652 return "<p class=\"verse\">\n" + rendered.joined(separator: "<br>\n") + "\n</p>\n"
653 case "export":
654 return parameters.lowercased().hasPrefix("html") ? text + "\n" : ""
655 case "comment":
656 return ""
657 default:
658 return "<div class=\"\(Self.escape(type))\">\n\(blocks(text))</div>\n"
659 }
660 }
661
662 private func dedent(_ lines: [String]) -> [String] {
663 let indents = lines.filter { $0.contains(where: { !$0.isWhitespace }) }.map { $0.prefix { $0 == " " }.count }
664 let common = indents.min() ?? 0
665 return lines.map { String($0.dropFirst(min(common, $0.prefix { $0 == " " }.count))) }
666 }
667
668 // MARK: - Inline
669
670 func inline(_ node: SyntaxNode) -> String {
671 var out = ""
672 for element in node.elements {
673 switch element {
674 case .token(let token):
675 switch token.kind {
676 case .marker, .stars, .bullet, .checkbox, .tags, .todoKeyword, .priority:
677 continue
678 default:
679 out += entities(token.text)
680 }
681 case .node(let child):
682 out += object(child)
683 }
684 }
685 return out
686 }
687
688 /// Text: LaTeX fragments left for MathJax, `\alpha` and friends as characters, `_` and `^`
689 /// scripts as `<sub>` and `<sup>`, as `#+OPTIONS` says.
690 static let textObjects = try! NSRegularExpression(pattern: [
691 #"\\\((.+?)\\\)"#, #"\\\[(.+?)\\\]"#, #"\$\$(.+?)\$\$"#,
692 #"(?<![$\w])\$([^\s$.,;](?:[^$]*?[^\s$.,\\])?)\$(?=[\s.,;:!?'")\]-]|$)"#,
693 #"\\([a-zA-Z]+)(\{\})?"#,
694 #"(?<=\S)([_^])(\{[^{}]*\}|[+-]?[\p{L}\p{N}.,\\]*[\p{L}\p{N}])"#,
695 ].joined(separator: "|"))
696
697 private func entities(_ text: String) -> String {
698 guard text.contains(where: { $0 == "\\" || $0 == "$" || $0 == "_" || $0 == "^" }) else { return Self.escape(text) }
699 let ns = text as NSString
700 var out = ""
701 var at = 0
702 for m in Self.textObjects.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
703 out += Self.escape(ns.substring(with: NSRange(location: at, length: m.range.location - at)))
704 at = NSMaxRange(m.range)
705 let whole = ns.substring(with: m.range)
706 func group(_ i: Int) -> String? { m.range(at: i).location == NSNotFound ? nil : ns.substring(with: m.range(at: i)) }
707 if group(1) != nil || group(2) != nil || group(3) != nil || group(4) != nil {
708 guard options.tex else { continue }
709 hasMath = true
710 out += Self.escape(whole)
711 } else if let name = group(5) {
712 out += Self.escape(OrgEntities.display[name] ?? whole)
713 } else if let marker = group(7), let body = group(8) {
714 let braced = body.hasPrefix("{")
715 if options.scripts == .off || (options.scripts == .braces && !braced) {
716 out += Self.escape(whole)
717 continue
718 }
719 let inner = braced ? String(body.dropFirst().dropLast()) : body
720 out += marker == "_" ? "<sub>\(Self.escape(inner))</sub>" : "<sup>\(Self.escape(inner))</sup>"
721 } else {
722 out += Self.escape(whole)
723 }
724 }
725 return out + Self.escape(ns.substring(from: at))
726 }
727
728 private func object(_ node: SyntaxNode) -> String {
729 switch node.kind {
730 case .bold: return "<strong>\(inline(node))</strong>"
731 case .italic: return "<em>\(inline(node))</em>"
732 case .underline: return "<u>\(inline(node))</u>"
733 case .strikeThrough: return "<del>\(inline(node))</del>"
734 case .verbatim: return "<code class=\"verbatim\">\(inline(node))</code>"
735 case .code: return "<code>\(inline(node))</code>"
736 case .link: return link(node)
737 case .linkDescription: return inline(node)
738 case .timestamp: return timestamp(node.text)
739 case .footnoteReference: return footnote(node.text)
740 case .entity:
741 let name = node.text.dropFirst().replacingOccurrences(of: "{}", with: "")
742 return Self.escape(OrgEntities.display[name] ?? node.text)
743 case .radioTarget:
744 let inner = String(node.text.dropFirst(3).dropLast(3))
745 return "<a id=\"\(Self.escape(slugOnly(inner)))\">\(Self.escape(inner))</a>"
746 case .superscript, .subscript:
747 let text = node.text
748 let body = text.dropFirst()
749 let braced = body.hasPrefix("{") && body.hasSuffix("}")
750 if options.scripts == .off || (options.scripts == .braces && !braced) { return Self.escape(text) }
751 let inner = braced ? String(body.dropFirst().dropLast()) : String(body)
752 return text.hasPrefix("_") ? "<sub>\(Self.escape(inner))</sub>" : "<sup>\(Self.escape(inner))</sup>"
753 case .lineBreak: return "<br>\n"
754 case .macro:
755 guard case .some(let value) = source?.resultsMacro(node) else { return "" }
756 return value.map(inlineString) ?? ""
757 case .target: return ""
758 case .inlineSourceBlock:
759 let exports = source?.exports(at: node.range.lowerBound) ?? "results"
760 source?.inlineResults = exports == "results" || exports == "both"
761 guard exports == "code" || exports == "both" else { return "" }
762 if let m = node.text.firstMatch(of: /\{(.*)\}$/) { return "<code>\(Self.escape(String(m.1)))</code>" }
763 return Self.escape(node.text)
764 case .latexFragment:
765 // For MathJax, as ox-html leaves them with `tex:t`; `tex:nil` drops them.
766 guard options.tex else { return "" }
767 hasMath = true
768 return Self.escape(node.text)
769 case .statisticsCookie: return Self.escape(node.text)
770 // `org-html-export-snippet`: only HTML snippets.
771 case .exportSnippet:
772 guard node.tokens.first?.text == "@@html:" else { return "" }
773 return node.tokens.filter { $0.kind != .marker }.map(\.text).joined()
774 case .citation:
775 if inTitle { return "" }
776 guard let pieces = citations?.next(node.text) else { return Self.escape(node.text) }
777 return render(pieces)
778 case .inlineBabelCall:
779 source?.inlineResults = (source?.exports(at: node.range.lowerBound) ?? "results") == "results"
780 return ""
781 default: return inline(node)
782 }
783 }
784
785 static let imageExtensions: Set<String> = ["png", "jpg", "jpeg", "gif", "svg", "webp", "bmp", "tif", "tiff", "avif"]
786
787 private func link(_ node: SyntaxNode) -> String {
788 let path = node.tokens.first { $0.kind == .linkPath }?.text
789 let description = node.firstChild(.linkDescription)
790 guard let path else {
791 // A radio link goes to its target.
792 if !node.text.hasPrefix("<"), !node.text.contains(":") {
793 return "<a href=\"#\(Self.escape(slugOnly(node.text)))\">\(Self.escape(node.text))</a>"
794 }
795 // A plain or angle link.
796 var target = node.text
797 if target.hasPrefix("<"), target.hasSuffix(">") { target = String(target.dropFirst().dropLast()) }
798 return "<a href=\"\(Self.escape(target))\">\(Self.escape(target))</a>"
799 }
800 var href = path
801 var label = path
802 if path.hasPrefix("file:") {
803 href = String(path.dropFirst(5))
804 label = href
805 } else if path.hasPrefix("id:") {
806 href = "#" + path.dropFirst(3)
807 label = String(path.dropFirst(3))
808 } else if path.hasPrefix("*") {
809 href = "#" + slugOnly(String(path.dropFirst()))
810 } else if !path.hasPrefix("#"), !path.contains(":"), !path.contains("/"), !path.contains(".") {
811 href = "#" + slugOnly(path)
812 }
813 let isFile = path.hasPrefix("file:") || (!path.contains(":") && (path.contains("/") || path.contains(".")))
814 if isFile, href.lowercased().hasSuffix(".org") { href = String(href.dropLast(4)) + ".html" }
815 if isFile, let range = href.range(of: ".org::") { href = String(href[..<range.lowerBound]) + ".html" }
816 let ext = (href.split(separator: "?").first.map(String.init) ?? href).split(separator: ".").last.map { $0.lowercased() } ?? ""
817 if description == nil, Self.imageExtensions.contains(ext) {
818 var attributes = ""
819 if let attrs = affiliated["ATTR_HTML"] {
820 for (key, value) in parseAttributes(attrs) where key != "src" {
821 attributes += " \(key)=\"\(Self.escape(value))\""
822 }
823 }
824 if !attributes.contains(" alt=") { attributes = " alt=\"\(Self.escape((href as NSString).lastPathComponent))\"" + attributes }
825 return "<img src=\"\(Self.escape(href))\"\(attributes)>"
826 }
827 let text = description.map(inline) ?? Self.escape(label)
828 return "<a href=\"\(Self.escape(href))\">\(text)</a>"
829 }
830
831 private func slugOnly(_ text: String) -> String {
832 var slug = ""
833 var dash = false
834 for c in text.lowercased() {
835 if c.isLetter || c.isNumber {
836 if dash, !slug.isEmpty { slug.append("-") }
837 slug.append(c)
838 dash = false
839 } else {
840 dash = true
841 }
842 }
843 return slug
844 }
845
846 /// `:key value :other "quoted value"`.
847 private func parseAttributes(_ text: String) -> [(String, String)] {
848 var pairs: [(String, String)] = []
849 let regex = try! NSRegularExpression(pattern: ":([A-Za-z_-]+)[ \\t]+(\"[^\"]*\"|[^:\\s][^\\s]*)")
850 let ns = text as NSString
851 for m in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
852 var value = ns.substring(with: m.range(at: 2))
853 if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 { value = String(value.dropFirst().dropLast()) }
854 pairs.append((ns.substring(with: m.range(at: 1)), value))
855 }
856 return pairs
857 }
858
859 private func timestamp(_ text: String) -> String {
860 guard let stamp = Timestamp.parse(text) else { return Self.escape(text) }
861 func date(_ p: Timestamp.Point) -> String { String(format: "%04d-%02d-%02d", p.year, p.month, p.day) }
862 func time(_ p: Timestamp.Point) -> String? {
863 guard let h = p.hour, let m = p.minute else { return nil }
864 return String(format: "%02d:%02d", h, m)
865 }
866 func element(_ p: Timestamp.Point, full: Bool) -> String {
867 let datetime = date(p) + (time(p).map { "T" + $0 } ?? "")
868 let shown = full ? date(p) + (time(p).map { " " + $0 } ?? "") : (time(p) ?? date(p))
869 return "<time class=\"timestamp\" datetime=\"\(datetime)\">\(shown)</time>"
870 }
871 guard let end = stamp.end else { return element(stamp.start, full: true) }
872 let sameDay = end.year == stamp.start.year && end.month == stamp.start.month && end.day == stamp.start.day
873 return element(stamp.start, full: true) + "–" + element(end, full: !sameDay)
874 }
875
876 private func footnote(_ text: String) -> String {
877 guard let m = text.firstMatch(of: /^\[fn:([^:\]]*)(?::(.*))?\]$/) else { return Self.escape(text) }
878 var label = String(m.1)
879 let inlineText = m.2.map(String.init)
880 if label.isEmpty {
881 anonymous += 1
882 label = "anonymous-\(anonymous)"
883 }
884 let number: Int
885 if let existing = footnoteNumbers[label] {
886 number = existing
887 } else {
888 let content: FootnoteContent
889 if let inlineText {
890 content = .inline(inlineText)
891 } else if let definition = definitions[label] {
892 content = .definition(definition)
893 } else {
894 return Self.escape(text)
895 }
896 footnoteOrder.append((label, content))
897 number = footnoteOrder.count
898 footnoteNumbers[label] = number
899 }
900 return "<sup class=\"footnote-ref\"><a id=\"fnr-\(number)\" href=\"#fn-\(number)\">\(number)</a></sup>"
901 }
902}