krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgCore/Export/HTMLExport.swift

abca8dc581329472f7fb8ccd3af147e5fb882f29
orgstar/Sources/OrgCore/Export/HTMLExport.swift history · blame · raw

648 lines · 28657 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)
 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        for node in tree.root.children where node.kind == .zerothSection {
 23            for child in node.children where child.kind == .keyword {
 24                let line = child.text.trimmingCharacters(in: .whitespacesAndNewlines)
 25                guard let colon = line.firstIndex(of: ":") else { continue }
 26                let key = line.dropFirst(2).prefix { $0 != ":" }.uppercased()
 27                let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
 28                switch key {
 29                case "TITLE": meta.title = value
 30                case "AUTHOR": meta.author = value
 31                case "DATE": meta.date = value
 32                default: break
 33                }
 34            }
 35        }
 36        return meta
 37    }
 38
 39    /// A standalone page: the body under the document's title, with a small stylesheet.
 40    public static func page(_ text: String, fallbackTitle: String, settings: OrgSettings = .default) -> String {
 41        let meta = metadata(text, settings: settings)
 42        let title = meta.title ?? fallbackTitle
 43        let renderer = HTMLRenderer(settings: settings)
 44        let titleHTML = renderer.inlineString(title)
 45        var header = "<h1 class=\"title\">\(titleHTML)</h1>\n"
 46        let byline = [meta.author, meta.date].compactMap { $0 }.filter { !$0.isEmpty }
 47        if !byline.isEmpty { header += "<p class=\"byline\">\(byline.map(HTMLRenderer.escape).joined(separator: " · "))</p>\n" }
 48        return """
 49        <!DOCTYPE html>
 50        <html lang="en">
 51        <head>
 52        <meta charset="utf-8">
 53        <meta name="viewport" content="width=device-width, initial-scale=1">
 54        <title>\(HTMLRenderer.escape(title))</title>
 55        <style>
 56        body { font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; max-width: 46em; margin: 2em auto; padding: 0 1em; color: #222; }
 57        @media (prefers-color-scheme: dark) { body { background: #1d1d1f; color: #ddd; } a { color: #6cb6ff; } }
 58        pre { background: rgba(127, 127, 127, 0.12); padding: 0.75em; overflow-x: auto; }
 59        code { font: 0.9em ui-monospace, Menlo, monospace; }
 60        table { border-collapse: collapse; } th, td { border: 1px solid rgba(127, 127, 127, 0.4); padding: 0.25em 0.6em; }
 61        blockquote { margin-left: 0; padding-left: 1em; border-left: 3px solid rgba(127, 127, 127, 0.4); }
 62        .tag { font-size: 0.75em; padding: 0 0.3em; border-radius: 3px; background: rgba(127, 127, 127, 0.18); }
 63        .todo { color: #c33; } .done { color: #393; } .byline { color: #888; } .center { text-align: center; }
 64        figure { margin: 1em 0; } img { max-width: 100%; }
 65        </style>
 66        </head>
 67        <body>
 68        \(header)\(renderer.document(text))</body>
 69        </html>
 70
 71        """
 72    }
 73}
 74
 75final class HTMLRenderer {
 76    let settings: OrgSettings
 77    private var footnoteNumbers: [String: Int] = [:]
 78    private var footnoteOrder: [(label: String, content: FootnoteContent)] = []
 79    private var definitions: [String: String] = [:]
 80    private var anonymous = 0
 81    private var figures = 0
 82    private var ids: Set<String> = []
 83
 84    enum FootnoteContent {
 85        case definition(String)
 86        case inline(String)
 87    }
 88
 89    init(settings: OrgSettings) {
 90        self.settings = settings
 91    }
 92
 93    static func escape(_ s: String) -> String {
 94        var out = ""
 95        for c in s {
 96            switch c {
 97            case "&": out += "&amp;"
 98            case "<": out += "&lt;"
 99            case ">": out += "&gt;"
100            case "\"": out += "&quot;"
101            default: out.append(c)
102            }
103        }
104        return out
105    }
106
107    // MARK: - Document
108
109    func document(_ text: String) -> String {
110        let tree = OrgParser.parse(text, defaults: settings)
111        collectDefinitions(tree.root)
112        var out = ""
113        for child in tree.root.children { out += element(child) }
114        out += footnoteSection()
115        return out
116    }
117
118    private func collectDefinitions(_ node: SyntaxNode) {
119        for child in node.children {
120            if child.kind == .footnoteDefinition {
121                let text = child.text
122                if let m = text.firstMatch(of: /^\[fn:([^\]]+)\][ \t]*/) {
123                    definitions[String(m.1)] = String(text[m.range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
124                }
125            } else {
126                collectDefinitions(child)
127            }
128        }
129    }
130
131    private func footnoteSection() -> String {
132        guard !footnoteOrder.isEmpty else { return "" }
133        var out = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n"
134        for (index, note) in footnoteOrder.enumerated() {
135            let n = index + 1
136            let content: String
137            switch note.content {
138            case .definition(let text): content = blocks(text).trimmingCharacters(in: .newlines)
139            case .inline(let text): content = inlineString(text)
140            }
141            out += "<li id=\"fn-\(n)\">\(content)\n <a class=\"footnote-back\" href=\"#fnr-\(n)\" aria-label=\"Back to reference \(n)\">&#8617;</a></li>\n"
142        }
143        return out + "</ol>\n</section>\n"
144    }
145
146    /// Org text rendered as elements, for block and footnote contents.
147    func blocks(_ text: String) -> String {
148        let tree = OrgParser.parse(text, defaults: settings)
149        var out = ""
150        for child in tree.root.children { out += element(child) }
151        return out
152    }
153
154    /// One line of org text rendered inline.
155    func inlineString(_ text: String) -> String {
156        let tree = OrgParser.parse(text, defaults: settings)
157        guard let paragraph = tree.root.descendants().first(where: { $0.kind == .paragraph }) else { return Self.escape(text) }
158        return inline(paragraph).trimmingCharacters(in: .whitespacesAndNewlines)
159    }
160
161    // MARK: - Elements
162
163    private var affiliated: [String: String] = [:]
164    /// `:exports` of the last src block: its results are exported only for `results` and
165    /// `both`; nothing runs.
166    private var lastExports = "code"
167
168    func element(_ node: SyntaxNode) -> String {
169        defer { if node.kind != .affiliatedKeyword { affiliated = [:] } }
170        if node.kind != .affiliatedKeyword, affiliated["RESULTS"] != nil {
171            defer { lastExports = "code" }
172            if lastExports != "results", lastExports != "both" { return "" }
173        }
174        if node.kind == .block, let exports = Self.srcExports(node) {
175            lastExports = exports
176            if exports == "results" || exports == "none" { return "" }
177        }
178        switch node.kind {
179        case .zerothSection, .section, .document:
180            var out = ""
181            for child in node.children { out += element(child) }
182            return out
183        case .heading:
184            return heading(node)
185        case .affiliatedKeyword:
186            let line = node.text.trimmingCharacters(in: .whitespacesAndNewlines)
187            if let m = line.firstMatch(of: /^#\+([A-Za-z_]+):[ \t]*(.*)$/) {
188                affiliated[m.1.uppercased()] = String(m.2)
189            }
190            return ""
191        case .paragraph:
192            return paragraph(node)
193        case .plainList:
194            return list(node)
195        case .table:
196            return table(node)
197        case .block:
198            return block(node)
199        case .horizontalRule:
200            return "<hr>\n"
201        case .fixedWidth:
202            let lines = node.text.split(separator: "\n", omittingEmptySubsequences: false).map { line -> String in
203                let trimmed = line.drop { $0 == " " || $0 == "\t" }
204                return trimmed.hasPrefix(": ") ? String(trimmed.dropFirst(2)) : String(trimmed.dropFirst(trimmed.hasPrefix(":") ? 1 : 0))
205            }
206            return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n"
207        case .dynamicBlock:
208            let lines = node.text.components(separatedBy: "\n")
209            return blocks(lines.dropFirst().dropLast(2).joined(separator: "\n"))
210        default:
211            // Keywords, comments, drawers, planning, clocks, footnote definitions, formulas.
212            return ""
213        }
214    }
215
216    /// The `:exports` value of a src block, or nil for other blocks.
217    static func srcExports(_ node: SyntaxNode) -> String? {
218        guard DocumentModel.isSrcBlock(node) else { return nil }
219        let first = node.text.components(separatedBy: "\n").first ?? ""
220        if let m = first.firstMatch(of: /:exports[ \t]+(\S+)/) { return m.1.lowercased() }
221        return "code"
222    }
223
224    private func slug(_ text: String) -> String {
225        var slug = ""
226        var dash = false
227        for c in text.lowercased() {
228            if c.isLetter || c.isNumber {
229                if dash, !slug.isEmpty { slug.append("-") }
230                slug.append(c)
231                dash = false
232            } else {
233                dash = true
234            }
235        }
236        if slug.isEmpty { slug = "section" }
237        var candidate = slug
238        var n = 1
239        while ids.contains(candidate) {
240            candidate = "\(slug)-\(n)"
241            n += 1
242        }
243        ids.insert(candidate)
244        return candidate
245    }
246
247    private func heading(_ node: SyntaxNode) -> String {
248        let tokens = node.tokens
249        let level = tokens.first { $0.kind == .stars }?.text.count ?? 1
250        let title = node.firstChild(.title)
251        let titleText = title?.text.trimmingCharacters(in: .whitespaces) ?? ""
252        // `CUSTOM_ID` from the property drawer that follows.
253        var customID: String?
254        if let section = node.parent, let drawer = section.firstChild(.propertyDrawer) {
255            for property in drawer.children where property.kind == .nodeProperty {
256                if let m = property.text.firstMatch(of: /^[ \t]*:CUSTOM_ID:[ \t]*(\S+)/) { customID = String(m.1) }
257            }
258        }
259        let id = customID ?? slug(titleText)
260        if customID != nil { ids.insert(id) }
261        var parts: [String] = []
262        if let todo = tokens.first(where: { $0.kind == .todoKeyword })?.text {
263            parts.append("<span class=\"todo \(settings.isDone(todo) ? "done " : "")\(Self.escape(todo))\">\(Self.escape(todo))</span>")
264        }
265        if let priority = tokens.first(where: { $0.kind == .priority })?.text {
266            parts.append("<span class=\"priority\">\(Self.escape(priority))</span>")
267        }
268        if let title, !titleText.isEmpty { parts.append(inline(title).trimmingCharacters(in: .whitespaces)) }
269        if let tags = tokens.first(where: { $0.kind == .tags })?.text {
270            parts += tags.split(separator: ":").map { "<span class=\"tag\">\(Self.escape(String($0)))</span>" }
271        }
272        let h = min(level + 1, 6)
273        return "<h\(h) id=\"\(Self.escape(id))\">\(parts.joined(separator: " "))</h\(h)>\n"
274    }
275
276    private func paragraph(_ node: SyntaxNode) -> String {
277        let body = inline(node).trimmingCharacters(in: .whitespacesAndNewlines)
278        if let image = soleImage(node), affiliated["CAPTION"] != nil || affiliated["ATTR_HTML"] != nil {
279            var out = "<figure>\n" + image + "\n"
280            if let caption = affiliated["CAPTION"] {
281                figures += 1
282                out += "<figcaption>Figure \(figures): \(inlineString(caption))</figcaption>\n"
283            }
284            return out + "</figure>\n"
285        }
286        return "<p>\(body)</p>\n"
287    }
288
289    /// The `<img>` of a paragraph that is only an image link.
290    private func soleImage(_ node: SyntaxNode) -> String? {
291        let links = node.children.filter { $0.kind == .link }
292        guard links.count == 1, node.children.count == 1,
293              node.tokens.allSatisfy({ $0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else { return nil }
294        let html = link(links[0])
295        return html.hasPrefix("<img") ? html : nil
296    }
297
298    // MARK: - Lists
299
300    private func isOrdered(_ item: SyntaxNode) -> Bool {
301        let bullet = item.tokens.first { $0.kind == .bullet }?.text ?? "-"
302        return bullet.first.map { $0.isNumber || $0.isLetter } ?? false
303    }
304
305    /// As orgo does, a change between ordered and unordered bullets starts another list.
306    private func list(_ node: SyntaxNode) -> String {
307        var runs: [[SyntaxNode]] = []
308        for item in node.children where item.kind == .item {
309            if let last = runs.last?.last, isOrdered(last) == isOrdered(item) {
310                runs[runs.count - 1].append(item)
311            } else {
312                runs.append([item])
313            }
314        }
315        return runs.map(list).joined()
316    }
317
318    private func list(_ items: [SyntaxNode]) -> String {
319        guard let first = items.first else { return "" }
320        let ordered = isOrdered(first)
321        let description = !ordered && firstLine(first).contains(" :: ")
322        if description {
323            var out = "<dl>\n"
324            for item in items {
325                let (term, rest) = descriptionParts(item)
326                out += "<dt>\(term)</dt>\n<dd>\(rest)</dd>\n"
327            }
328            return out + "</dl>\n"
329        }
330        let tag = ordered ? "ol" : "ul"
331        var out = "<\(tag)>\n"
332        for item in items { out += listItem(item) }
333        return out + "</\(tag)>\n"
334    }
335
336    private func firstLine(_ item: SyntaxNode) -> String {
337        item.firstChild(.paragraph)?.text.components(separatedBy: "\n").first ?? ""
338    }
339
340    private func descriptionParts(_ item: SyntaxNode) -> (String, String) {
341        guard let paragraph = item.firstChild(.paragraph) else { return ("", "") }
342        let text = paragraph.text
343        guard let separator = text.range(of: " :: ") ?? text.range(of: " ::\n") else { return ("", inline(paragraph)) }
344        let term = inlineString(String(text[..<separator.lowerBound]))
345        var rest = inlineParagraphText(String(text[separator.upperBound...]))
346        for child in item.children where child.kind != .paragraph || child.range != paragraph.range {
347            rest += element(child)
348        }
349        return (term, rest.trimmingCharacters(in: .whitespacesAndNewlines))
350    }
351
352    /// Paragraph text, possibly spanning lines, rendered inline.
353    private func inlineParagraphText(_ text: String) -> String {
354        let tree = OrgParser.parse(text, defaults: settings)
355        let paragraphs = tree.root.descendants().filter { $0.kind == .paragraph }
356        guard !paragraphs.isEmpty else { return Self.escape(text.trimmingCharacters(in: .whitespacesAndNewlines)) }
357        return paragraphs.map { inline($0).trimmingCharacters(in: .whitespacesAndNewlines) }.joined(separator: "\n")
358    }
359
360    private func listItem(_ item: SyntaxNode) -> String {
361        var open = "<li>"
362        var prefix = ""
363        if let box = item.tokens.first(where: { $0.kind == .checkbox })?.text {
364            switch box {
365            case "[X]", "[x]":
366                open = "<li class=\"on\">"
367                prefix = "<code>[X]</code> "
368            case "[-]":
369                open = "<li class=\"trans\">"
370                prefix = "<code>[-]</code> "
371            default:
372                open = "<li class=\"off\">"
373                prefix = "<code>[&nbsp;]</code> "
374            }
375        }
376        let paragraphs = item.children.filter { $0.kind == .paragraph }.count
377        var body = ""
378        for child in item.children {
379            if child.kind == .paragraph {
380                let content = inline(child).trimmingCharacters(in: .whitespacesAndNewlines)
381                body += paragraphs >= 2 ? "<p>\(content)</p>\n" : content
382            } else {
383                body += element(child)
384            }
385        }
386        if paragraphs >= 2 {
387            body = prefix + body
388        } else {
389            body = prefix + body
390        }
391        return open + body + (body.hasSuffix("\n") ? "" : "") + "</li>\n"
392    }
393
394    // MARK: - Tables
395
396    private func table(_ node: SyntaxNode) -> String {
397        let rows = node.children.filter { $0.kind == .tableRow }
398        var groups: [[SyntaxNode]] = [[]]
399        for row in rows {
400            if row.children.isEmpty, row.text.trimmingCharacters(in: .whitespaces).hasPrefix("|-") {
401                if !groups[groups.count - 1].isEmpty { groups.append([]) }
402            } else {
403                groups[groups.count - 1].append(row)
404            }
405        }
406        groups.removeAll { $0.isEmpty }
407        var out = "<table>\n"
408        if let caption = affiliated["CAPTION"] { out += "<caption>\(inlineString(caption))</caption>\n" }
409        let hasHeader = groups.count > 1
410        for (index, group) in groups.enumerated() {
411            let header = hasHeader && index == 0
412            if header { out += "<thead>\n" } else if index == (hasHeader ? 1 : 0) { out += "<tbody>\n" }
413            for row in group {
414                out += "<tr>" + row.children.filter { $0.kind == .tableCell }.map { cell in
415                    let content = inline(cell).trimmingCharacters(in: .whitespaces)
416                    return header ? "<th>\(content)</th>" : "<td>\(content)</td>"
417                }.joined() + "</tr>\n"
418            }
419            if header { out += "</thead>\n" }
420        }
421        if !groups.isEmpty, !(hasHeader && groups.count == 1) { out += "</tbody>\n" }
422        return out + "</table>\n"
423    }
424
425    // MARK: - Blocks
426
427    private func block(_ node: SyntaxNode) -> String {
428        var lines = node.text.components(separatedBy: "\n")
429        if lines.last == "" { lines.removeLast() }
430        guard let first = lines.first,
431              let m = first.firstMatch(of: /^[ \t]*#\+(?i:begin)_(\S+)[ \t]*(.*)$/) else { return "" }
432        let type = m.1.lowercased()
433        let parameters = String(m.2)
434        let body = Array(lines.dropFirst().dropLast())
435        // Commas protect `*` and `#+` lines inside blocks.
436        let unescaped = body.map { $0.replacingOccurrences(of: "^([ \\t]*),([,*]|#\\+)", with: "$1$2", options: .regularExpression) }
437        let text = unescaped.joined(separator: "\n")
438        switch type {
439        case "src":
440            let language = parameters.split(separator: " ").first.map(String.init)
441            let code = Self.escape(dedent(unescaped).joined(separator: "\n"))
442            let cls = language.map { " class=\"language-\(Self.escape($0))\"" } ?? ""
443            return "<pre><code\(cls)>\(code)</code></pre>\n"
444        case "example":
445            return "<pre>\(Self.escape(dedent(unescaped).joined(separator: "\n")))</pre>\n"
446        case "quote":
447            return "<blockquote>\n\(blocks(text))</blockquote>\n"
448        case "center":
449            return "<div class=\"center\">\n\(blocks(text))</div>\n"
450        case "verse":
451            let rendered = unescaped.map { line -> String in
452                let indent = line.prefix { $0 == " " }.count
453                return String(repeating: "&nbsp;", count: indent) + inlineString(String(line.dropFirst(indent)))
454            }
455            return "<p class=\"verse\">\n" + rendered.joined(separator: "<br>\n") + "\n</p>\n"
456        case "export":
457            return parameters.lowercased().hasPrefix("html") ? text + "\n" : ""
458        case "comment":
459            return ""
460        default:
461            return "<div class=\"\(Self.escape(type))\">\n\(blocks(text))</div>\n"
462        }
463    }
464
465    private func dedent(_ lines: [String]) -> [String] {
466        let indents = lines.filter { $0.contains(where: { !$0.isWhitespace }) }.map { $0.prefix { $0 == " " }.count }
467        let common = indents.min() ?? 0
468        return lines.map { String($0.dropFirst(min(common, $0.prefix { $0 == " " }.count))) }
469    }
470
471    // MARK: - Inline
472
473    func inline(_ node: SyntaxNode) -> String {
474        var out = ""
475        for element in node.elements {
476            switch element {
477            case .token(let token):
478                switch token.kind {
479                case .marker, .stars, .bullet, .checkbox, .tags, .todoKeyword, .priority:
480                    continue
481                default:
482                    out += entities(token.text)
483                }
484            case .node(let child):
485                out += object(child)
486            }
487        }
488        return out
489    }
490
491    /// `\alpha` and friends as characters.
492    private func entities(_ text: String) -> String {
493        guard text.contains("\\") else { return Self.escape(text) }
494        var out = ""
495        var rest = Substring(text)
496        while let m = rest.firstMatch(of: /\\([a-zA-Z]+)(\{\})?/) {
497            out += Self.escape(String(rest[..<m.range.lowerBound]))
498            if let display = OrgEntities.display[String(m.1)] {
499                out += Self.escape(display)
500            } else {
501                out += Self.escape(String(rest[m.range]))
502            }
503            rest = rest[m.range.upperBound...]
504        }
505        return out + Self.escape(String(rest))
506    }
507
508    private func object(_ node: SyntaxNode) -> String {
509        switch node.kind {
510        case .bold: return "<strong>\(inline(node))</strong>"
511        case .italic: return "<em>\(inline(node))</em>"
512        case .underline: return "<u>\(inline(node))</u>"
513        case .strikeThrough: return "<del>\(inline(node))</del>"
514        case .verbatim: return "<code class=\"verbatim\">\(inline(node))</code>"
515        case .code: return "<code>\(inline(node))</code>"
516        case .link: return link(node)
517        case .linkDescription: return inline(node)
518        case .timestamp: return timestamp(node.text)
519        case .footnoteReference: return footnote(node.text)
520        case .superscript:
521            let text = node.text
522            let body = text.dropFirst()
523            let inner = body.hasPrefix("{") && body.hasSuffix("}") ? String(body.dropFirst().dropLast()) : String(body)
524            return text.hasPrefix("_") ? "<sub>\(Self.escape(inner))</sub>" : "<sup>\(Self.escape(inner))</sup>"
525        case .lineBreak: return "<br>\n"
526        case .target, .macro: return ""
527        case .inlineSourceBlock:
528            if let m = node.text.firstMatch(of: /\{(.*)\}$/) { return "<code>\(Self.escape(String(m.1)))</code>" }
529            return Self.escape(node.text)
530        case .statisticsCookie, .latexFragment: return Self.escape(node.text)
531        default: return inline(node)
532        }
533    }
534
535    static let imageExtensions: Set<String> = ["png", "jpg", "jpeg", "gif", "svg", "webp", "bmp", "tif", "tiff", "avif"]
536
537    private func link(_ node: SyntaxNode) -> String {
538        let path = node.tokens.first { $0.kind == .linkPath }?.text
539        let description = node.firstChild(.linkDescription)
540        guard let path else {
541            // A plain or angle link.
542            var target = node.text
543            if target.hasPrefix("<"), target.hasSuffix(">") { target = String(target.dropFirst().dropLast()) }
544            return "<a href=\"\(Self.escape(target))\">\(Self.escape(target))</a>"
545        }
546        var href = path
547        var label = path
548        if path.hasPrefix("file:") {
549            href = String(path.dropFirst(5))
550            label = href
551        } else if path.hasPrefix("id:") {
552            href = "#" + path.dropFirst(3)
553            label = String(path.dropFirst(3))
554        } else if path.hasPrefix("*") {
555            href = "#" + slugOnly(String(path.dropFirst()))
556        } else if !path.hasPrefix("#"), !path.contains(":"), !path.contains("/"), !path.contains(".") {
557            href = "#" + slugOnly(path)
558        }
559        let isFile = path.hasPrefix("file:") || (!path.contains(":") && (path.contains("/") || path.contains(".")))
560        if isFile, href.lowercased().hasSuffix(".org") { href = String(href.dropLast(4)) + ".html" }
561        if isFile, let range = href.range(of: ".org::") { href = String(href[..<range.lowerBound]) + ".html" }
562        let ext = (href.split(separator: "?").first.map(String.init) ?? href).split(separator: ".").last.map { $0.lowercased() } ?? ""
563        if description == nil, Self.imageExtensions.contains(ext) {
564            var attributes = ""
565            if let attrs = affiliated["ATTR_HTML"] {
566                for (key, value) in parseAttributes(attrs) where key != "src" {
567                    attributes += " \(key)=\"\(Self.escape(value))\""
568                }
569            }
570            if !attributes.contains(" alt=") { attributes = " alt=\"\(Self.escape((href as NSString).lastPathComponent))\"" + attributes }
571            return "<img src=\"\(Self.escape(href))\"\(attributes)>"
572        }
573        let text = description.map(inline) ?? Self.escape(label)
574        return "<a href=\"\(Self.escape(href))\">\(text)</a>"
575    }
576
577    private func slugOnly(_ text: String) -> String {
578        var slug = ""
579        var dash = false
580        for c in text.lowercased() {
581            if c.isLetter || c.isNumber {
582                if dash, !slug.isEmpty { slug.append("-") }
583                slug.append(c)
584                dash = false
585            } else {
586                dash = true
587            }
588        }
589        return slug
590    }
591
592    /// `:key value :other "quoted value"`.
593    private func parseAttributes(_ text: String) -> [(String, String)] {
594        var pairs: [(String, String)] = []
595        let regex = try! NSRegularExpression(pattern: ":([A-Za-z_-]+)[ \\t]+(\"[^\"]*\"|[^:\\s][^\\s]*)")
596        let ns = text as NSString
597        for m in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
598            var value = ns.substring(with: m.range(at: 2))
599            if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 { value = String(value.dropFirst().dropLast()) }
600            pairs.append((ns.substring(with: m.range(at: 1)), value))
601        }
602        return pairs
603    }
604
605    private func timestamp(_ text: String) -> String {
606        guard let stamp = Timestamp.parse(text) else { return Self.escape(text) }
607        func date(_ p: Timestamp.Point) -> String { String(format: "%04d-%02d-%02d", p.year, p.month, p.day) }
608        func time(_ p: Timestamp.Point) -> String? {
609            guard let h = p.hour, let m = p.minute else { return nil }
610            return String(format: "%02d:%02d", h, m)
611        }
612        func element(_ p: Timestamp.Point, full: Bool) -> String {
613            let datetime = date(p) + (time(p).map { "T" + $0 } ?? "")
614            let shown = full ? date(p) + (time(p).map { " " + $0 } ?? "") : (time(p) ?? date(p))
615            return "<time class=\"timestamp\" datetime=\"\(datetime)\">\(shown)</time>"
616        }
617        guard let end = stamp.end else { return element(stamp.start, full: true) }
618        let sameDay = end.year == stamp.start.year && end.month == stamp.start.month && end.day == stamp.start.day
619        return element(stamp.start, full: true) + "–" + element(end, full: !sameDay)
620    }
621
622    private func footnote(_ text: String) -> String {
623        guard let m = text.firstMatch(of: /^\[fn:([^:\]]*)(?::(.*))?\]$/) else { return Self.escape(text) }
624        var label = String(m.1)
625        let inlineText = m.2.map(String.init)
626        if label.isEmpty {
627            anonymous += 1
628            label = "anonymous-\(anonymous)"
629        }
630        let number: Int
631        if let existing = footnoteNumbers[label] {
632            number = existing
633        } else {
634            let content: FootnoteContent
635            if let inlineText {
636                content = .inline(inlineText)
637            } else if let definition = definitions[label] {
638                content = .definition(definition)
639            } else {
640                return Self.escape(text)
641            }
642            footnoteOrder.append((label, content))
643            number = footnoteOrder.count
644            footnoteNumbers[label] = number
645        }
646        return "<sup class=\"footnote-ref\"><a id=\"fnr-\(number)\" href=\"#fn-\(number)\">\(number)</a></sup>"
647    }
648}