Sources/OrgCore/Export/HTMLExport.swift
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 += "&"
98 case "<": out += "<"
99 case ">": out += ">"
100 case "\"": out += """
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)\">↩</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>[ ]</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: " ", 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}