Commit e2f8315a9c
Email mismatch · cmc
.gitignore added +4
| @@ -0,0 +1,4 @@ | ||
| 1 | .build/ | |
| 2 | .swiftpm/ | |
| 3 | *.xcodeproj | |
| 4 | .DS_Store | |
Package.swift added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | // swift-tools-version: 5.9 | |
| 2 | import PackageDescription | |
| 3 | ||
| 4 | let package = Package( | |
| 5 | name: "OrgSwift", | |
| 6 | platforms: [ | |
| 7 | .macOS(.v13), | |
| 8 | .iOS(.v16) | |
| 9 | ], | |
| 10 | products: [ | |
| 11 | .library(name: "OrgSwift", targets: ["OrgSwift"]) | |
| 12 | ], | |
| 13 | targets: [ | |
| 14 | .target( | |
| 15 | name: "OrgSwift", | |
| 16 | swiftSettings: [.enableUpcomingFeature("BareSlashRegexLiterals")] | |
| 17 | ), | |
| 18 | .testTarget(name: "OrgSwiftTests", dependencies: ["OrgSwift"]) | |
| 19 | ] | |
| 20 | ) | |
README.md added +71
| @@ -0,0 +1,71 @@ | ||
| 1 | # OrgSwift | |
| 2 | ||
| 3 | A dependency-free Swift library that renders a practical subset of | |
| 4 | [org-mode](https://orgmode.org) to sanitized HTML. Pure Foundation — no | |
| 5 | SwiftUI, WebKit, UIKit, or third-party packages. | |
| 6 | ||
| 7 | Extracted from the hand-rolled renderer in the Hutch iOS client so it can be | |
| 8 | shared across apps. | |
| 9 | ||
| 10 | ## Supported syntax | |
| 11 | ||
| 12 | Headings, paragraphs, ordered/unordered lists (with nesting, wrapped lines, and | |
| 13 | `[ ]`/`[x]` task checkboxes), tables (with `:---:` alignment), `#+begin_src` / | |
| 14 | `example` / `quote` / `center` / `verse` blocks, property drawers, | |
| 15 | `#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+NAME` figures, org links | |
| 16 | and linked images (`[[dest][label]]`), horizontal rules, comments, and inline | |
| 17 | markup (`*bold*`, `/italic/`, `~code~`, `=verbatim=`, `+strike+`, `_underline_`, | |
| 18 | email autolinks). | |
| 19 | ||
| 20 | Output is sanitized: only `http`/`https`/`mailto` link schemes and | |
| 21 | `http`/`https` image schemes are allowed; everything else is dropped. | |
| 22 | ||
| 23 | ## Usage | |
| 24 | ||
| 25 | ```swift | |
| 26 | import OrgSwift | |
| 27 | ||
| 28 | let html = OrgRenderer.renderToHTML(orgSource) | |
| 29 | ``` | |
| 30 | ||
| 31 | ### Relative links | |
| 32 | ||
| 33 | Pass an `OrgRenderOptions` to rewrite repository-relative links and images to | |
| 34 | absolute `blob` URLs. When `owner`/`repositoryName` are nil (the default), | |
| 35 | relative links are left as-is, which the scheme allowlist then drops. | |
| 36 | ||
| 37 | ```swift | |
| 38 | let html = OrgRenderer.renderToHTML( | |
| 39 | orgSource, | |
| 40 | options: OrgRenderOptions( | |
| 41 | host: "git.sr.ht", // default | |
| 42 | owner: "~ccleberg", | |
| 43 | repositoryName: "Hutch", | |
| 44 | ref: "HEAD", // default | |
| 45 | readmePath: "README.org" // resolves paths relative to this file | |
| 46 | ) | |
| 47 | ) | |
| 48 | ``` | |
| 49 | ||
| 50 | `./images/badge.svg` then resolves to | |
| 51 | `https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg`. | |
| 52 | ||
| 53 | ### Syntax highlighting | |
| 54 | ||
| 55 | Code blocks are highlighted through a protocol so the library carries no | |
| 56 | highlighter dependency: | |
| 57 | ||
| 58 | ```swift | |
| 59 | public protocol CodeHighlighter { | |
| 60 | func highlightedHTML(code: String, language: String?) -> String? | |
| 61 | } | |
| 62 | ``` | |
| 63 | ||
| 64 | The default `PlainCodeHighlighter` returns `nil`, so blocks fall back to escaped | |
| 65 | `<pre><code>`. Provide your own conformer to plug in a real highlighter: | |
| 66 | ||
| 67 | ```swift | |
| 68 | let html = OrgRenderer.renderToHTML(orgSource, highlighter: MyHighlighter()) | |
| 69 | ``` | |
| 70 | ||
| 71 | A conformer returning `nil` for a given block gets the same escaped fallback. | |
Sources/OrgSwift/Escaping.swift added +122
| @@ -0,0 +1,122 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // MARK: - HTML Escaping | |
| 4 | ||
| 5 | func escapeHTML(_ text: String) -> String { | |
| 6 | text.replacingOccurrences(of: "&", with: "&") | |
| 7 | .replacingOccurrences(of: "<", with: "<") | |
| 8 | .replacingOccurrences(of: ">", with: ">") | |
| 9 | .replacingOccurrences(of: "\"", with: """) | |
| 10 | } | |
| 11 | ||
| 12 | func escapeHTMLAttribute(_ text: String) -> String { | |
| 13 | escapeHTML(text).replacingOccurrences(of: "'", with: "'") | |
| 14 | } | |
| 15 | ||
| 16 | // MARK: - URL Sanitization | |
| 17 | ||
| 18 | func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? { | |
| 19 | sanitizeReadmeURLString( | |
| 20 | rawURL, | |
| 21 | allowedSchemes: ["http", "https", "mailto"], | |
| 22 | allowsFragmentOnly: true | |
| 23 | ) | |
| 24 | } | |
| 25 | ||
| 26 | func sanitizedReadmeImageURLString(_ rawURL: String) -> String? { | |
| 27 | sanitizeReadmeURLString( | |
| 28 | rawURL, | |
| 29 | allowedSchemes: ["http", "https"], | |
| 30 | allowsFragmentOnly: false | |
| 31 | ) | |
| 32 | } | |
| 33 | ||
| 34 | private func sanitizeReadmeURLString( | |
| 35 | _ rawURL: String, | |
| 36 | allowedSchemes: Set<String>, | |
| 37 | allowsFragmentOnly: Bool | |
| 38 | ) -> String? { | |
| 39 | let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 40 | guard !trimmedURL.isEmpty else { return nil } | |
| 41 | ||
| 42 | if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 { | |
| 43 | return escapeHTMLAttribute(trimmedURL) | |
| 44 | } | |
| 45 | ||
| 46 | guard let components = URLComponents(string: trimmedURL), | |
| 47 | let scheme = components.scheme?.lowercased(), | |
| 48 | allowedSchemes.contains(scheme), | |
| 49 | let sanitizedURL = components.url?.absoluteString else { | |
| 50 | return nil | |
| 51 | } | |
| 52 | ||
| 53 | return escapeHTMLAttribute(sanitizedURL) | |
| 54 | } | |
| 55 | ||
| 56 | // MARK: - Regex Helpers | |
| 57 | ||
| 58 | func orgKeywordDirective(in line: String) -> (keyword: String, value: String)? { | |
| 59 | guard let match = line.firstMatch(of: /^#\+([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/) else { | |
| 60 | return nil | |
| 61 | } | |
| 62 | return ( | |
| 63 | keyword: String(match.1).lowercased(), | |
| 64 | value: String(match.2).trimmingCharacters(in: .whitespaces) | |
| 65 | ) | |
| 66 | } | |
| 67 | ||
| 68 | func isOrgHorizontalRule(_ line: String) -> Bool { | |
| 69 | matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#) | |
| 70 | } | |
| 71 | ||
| 72 | func matchesRegex(_ text: String, pattern: String) -> Bool { | |
| 73 | guard let regex = try? NSRegularExpression(pattern: pattern) else { return false } | |
| 74 | let range = NSRange(location: 0, length: (text as NSString).length) | |
| 75 | return regex.firstMatch(in: text, range: range) != nil | |
| 76 | } | |
| 77 | ||
| 78 | func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool { | |
| 79 | guard range.location != NSNotFound else { return false } | |
| 80 | let prefix = text.substring(to: range.location) | |
| 81 | guard let lastOpen = prefix.lastIndex(of: "<") else { return false } | |
| 82 | guard let lastClose = prefix.lastIndex(of: ">") else { return true } | |
| 83 | return lastOpen > lastClose | |
| 84 | } | |
| 85 | ||
| 86 | func protectMatches( | |
| 87 | in text: String, | |
| 88 | pattern: String, | |
| 89 | protectedFragments: inout [String: String], | |
| 90 | transform: (NSTextCheckingResult, NSString) -> String | |
| 91 | ) -> String { | |
| 92 | guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } | |
| 93 | var result = text | |
| 94 | let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length)) | |
| 95 | ||
| 96 | for match in matches.reversed() { | |
| 97 | let token = "ZZPROTECTED\(protectedFragments.count)ZZ" | |
| 98 | let nsText = result as NSString | |
| 99 | protectedFragments[token] = transform(match, nsText) | |
| 100 | result = nsText.replacingCharacters(in: match.range, with: token) | |
| 101 | } | |
| 102 | ||
| 103 | return result | |
| 104 | } | |
| 105 | ||
| 106 | func replaceMatches( | |
| 107 | in text: String, | |
| 108 | pattern: String, | |
| 109 | transform: (NSTextCheckingResult, NSString) -> String | |
| 110 | ) -> String { | |
| 111 | guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } | |
| 112 | var result = text | |
| 113 | let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length)) | |
| 114 | ||
| 115 | for match in matches.reversed() { | |
| 116 | let nsText = result as NSString | |
| 117 | let replacement = transform(match, nsText) | |
| 118 | result = nsText.replacingCharacters(in: match.range, with: replacement) | |
| 119 | } | |
| 120 | ||
| 121 | return result | |
| 122 | } | |
Sources/OrgSwift/Inline.swift added +99
| @@ -0,0 +1,99 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | func processOrgInline( | |
| 4 | _ text: String, | |
| 5 | imageURLResolver: ((String) -> String?)? = nil, | |
| 6 | linkURLResolver: ((String) -> String?)? = nil | |
| 7 | ) -> String { | |
| 8 | var result = escapeHTML(text) | |
| 9 | var protectedFragments: [String: String] = [:] | |
| 10 | ||
| 11 | result = protectMatches( | |
| 12 | in: result, | |
| 13 | pattern: #"\[\[([^\]]+)\]\[\[([^\]]+)\]\]\]"#, | |
| 14 | protectedFragments: &protectedFragments | |
| 15 | ) { match, nsText in | |
| 16 | let destination = nsText.substring(with: match.range(at: 1)) | |
| 17 | let source = nsText.substring(with: match.range(at: 2)) | |
| 18 | guard let imageHTML = makeOrgImageHTML( | |
| 19 | source: source, | |
| 20 | alt: nil, | |
| 21 | imageURLResolver: imageURLResolver | |
| 22 | ) else { | |
| 23 | return source | |
| 24 | } | |
| 25 | let resolvedDestination = linkURLResolver?(destination) ?? destination | |
| 26 | guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else { | |
| 27 | return imageHTML | |
| 28 | } | |
| 29 | return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"# | |
| 30 | } | |
| 31 | ||
| 32 | result = protectOrgLinks( | |
| 33 | in: result, | |
| 34 | protectedFragments: &protectedFragments, | |
| 35 | imageURLResolver: imageURLResolver, | |
| 36 | linkURLResolver: linkURLResolver | |
| 37 | ) | |
| 38 | result = protectMatches( | |
| 39 | in: result, | |
| 40 | pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#, | |
| 41 | protectedFragments: &protectedFragments | |
| 42 | ) { match, nsText in | |
| 43 | let tildeRange = match.range(at: 1) | |
| 44 | let equalsRange = match.range(at: 2) | |
| 45 | let codeText: String | |
| 46 | if tildeRange.location != NSNotFound { | |
| 47 | codeText = nsText.substring(with: tildeRange) | |
| 48 | } else { | |
| 49 | codeText = nsText.substring(with: equalsRange) | |
| 50 | } | |
| 51 | return "<code>\(codeText)</code>" | |
| 52 | } | |
| 53 | result = protectMatches( | |
| 54 | in: result, | |
| 55 | pattern: #"(?<!\S)\+(.+?)\+(?=\s|$|[.,;:!?])"#, | |
| 56 | protectedFragments: &protectedFragments | |
| 57 | ) { match, nsText in | |
| 58 | let value = nsText.substring(with: match.range(at: 1)) | |
| 59 | return "<del>\(value)</del>" | |
| 60 | } | |
| 61 | result = protectMatches( | |
| 62 | in: result, | |
| 63 | pattern: #"(?<!\S)_(.+?)_(?=\s|$|[.,;:!?])"#, | |
| 64 | protectedFragments: &protectedFragments | |
| 65 | ) { match, nsText in | |
| 66 | let value = nsText.substring(with: match.range(at: 1)) | |
| 67 | return "<u>\(value)</u>" | |
| 68 | } | |
| 69 | ||
| 70 | // Bold: *text* | |
| 71 | result = result.replacingOccurrences( | |
| 72 | of: #"(?<!\S)\*(.+?)\*(?=\s|$|[.,;:!?])"#, | |
| 73 | with: "<strong>$1</strong>", | |
| 74 | options: .regularExpression | |
| 75 | ) | |
| 76 | // Italic: /text/ | |
| 77 | result = result.replacingOccurrences( | |
| 78 | of: #"(?<!\S)/(.+?)/(?=\s|$|[.,;:!?])"#, | |
| 79 | with: "<em>$1</em>", | |
| 80 | options: .regularExpression | |
| 81 | ) | |
| 82 | result = replaceMatches( | |
| 83 | in: result, | |
| 84 | pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"# | |
| 85 | ) { match, nsText in | |
| 86 | guard !isInsideHTMLTag(nsText, range: match.range) else { | |
| 87 | return nsText.substring(with: match.range) | |
| 88 | } | |
| 89 | let email = nsText.substring(with: match.range(at: 1)) | |
| 90 | let href = escapeHTMLAttribute("mailto:\(email)") | |
| 91 | return #"<a href="\#(href)">\#(email)</a>"# | |
| 92 | } | |
| 93 | ||
| 94 | for (token, fragment) in protectedFragments { | |
| 95 | result = result.replacingOccurrences(of: token, with: fragment) | |
| 96 | } | |
| 97 | ||
| 98 | return result | |
| 99 | } | |
Sources/OrgSwift/Links.swift added +207
| @@ -0,0 +1,207 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | func protectOrgLinks( | |
| 4 | in text: String, | |
| 5 | protectedFragments: inout [String: String], | |
| 6 | imageURLResolver: ((String) -> String?)? = nil, | |
| 7 | linkURLResolver: ((String) -> String?)? = nil | |
| 8 | ) -> String { | |
| 9 | var result = text | |
| 10 | ||
| 11 | while let range = result.range(of: "[[") { | |
| 12 | guard let parsed = parseOrgLink(in: result, from: range.lowerBound) else { | |
| 13 | break | |
| 14 | } | |
| 15 | let token = "ZZPROTECTED\(protectedFragments.count)ZZ" | |
| 16 | protectedFragments[token] = renderOrgLink( | |
| 17 | destination: parsed.destination, | |
| 18 | label: parsed.label, | |
| 19 | imageURLResolver: imageURLResolver, | |
| 20 | linkURLResolver: linkURLResolver | |
| 21 | ) | |
| 22 | result.replaceSubrange(parsed.range, with: token) | |
| 23 | } | |
| 24 | ||
| 25 | return result | |
| 26 | } | |
| 27 | ||
| 28 | private func parseOrgLink( | |
| 29 | in text: String, | |
| 30 | from start: String.Index | |
| 31 | ) -> (range: Range<String.Index>, destination: String, label: String?)? { | |
| 32 | guard text[start...].hasPrefix("[[") else { return nil } | |
| 33 | ||
| 34 | var index = text.index(start, offsetBy: 2) | |
| 35 | guard let destinationEnd = text[index...].range(of: "][" )?.lowerBound else { | |
| 36 | guard let end = text[index...].range(of: "]]")?.lowerBound else { return nil } | |
| 37 | return (start..<text.index(end, offsetBy: 2), String(text[index..<end]), nil) | |
| 38 | } | |
| 39 | ||
| 40 | let destination = String(text[index..<destinationEnd]) | |
| 41 | index = text.index(destinationEnd, offsetBy: 2) | |
| 42 | let labelStart = index | |
| 43 | var depth = 0 | |
| 44 | ||
| 45 | while index < text.endIndex { | |
| 46 | if text[index...].hasPrefix("[[") { | |
| 47 | depth += 1 | |
| 48 | index = text.index(index, offsetBy: 2) | |
| 49 | continue | |
| 50 | } | |
| 51 | if text[index...].hasPrefix("]]") { | |
| 52 | if depth == 0 { | |
| 53 | let end = text.index(index, offsetBy: 2) | |
| 54 | return (start..<end, destination, String(text[labelStart..<index])) | |
| 55 | } | |
| 56 | depth -= 1 | |
| 57 | index = text.index(index, offsetBy: 2) | |
| 58 | continue | |
| 59 | } | |
| 60 | index = text.index(after: index) | |
| 61 | } | |
| 62 | ||
| 63 | return nil | |
| 64 | } | |
| 65 | ||
| 66 | private func renderOrgLink( | |
| 67 | destination: String, | |
| 68 | label: String?, | |
| 69 | imageURLResolver: ((String) -> String?)? = nil, | |
| 70 | linkURLResolver: ((String) -> String?)? = nil | |
| 71 | ) -> String { | |
| 72 | if let label, label.hasPrefix("[["), label.hasSuffix("]]") { | |
| 73 | let source = String(label.dropFirst(2).dropLast(2)) | |
| 74 | if let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) { | |
| 75 | let resolvedDestination = linkURLResolver?(destination) ?? destination | |
| 76 | guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else { | |
| 77 | return imageHTML | |
| 78 | } | |
| 79 | return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"# | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | if let imageHTML = makeOrgImageHTML( | |
| 84 | source: destination, | |
| 85 | alt: label, | |
| 86 | imageURLResolver: imageURLResolver | |
| 87 | ) { | |
| 88 | return imageHTML | |
| 89 | } | |
| 90 | ||
| 91 | let resolvedDestination = linkURLResolver?(destination) ?? destination | |
| 92 | guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else { | |
| 93 | return label ?? destination | |
| 94 | } | |
| 95 | ||
| 96 | let renderedLabel = label.map { | |
| 97 | processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 98 | } ?? destination | |
| 99 | return #"<a href="\#(sanitizedURL)">\#(renderedLabel)</a>"# | |
| 100 | } | |
| 101 | ||
| 102 | func makeOrgImageHTML( | |
| 103 | source: String, | |
| 104 | alt: String?, | |
| 105 | imageURLResolver: ((String) -> String?)? | |
| 106 | ) -> String? { | |
| 107 | guard isRenderableImageSource(source) else { return nil } | |
| 108 | let resolvedSource = imageURLResolver?(source) ?? source | |
| 109 | guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil } | |
| 110 | let altText = escapeHTMLAttribute(alt ?? "") | |
| 111 | return #"<img src="\#(sanitizedSource)" alt="\#(altText)">"# | |
| 112 | } | |
| 113 | ||
| 114 | private func isRenderableImageSource(_ source: String) -> Bool { | |
| 115 | let lowercased = source.lowercased() | |
| 116 | return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"] | |
| 117 | .contains(where: { lowercased.hasSuffix($0) }) | |
| 118 | } | |
| 119 | ||
| 120 | // MARK: - Relative link/image resolution | |
| 121 | ||
| 122 | func resolveRepositoryLinkURL( | |
| 123 | _ source: String, | |
| 124 | host: String, | |
| 125 | owner: String, | |
| 126 | repositoryName: String, | |
| 127 | ref: String, | |
| 128 | readmePath: String? | |
| 129 | ) -> String? { | |
| 130 | let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 131 | guard !trimmedSource.isEmpty else { return nil } | |
| 132 | ||
| 133 | if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://") | |
| 134 | || trimmedSource.hasPrefix("mailto:") || trimmedSource.hasPrefix("#") { | |
| 135 | return trimmedSource | |
| 136 | } | |
| 137 | ||
| 138 | return resolveRepositoryAssetURL( | |
| 139 | trimmedSource, | |
| 140 | host: host, | |
| 141 | owner: owner, | |
| 142 | repositoryName: repositoryName, | |
| 143 | ref: ref, | |
| 144 | readmePath: readmePath | |
| 145 | ) | |
| 146 | } | |
| 147 | ||
| 148 | func resolveRepositoryAssetURL( | |
| 149 | _ source: String, | |
| 150 | host: String, | |
| 151 | owner: String, | |
| 152 | repositoryName: String, | |
| 153 | ref: String, | |
| 154 | readmePath: String? | |
| 155 | ) -> String? { | |
| 156 | let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 157 | guard !trimmedSource.isEmpty else { return nil } | |
| 158 | ||
| 159 | if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://") || trimmedSource.hasPrefix("data:") { | |
| 160 | return trimmedSource | |
| 161 | } | |
| 162 | ||
| 163 | let relativePath: String | |
| 164 | if trimmedSource.hasPrefix("/") { | |
| 165 | relativePath = String(trimmedSource.dropFirst()) | |
| 166 | } else { | |
| 167 | let readmeDirectory = (readmePath as NSString?)?.deletingLastPathComponent ?? "" | |
| 168 | relativePath = normalizeRepositoryPath( | |
| 169 | (readmeDirectory as NSString).appendingPathComponent(trimmedSource) | |
| 170 | ) | |
| 171 | } | |
| 172 | ||
| 173 | guard !relativePath.isEmpty else { return nil } | |
| 174 | var components = URLComponents() | |
| 175 | components.scheme = "https" | |
| 176 | components.host = host | |
| 177 | let encodedOwner = owner.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? owner | |
| 178 | let encodedRepository = repositoryName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? repositoryName | |
| 179 | let encodedRef = ref.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? ref | |
| 180 | let encodedRelativePath = relativePath | |
| 181 | .split(separator: "/", omittingEmptySubsequences: false) | |
| 182 | .map { segment in | |
| 183 | String(segment).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(segment) | |
| 184 | } | |
| 185 | .joined(separator: "/") | |
| 186 | components.percentEncodedPath = "/\(encodedOwner)/\(encodedRepository)/blob/\(encodedRef)/\(encodedRelativePath)" | |
| 187 | return components.string | |
| 188 | } | |
| 189 | ||
| 190 | private func normalizeRepositoryPath(_ path: String) -> String { | |
| 191 | var components: [String] = [] | |
| 192 | ||
| 193 | for part in path.split(separator: "/") { | |
| 194 | switch part { | |
| 195 | case ".": | |
| 196 | continue | |
| 197 | case "..": | |
| 198 | if !components.isEmpty { | |
| 199 | components.removeLast() | |
| 200 | } | |
| 201 | default: | |
| 202 | components.append(String(part)) | |
| 203 | } | |
| 204 | } | |
| 205 | ||
| 206 | return components.joined(separator: "/") | |
| 207 | } | |
Sources/OrgSwift/Lists.swift added +147
| @@ -0,0 +1,147 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | enum OrgListType: Equatable { | |
| 4 | case unordered | |
| 5 | case ordered | |
| 6 | } | |
| 7 | ||
| 8 | func orderedListItem(in line: String) -> String? { | |
| 9 | guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil } | |
| 10 | return String(match.2) | |
| 11 | } | |
| 12 | ||
| 13 | func renderOrgListItemBody( | |
| 14 | _ lines: [String], | |
| 15 | imageURLResolver: ((String) -> String?)? = nil, | |
| 16 | linkURLResolver: ((String) -> String?)? = nil | |
| 17 | ) -> String { | |
| 18 | guard let firstLine = lines.first else { return "" } | |
| 19 | ||
| 20 | var contentLines: [String] = [firstLine.trimmingCharacters(in: .whitespaces)] | |
| 21 | var nestedLines: [String] = [] | |
| 22 | ||
| 23 | for line in lines.dropFirst() { | |
| 24 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 25 | if trimmed.isEmpty { | |
| 26 | continue | |
| 27 | } | |
| 28 | ||
| 29 | if isIndentedListItemLine(line) { | |
| 30 | nestedLines.append(outdentOrgListLine(line)) | |
| 31 | } else { | |
| 32 | contentLines.append(trimmed) | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | var html = renderTaskListItem( | |
| 37 | contentLines.joined(separator: " "), | |
| 38 | inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } | |
| 39 | ) | |
| 40 | if !nestedLines.isEmpty { | |
| 41 | html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 42 | } | |
| 43 | return html | |
| 44 | } | |
| 45 | ||
| 46 | private func renderNestedOrgListHTML( | |
| 47 | _ lines: [String], | |
| 48 | imageURLResolver: ((String) -> String?)? = nil, | |
| 49 | linkURLResolver: ((String) -> String?)? = nil | |
| 50 | ) -> String { | |
| 51 | var html = "" | |
| 52 | var listType: OrgListType? | |
| 53 | var currentItemLines: [String] = [] | |
| 54 | ||
| 55 | func flushNestedItem() { | |
| 56 | guard !currentItemLines.isEmpty else { return } | |
| 57 | html += "<li>" + renderOrgListItemBody(currentItemLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</li>\n" | |
| 58 | currentItemLines = [] | |
| 59 | } | |
| 60 | ||
| 61 | func closeNestedList() { | |
| 62 | flushNestedItem() | |
| 63 | switch listType { | |
| 64 | case .unordered: | |
| 65 | html += "</ul>\n" | |
| 66 | case .ordered: | |
| 67 | html += "</ol>\n" | |
| 68 | case nil: | |
| 69 | break | |
| 70 | } | |
| 71 | listType = nil | |
| 72 | } | |
| 73 | ||
| 74 | for line in lines { | |
| 75 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 76 | if trimmed.hasPrefix("- ") { | |
| 77 | if listType != .unordered { | |
| 78 | closeNestedList() | |
| 79 | html += "<ul>\n" | |
| 80 | listType = .unordered | |
| 81 | } | |
| 82 | flushNestedItem() | |
| 83 | currentItemLines = [String(trimmed.dropFirst(2))] | |
| 84 | continue | |
| 85 | } | |
| 86 | ||
| 87 | if let orderedItem = orderedListItem(in: trimmed) { | |
| 88 | if listType != .ordered { | |
| 89 | closeNestedList() | |
| 90 | html += "<ol>\n" | |
| 91 | listType = .ordered | |
| 92 | } | |
| 93 | flushNestedItem() | |
| 94 | currentItemLines = [orderedItem] | |
| 95 | continue | |
| 96 | } | |
| 97 | ||
| 98 | if listType != nil { | |
| 99 | currentItemLines.append(line) | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | closeNestedList() | |
| 104 | return html | |
| 105 | } | |
| 106 | ||
| 107 | func isIndentedContinuationLine(_ line: String) -> Bool { | |
| 108 | guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false } | |
| 109 | guard let first = line.first else { return false } | |
| 110 | return first == " " || first == "\t" | |
| 111 | } | |
| 112 | ||
| 113 | private func isIndentedListItemLine(_ line: String) -> Bool { | |
| 114 | guard isIndentedContinuationLine(line) else { return false } | |
| 115 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 116 | return trimmed.hasPrefix("- ") || orderedListItem(in: trimmed) != nil | |
| 117 | } | |
| 118 | ||
| 119 | private func outdentOrgListLine(_ line: String) -> String { | |
| 120 | var result = line | |
| 121 | while result.first == " " || result.first == "\t" { | |
| 122 | result.removeFirst() | |
| 123 | } | |
| 124 | return result | |
| 125 | } | |
| 126 | ||
| 127 | func renderTaskListItem( | |
| 128 | _ text: String, | |
| 129 | inlineRenderer: (String) -> String | |
| 130 | ) -> String { | |
| 131 | let trimmed = text.trimmingCharacters(in: .whitespaces) | |
| 132 | guard trimmed.count >= 4 else { | |
| 133 | return inlineRenderer(text) | |
| 134 | } | |
| 135 | ||
| 136 | let prefix = String(trimmed.prefix(4)) | |
| 137 | let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces) | |
| 138 | ||
| 139 | switch prefix { | |
| 140 | case "[ ] ": | |
| 141 | return #"<span class="task-list-item"><input type="checkbox" disabled> \#(inlineRenderer(remainder))</span>"# | |
| 142 | case "[x] ", "[X] ": | |
| 143 | return #"<span class="task-list-item"><input type="checkbox" checked disabled> \#(inlineRenderer(remainder))</span>"# | |
| 144 | default: | |
| 145 | return inlineRenderer(text) | |
| 146 | } | |
| 147 | } | |
Sources/OrgSwift/OrgRenderer.swift added +529
| @@ -0,0 +1,529 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Produces highlighted HTML for a fenced code block. Returning `nil` makes the | |
| 4 | /// renderer fall back to an escaped `<pre><code>` block. | |
| 5 | public protocol CodeHighlighter { | |
| 6 | func highlightedHTML(code: String, language: String?) -> String? | |
| 7 | } | |
| 8 | ||
| 9 | /// A highlighter that performs no highlighting. The renderer escapes the code | |
| 10 | /// and wraps it in `<pre><code>` when this is used. | |
| 11 | public struct PlainCodeHighlighter: CodeHighlighter { | |
| 12 | public init() {} | |
| 13 | public func highlightedHTML(code: String, language: String?) -> String? { nil } | |
| 14 | } | |
| 15 | ||
| 16 | /// Options controlling how relative links and images are resolved. | |
| 17 | /// | |
| 18 | /// When `owner` and `repositoryName` are both non-nil, relative links and image | |
| 19 | /// sources are rewritten to `{host}/{owner}/{repositoryName}/blob/{ref}/...`. | |
| 20 | /// When either is nil, relative links are left as-is (and dropped by the | |
| 21 | /// URL-scheme allowlist, which only permits absolute http/https/mailto). | |
| 22 | public struct OrgRenderOptions { | |
| 23 | /// Host used to build absolute URLs for repository-relative links. | |
| 24 | public var host: String | |
| 25 | /// Repository owner (e.g. `~ccleberg`). Nil disables relative-link resolution. | |
| 26 | public var owner: String? | |
| 27 | /// Repository name. Nil disables relative-link resolution. | |
| 28 | public var repositoryName: String? | |
| 29 | /// Git ref used in the `blob/<ref>` path segment. | |
| 30 | public var ref: String | |
| 31 | /// Path of the README being rendered, used to resolve paths relative to it. | |
| 32 | public var readmePath: String? | |
| 33 | ||
| 34 | public init( | |
| 35 | host: String = "git.sr.ht", | |
| 36 | owner: String? = nil, | |
| 37 | repositoryName: String? = nil, | |
| 38 | ref: String = "HEAD", | |
| 39 | readmePath: String? = nil | |
| 40 | ) { | |
| 41 | self.host = host | |
| 42 | self.owner = owner | |
| 43 | self.repositoryName = repositoryName | |
| 44 | self.ref = ref | |
| 45 | self.readmePath = readmePath | |
| 46 | } | |
| 47 | ||
| 48 | func imageURLResolver() -> ((String) -> String?)? { | |
| 49 | guard let owner, let repositoryName else { return nil } | |
| 50 | let host = host | |
| 51 | let ref = ref | |
| 52 | let readmePath = readmePath | |
| 53 | return { source in | |
| 54 | resolveRepositoryAssetURL( | |
| 55 | source, | |
| 56 | host: host, | |
| 57 | owner: owner, | |
| 58 | repositoryName: repositoryName, | |
| 59 | ref: ref, | |
| 60 | readmePath: readmePath | |
| 61 | ) | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | func linkURLResolver() -> ((String) -> String?)? { | |
| 66 | guard let owner, let repositoryName else { return nil } | |
| 67 | let host = host | |
| 68 | let ref = ref | |
| 69 | let readmePath = readmePath | |
| 70 | return { source in | |
| 71 | resolveRepositoryLinkURL( | |
| 72 | source, | |
| 73 | host: host, | |
| 74 | owner: owner, | |
| 75 | repositoryName: repositoryName, | |
| 76 | ref: ref, | |
| 77 | readmePath: readmePath | |
| 78 | ) | |
| 79 | } | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | /// Renders org-mode source to sanitized HTML. | |
| 84 | public enum OrgRenderer { | |
| 85 | public static func renderToHTML( | |
| 86 | _ source: String, | |
| 87 | options: OrgRenderOptions = .init(), | |
| 88 | highlighter: CodeHighlighter = PlainCodeHighlighter() | |
| 89 | ) -> String { | |
| 90 | orgToHTML( | |
| 91 | source, | |
| 92 | highlighter: highlighter, | |
| 93 | imageURLResolver: options.imageURLResolver(), | |
| 94 | linkURLResolver: options.linkURLResolver() | |
| 95 | ) | |
| 96 | } | |
| 97 | } | |
| 98 | ||
| 99 | // MARK: - Org-mode to HTML | |
| 100 | ||
| 101 | func orgToHTML( | |
| 102 | _ text: String, | |
| 103 | highlighter: CodeHighlighter, | |
| 104 | imageURLResolver: ((String) -> String?)? = nil, | |
| 105 | linkURLResolver: ((String) -> String?)? = nil | |
| 106 | ) -> String { | |
| 107 | let normalizedText = text | |
| 108 | .replacingOccurrences(of: "\r\n", with: "\n") | |
| 109 | .replacingOccurrences(of: "\r", with: "\n") | |
| 110 | let rawLines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) | |
| 111 | var title: String? | |
| 112 | var author: String? | |
| 113 | var date: String? | |
| 114 | let lines = rawLines.filter { line in | |
| 115 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 116 | guard let directive = orgKeywordDirective(in: trimmed) else { | |
| 117 | return true | |
| 118 | } | |
| 119 | switch directive.keyword { | |
| 120 | case "title": | |
| 121 | title = directive.value | |
| 122 | return false | |
| 123 | case "author": | |
| 124 | author = directive.value | |
| 125 | return false | |
| 126 | case "date": | |
| 127 | date = directive.value | |
| 128 | return false | |
| 129 | default: | |
| 130 | return true | |
| 131 | } | |
| 132 | } | |
| 133 | var html = "" | |
| 134 | var listType: OrgListType? | |
| 135 | var inQuoteBlock = false | |
| 136 | var inPropertyDrawer = false | |
| 137 | var srcLanguage: String? | |
| 138 | var srcLines: [String] = [] | |
| 139 | var inExampleBlock = false | |
| 140 | var inCenterBlock = false | |
| 141 | var inVerseBlock = false | |
| 142 | var currentListItemLines: [String] = [] | |
| 143 | var paragraph: [String] = [] | |
| 144 | var tableRows: [[String]] = [] | |
| 145 | var propertyRows: [(String, String)] = [] | |
| 146 | var verseLines: [String] = [] | |
| 147 | var pendingBlockName: String? | |
| 148 | var pendingBlockCaption: String? | |
| 149 | var activeBlockCaption: String? | |
| 150 | var isWrappingBlockFigure = false | |
| 151 | ||
| 152 | func beginPendingBlockWrapperIfNeeded() { | |
| 153 | guard pendingBlockName != nil || pendingBlockCaption != nil else { return } | |
| 154 | let idAttribute = pendingBlockName.map { #" id="\#(escapeHTMLAttribute($0))""# } ?? "" | |
| 155 | html += #"<figure class="org-block"\#(idAttribute)>"# + "\n" | |
| 156 | activeBlockCaption = pendingBlockCaption | |
| 157 | isWrappingBlockFigure = true | |
| 158 | pendingBlockName = nil | |
| 159 | pendingBlockCaption = nil | |
| 160 | } | |
| 161 | ||
| 162 | func closePendingBlockWrapper() { | |
| 163 | guard isWrappingBlockFigure else { return } | |
| 164 | if let activeBlockCaption { | |
| 165 | html += "<figcaption>" + processOrgInline(activeBlockCaption, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</figcaption>\n" | |
| 166 | } | |
| 167 | html += "</figure>\n" | |
| 168 | activeBlockCaption = nil | |
| 169 | isWrappingBlockFigure = false | |
| 170 | } | |
| 171 | ||
| 172 | func flushParagraph() { | |
| 173 | if !paragraph.isEmpty { | |
| 174 | let normalizedParagraph = paragraph | |
| 175 | .map { $0.trimmingCharacters(in: .whitespaces) } | |
| 176 | .joined(separator: " ") | |
| 177 | html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</p>\n" | |
| 178 | paragraph = [] | |
| 179 | } | |
| 180 | } | |
| 181 | ||
| 182 | func flushListItem() { | |
| 183 | guard !currentListItemLines.isEmpty else { return } | |
| 184 | html += "<li>" + renderOrgListItemBody( | |
| 185 | currentListItemLines, | |
| 186 | imageURLResolver: imageURLResolver, | |
| 187 | linkURLResolver: linkURLResolver | |
| 188 | ) + "</li>\n" | |
| 189 | currentListItemLines = [] | |
| 190 | } | |
| 191 | ||
| 192 | func closeList() { | |
| 193 | flushListItem() | |
| 194 | switch listType { | |
| 195 | case .unordered: | |
| 196 | html += "</ul>\n" | |
| 197 | case .ordered: | |
| 198 | html += "</ol>\n" | |
| 199 | case nil: | |
| 200 | break | |
| 201 | } | |
| 202 | listType = nil | |
| 203 | } | |
| 204 | ||
| 205 | func flushTable() { | |
| 206 | guard !tableRows.isEmpty else { return } | |
| 207 | beginPendingBlockWrapperIfNeeded() | |
| 208 | html += renderHTMLTable( | |
| 209 | rows: tableRows, | |
| 210 | inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } | |
| 211 | ) | |
| 212 | closePendingBlockWrapper() | |
| 213 | tableRows = [] | |
| 214 | } | |
| 215 | ||
| 216 | func flushPropertyDrawer() { | |
| 217 | guard !propertyRows.isEmpty else { return } | |
| 218 | html += "<dl class=\"org-properties\">\n" | |
| 219 | for (key, value) in propertyRows { | |
| 220 | html += "<dt>" + escapeHTML(key) + "</dt>" | |
| 221 | html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</dd>\n" | |
| 222 | } | |
| 223 | html += "</dl>\n" | |
| 224 | propertyRows = [] | |
| 225 | } | |
| 226 | ||
| 227 | func closeQuoteBlock() { | |
| 228 | if inQuoteBlock { | |
| 229 | flushParagraph() | |
| 230 | html += "</blockquote>\n" | |
| 231 | inQuoteBlock = false | |
| 232 | } | |
| 233 | } | |
| 234 | ||
| 235 | func closeSourceBlock() { | |
| 236 | if let language = srcLanguage { | |
| 237 | let code = srcLines.joined(separator: "\n") | |
| 238 | if !code.isEmpty { | |
| 239 | let highlighted = highlighter.highlightedHTML(code: code, language: language.isEmpty ? nil : language) | |
| 240 | html += (highlighted ?? escapeHTML(code)) + "\n" | |
| 241 | } | |
| 242 | html += "</code></pre>\n" | |
| 243 | srcLanguage = nil | |
| 244 | srcLines = [] | |
| 245 | closePendingBlockWrapper() | |
| 246 | } | |
| 247 | } | |
| 248 | ||
| 249 | func closeExampleBlock() { | |
| 250 | if inExampleBlock { | |
| 251 | html += "</code></pre>\n" | |
| 252 | inExampleBlock = false | |
| 253 | closePendingBlockWrapper() | |
| 254 | } | |
| 255 | } | |
| 256 | ||
| 257 | func closeCenterBlock() { | |
| 258 | if inCenterBlock { | |
| 259 | flushParagraph() | |
| 260 | html += "</div>\n" | |
| 261 | inCenterBlock = false | |
| 262 | closePendingBlockWrapper() | |
| 263 | } | |
| 264 | } | |
| 265 | ||
| 266 | func closeVerseBlock() { | |
| 267 | if inVerseBlock { | |
| 268 | let content = verseLines | |
| 269 | .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } | |
| 270 | .joined(separator: "\n") | |
| 271 | html += #"<blockquote class="org-verse">"# + "\n" | |
| 272 | html += content + "\n" | |
| 273 | html += "</blockquote>\n" | |
| 274 | verseLines = [] | |
| 275 | inVerseBlock = false | |
| 276 | closePendingBlockWrapper() | |
| 277 | } | |
| 278 | } | |
| 279 | ||
| 280 | func flushBlockState() { | |
| 281 | flushParagraph() | |
| 282 | closeList() | |
| 283 | flushTable() | |
| 284 | flushPropertyDrawer() | |
| 285 | } | |
| 286 | ||
| 287 | if title != nil || author != nil || date != nil { | |
| 288 | html += "<div class=\"org-metadata\">\n" | |
| 289 | if let title { | |
| 290 | html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n" | |
| 291 | } | |
| 292 | if let author { | |
| 293 | html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n" | |
| 294 | } | |
| 295 | if let date { | |
| 296 | html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n" | |
| 297 | } | |
| 298 | html += "</div>\n" | |
| 299 | } | |
| 300 | ||
| 301 | for line in lines { | |
| 302 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 303 | ||
| 304 | if srcLanguage != nil { | |
| 305 | if trimmed.lowercased() == "#+end_src" { | |
| 306 | closeSourceBlock() | |
| 307 | } else { | |
| 308 | srcLines.append(line) | |
| 309 | } | |
| 310 | continue | |
| 311 | } | |
| 312 | ||
| 313 | if inExampleBlock { | |
| 314 | if trimmed.lowercased() == "#+end_example" { | |
| 315 | closeExampleBlock() | |
| 316 | } else { | |
| 317 | html += escapeHTML(line) + "\n" | |
| 318 | } | |
| 319 | continue | |
| 320 | } | |
| 321 | ||
| 322 | if inVerseBlock { | |
| 323 | if trimmed.lowercased() == "#+end_verse" { | |
| 324 | closeVerseBlock() | |
| 325 | } else { | |
| 326 | verseLines.append(line) | |
| 327 | } | |
| 328 | continue | |
| 329 | } | |
| 330 | ||
| 331 | if inQuoteBlock, trimmed.lowercased() == "#+end_quote" { | |
| 332 | closeQuoteBlock() | |
| 333 | continue | |
| 334 | } | |
| 335 | ||
| 336 | if inCenterBlock { | |
| 337 | if trimmed.lowercased() == "#+end_center" { | |
| 338 | closeCenterBlock() | |
| 339 | } else if trimmed.isEmpty { | |
| 340 | flushParagraph() | |
| 341 | } else { | |
| 342 | paragraph.append(line) | |
| 343 | } | |
| 344 | continue | |
| 345 | } | |
| 346 | ||
| 347 | if trimmed == "#" || trimmed.hasPrefix("# ") { | |
| 348 | continue | |
| 349 | } | |
| 350 | ||
| 351 | if let directive = orgKeywordDirective(in: trimmed) { | |
| 352 | switch directive.keyword { | |
| 353 | case "caption": | |
| 354 | pendingBlockCaption = directive.value | |
| 355 | continue | |
| 356 | case "name": | |
| 357 | pendingBlockName = directive.value | |
| 358 | continue | |
| 359 | case "options", "property": | |
| 360 | continue | |
| 361 | default: | |
| 362 | break | |
| 363 | } | |
| 364 | } | |
| 365 | ||
| 366 | if trimmed.lowercased().hasPrefix("#+begin_src") { | |
| 367 | closeQuoteBlock() | |
| 368 | flushBlockState() | |
| 369 | beginPendingBlockWrapperIfNeeded() | |
| 370 | let language = trimmed | |
| 371 | .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) | |
| 372 | .dropFirst() | |
| 373 | .first | |
| 374 | .map(String.init)? | |
| 375 | .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 376 | let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? "" | |
| 377 | html += "<pre><code\(classAttribute)>" | |
| 378 | srcLanguage = language ?? "" | |
| 379 | srcLines = [] | |
| 380 | continue | |
| 381 | } | |
| 382 | ||
| 383 | if trimmed.lowercased() == "#+begin_example" { | |
| 384 | closeQuoteBlock() | |
| 385 | flushBlockState() | |
| 386 | beginPendingBlockWrapperIfNeeded() | |
| 387 | html += "<pre><code>" | |
| 388 | inExampleBlock = true | |
| 389 | continue | |
| 390 | } | |
| 391 | ||
| 392 | if trimmed.lowercased() == "#+begin_quote" { | |
| 393 | flushBlockState() | |
| 394 | beginPendingBlockWrapperIfNeeded() | |
| 395 | html += "<blockquote>\n" | |
| 396 | inQuoteBlock = true | |
| 397 | continue | |
| 398 | } | |
| 399 | ||
| 400 | if trimmed.lowercased() == "#+begin_center" { | |
| 401 | closeQuoteBlock() | |
| 402 | flushBlockState() | |
| 403 | beginPendingBlockWrapperIfNeeded() | |
| 404 | html += "<div style=\"text-align:center\">\n" | |
| 405 | inCenterBlock = true | |
| 406 | continue | |
| 407 | } | |
| 408 | ||
| 409 | if trimmed.lowercased() == "#+begin_verse" { | |
| 410 | closeQuoteBlock() | |
| 411 | flushBlockState() | |
| 412 | beginPendingBlockWrapperIfNeeded() | |
| 413 | verseLines = [] | |
| 414 | inVerseBlock = true | |
| 415 | continue | |
| 416 | } | |
| 417 | ||
| 418 | if trimmed == ":PROPERTIES:" { | |
| 419 | closeQuoteBlock() | |
| 420 | flushBlockState() | |
| 421 | inPropertyDrawer = true | |
| 422 | continue | |
| 423 | } | |
| 424 | ||
| 425 | if trimmed == ":END:", inPropertyDrawer { | |
| 426 | flushPropertyDrawer() | |
| 427 | inPropertyDrawer = false | |
| 428 | continue | |
| 429 | } | |
| 430 | ||
| 431 | if inPropertyDrawer, | |
| 432 | trimmed.hasPrefix(":"), | |
| 433 | let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") { | |
| 434 | let keyStart = trimmed.index(after: trimmed.startIndex) | |
| 435 | let key = String(trimmed[keyStart..<secondColonIndex]).trimmingCharacters(in: .whitespaces) | |
| 436 | let valueStart = trimmed.index(after: secondColonIndex) | |
| 437 | let value = String(trimmed[valueStart...]).trimmingCharacters(in: .whitespaces) | |
| 438 | if !key.isEmpty { | |
| 439 | propertyRows.append((key, value)) | |
| 440 | continue | |
| 441 | } | |
| 442 | } | |
| 443 | ||
| 444 | if isTableLine(trimmed) { | |
| 445 | closeQuoteBlock() | |
| 446 | flushParagraph() | |
| 447 | closeList() | |
| 448 | tableRows.append(parseTableRow(trimmed)) | |
| 449 | continue | |
| 450 | } else { | |
| 451 | flushTable() | |
| 452 | } | |
| 453 | ||
| 454 | if isOrgHorizontalRule(trimmed) { | |
| 455 | closeQuoteBlock() | |
| 456 | flushBlockState() | |
| 457 | html += "<hr>\n" | |
| 458 | continue | |
| 459 | } | |
| 460 | ||
| 461 | // Org headings: * heading, ** heading, *** heading | |
| 462 | if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) { | |
| 463 | closeQuoteBlock() | |
| 464 | flushBlockState() | |
| 465 | let level = match.1.count | |
| 466 | let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 467 | html += "<h\(level)>" + content + "</h\(level)>\n" | |
| 468 | continue | |
| 469 | } | |
| 470 | ||
| 471 | if listType != nil && isIndentedContinuationLine(line) { | |
| 472 | currentListItemLines.append(line) | |
| 473 | continue | |
| 474 | } | |
| 475 | ||
| 476 | // List items: - item | |
| 477 | if !isIndentedContinuationLine(line), trimmed.hasPrefix("- ") { | |
| 478 | flushParagraph() | |
| 479 | flushPropertyDrawer() | |
| 480 | if listType != .unordered { | |
| 481 | closeList() | |
| 482 | html += "<ul>\n" | |
| 483 | listType = .unordered | |
| 484 | } | |
| 485 | flushListItem() | |
| 486 | currentListItemLines = [String(trimmed.dropFirst(2))] | |
| 487 | continue | |
| 488 | } | |
| 489 | ||
| 490 | if !isIndentedContinuationLine(line), let orderedItem = orderedListItem(in: trimmed) { | |
| 491 | flushParagraph() | |
| 492 | flushPropertyDrawer() | |
| 493 | if listType != .ordered { | |
| 494 | closeList() | |
| 495 | html += "<ol>\n" | |
| 496 | listType = .ordered | |
| 497 | } | |
| 498 | flushListItem() | |
| 499 | currentListItemLines = [orderedItem] | |
| 500 | continue | |
| 501 | } | |
| 502 | ||
| 503 | // Blank line | |
| 504 | if trimmed.isEmpty { | |
| 505 | if inQuoteBlock { | |
| 506 | flushParagraph() | |
| 507 | } else { | |
| 508 | flushBlockState() | |
| 509 | } | |
| 510 | continue | |
| 511 | } | |
| 512 | ||
| 513 | // Regular text | |
| 514 | if pendingBlockName != nil || pendingBlockCaption != nil { | |
| 515 | pendingBlockName = nil | |
| 516 | pendingBlockCaption = nil | |
| 517 | } | |
| 518 | paragraph.append(line) | |
| 519 | } | |
| 520 | ||
| 521 | closeSourceBlock() | |
| 522 | closeExampleBlock() | |
| 523 | closeCenterBlock() | |
| 524 | closeVerseBlock() | |
| 525 | closeQuoteBlock() | |
| 526 | flushBlockState() | |
| 527 | ||
| 528 | return html | |
| 529 | } | |
Sources/OrgSwift/Tables.swift added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | func isTableLine(_ line: String) -> Bool { | |
| 4 | line.hasPrefix("|") && line.hasSuffix("|") | |
| 5 | } | |
| 6 | ||
| 7 | func parseTableRow(_ line: String) -> [String] { | |
| 8 | line | |
| 9 | .split(separator: "|", omittingEmptySubsequences: false) | |
| 10 | .dropFirst() | |
| 11 | .dropLast() | |
| 12 | .map { String($0).trimmingCharacters(in: .whitespaces) } | |
| 13 | } | |
| 14 | ||
| 15 | private func parseOrgTableSeparatorRow(_ line: String) -> [String] { | |
| 16 | var content = line.trimmingCharacters(in: .whitespaces) | |
| 17 | if content.hasPrefix("|") { | |
| 18 | content.removeFirst() | |
| 19 | } | |
| 20 | if content.hasSuffix("|") { | |
| 21 | content.removeLast() | |
| 22 | } | |
| 23 | return content | |
| 24 | .split(separator: "+", omittingEmptySubsequences: false) | |
| 25 | .map { String($0).trimmingCharacters(in: .whitespaces) } | |
| 26 | } | |
| 27 | ||
| 28 | private func isTableSeparatorCell(_ cell: String) -> Bool { | |
| 29 | tableAlignment(for: cell) != nil | |
| 30 | } | |
| 31 | ||
| 32 | private func tableAlignment(for cell: String) -> String? { | |
| 33 | let trimmed = cell.trimmingCharacters(in: .whitespaces) | |
| 34 | guard !trimmed.isEmpty else { return nil } | |
| 35 | ||
| 36 | let core = trimmed.replacingOccurrences(of: ":", with: "") | |
| 37 | guard !core.isEmpty, core.allSatisfy({ $0 == "-" || $0 == "+" }) else { | |
| 38 | return nil | |
| 39 | } | |
| 40 | ||
| 41 | let isLeftAligned = trimmed.hasPrefix(":") | |
| 42 | let isRightAligned = trimmed.hasSuffix(":") | |
| 43 | switch (isLeftAligned, isRightAligned) { | |
| 44 | case (true, true): | |
| 45 | return "center" | |
| 46 | case (true, false): | |
| 47 | return "left" | |
| 48 | case (false, true): | |
| 49 | return "right" | |
| 50 | case (false, false): | |
| 51 | return "" | |
| 52 | } | |
| 53 | } | |
| 54 | ||
| 55 | func renderHTMLTable( | |
| 56 | rows: [[String]], | |
| 57 | inlineRenderer: (String) -> String | |
| 58 | ) -> String { | |
| 59 | guard !rows.isEmpty else { return "" } | |
| 60 | let separatorCells: [String] | |
| 61 | if rows.count > 1, rows[1].count == 1 { | |
| 62 | separatorCells = parseOrgTableSeparatorRow(rows[1][0]) | |
| 63 | } else { | |
| 64 | separatorCells = rows.count > 1 ? rows[1] : [] | |
| 65 | } | |
| 66 | let hasHeaderSeparator = rows.count > 1 && !separatorCells.isEmpty && separatorCells.allSatisfy(isTableSeparatorCell) | |
| 67 | let headerRow = rows.first ?? [] | |
| 68 | let bodyRows = hasHeaderSeparator ? Array(rows.dropFirst(2)) : rows | |
| 69 | let columnAlignments = hasHeaderSeparator ? separatorCells.map(tableAlignment) : [] | |
| 70 | var html = "<table>\n" | |
| 71 | ||
| 72 | if hasHeaderSeparator { | |
| 73 | html += "<thead><tr>" | |
| 74 | for (index, cell) in headerRow.enumerated() { | |
| 75 | html += "<th" + tableAlignmentStyleAttribute(columnAlignment(at: index, in: columnAlignments)) + ">" + inlineRenderer(cell) + "</th>" | |
| 76 | } | |
| 77 | html += "</tr></thead>\n" | |
| 78 | } | |
| 79 | ||
| 80 | html += "<tbody>\n" | |
| 81 | for row in bodyRows { | |
| 82 | html += "<tr>" | |
| 83 | for (index, cell) in row.enumerated() { | |
| 84 | html += "<td" + tableAlignmentStyleAttribute(columnAlignment(at: index, in: columnAlignments)) + ">" + inlineRenderer(cell) + "</td>" | |
| 85 | } | |
| 86 | html += "</tr>\n" | |
| 87 | } | |
| 88 | html += "</tbody>\n" | |
| 89 | html += "</table>\n" | |
| 90 | return html | |
| 91 | } | |
| 92 | ||
| 93 | private func columnAlignment(at index: Int, in alignments: [String?]) -> String? { | |
| 94 | guard alignments.indices.contains(index) else { return nil } | |
| 95 | return alignments[index] | |
| 96 | } | |
| 97 | ||
| 98 | private func tableAlignmentStyleAttribute(_ alignment: String?) -> String { | |
| 99 | guard let alignment, !alignment.isEmpty else { return "" } | |
| 100 | return #" style="text-align: \#(alignment);""# | |
| 101 | } | |
Tests/OrgSwiftTests/OrgRendererTests.swift added +164
| @@ -0,0 +1,164 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgSwift | |
| 4 | ||
| 5 | private func render(_ source: String, options: OrgRenderOptions = .init()) -> String { | |
| 6 | OrgRenderer.renderToHTML(source, options: options) | |
| 7 | } | |
| 8 | ||
| 9 | struct OrgRendererTests { | |
| 10 | ||
| 11 | @Test | |
| 12 | func orgDeepHeading() { | |
| 13 | let html = render("**** Level 4 Heading") | |
| 14 | ||
| 15 | #expect(html.contains("<h4>")) | |
| 16 | } | |
| 17 | ||
| 18 | @Test | |
| 19 | func orgCommentLinesIgnored() { | |
| 20 | let html = render("# This is a comment\nNormal text") | |
| 21 | ||
| 22 | #expect(!html.contains("This is a comment")) | |
| 23 | #expect(html.contains("Normal text")) | |
| 24 | } | |
| 25 | ||
| 26 | @Test | |
| 27 | func orgStrikethrough() { | |
| 28 | let html = render("+deleted text+") | |
| 29 | ||
| 30 | #expect(html.contains("<del>")) | |
| 31 | } | |
| 32 | ||
| 33 | @Test | |
| 34 | func orgExampleBlock() { | |
| 35 | let html = render("#+begin_example\nhello world\n#+end_example") | |
| 36 | ||
| 37 | #expect(html.contains("<pre><code>")) | |
| 38 | #expect(html.contains("hello world")) | |
| 39 | } | |
| 40 | ||
| 41 | @Test | |
| 42 | func orgTitleKeyword() { | |
| 43 | let html = render("#+TITLE: My Document\nBody text") | |
| 44 | ||
| 45 | #expect(html.contains("org-title")) | |
| 46 | #expect(html.contains("My Document")) | |
| 47 | } | |
| 48 | ||
| 49 | @Test | |
| 50 | func orgItalicDoesNotMatchURLPaths() { | |
| 51 | let html = render("[[https://example.com/path/to/file]]") | |
| 52 | ||
| 53 | #expect(!html.contains("<em>")) | |
| 54 | } | |
| 55 | ||
| 56 | @Test | |
| 57 | func orgWrappedBulletNormalizesLines() { | |
| 58 | let html = render("- First line\n continues here") | |
| 59 | ||
| 60 | #expect(html.contains("<li>First line continues here</li>")) | |
| 61 | } | |
| 62 | ||
| 63 | @Test | |
| 64 | func orgHeaderKeywordsIgnored() { | |
| 65 | let html = render(""" | |
| 66 | #+OPTIONS: toc:nil | |
| 67 | #+PROPERTY: header-args :results output | |
| 68 | Body text | |
| 69 | """) | |
| 70 | ||
| 71 | #expect(!html.contains("#+OPTIONS")) | |
| 72 | #expect(!html.contains("#+PROPERTY")) | |
| 73 | #expect(html.contains("<p>Body text</p>")) | |
| 74 | } | |
| 75 | ||
| 76 | @Test | |
| 77 | func orgVerseBlock() { | |
| 78 | let html = render(""" | |
| 79 | #+begin_verse | |
| 80 | There is a line. | |
| 81 | And an indented line. | |
| 82 | #+end_verse | |
| 83 | """) | |
| 84 | ||
| 85 | #expect(html.contains(#"<blockquote class="org-verse">"#)) | |
| 86 | #expect(html.contains("There is a line.")) | |
| 87 | #expect(html.contains("And an indented line.")) | |
| 88 | #expect(!html.contains("#+begin_verse")) | |
| 89 | } | |
| 90 | ||
| 91 | @Test | |
| 92 | func orgNamedBlockRendersCaption() { | |
| 93 | let html = render(""" | |
| 94 | #+CAPTION: Build output | |
| 95 | #+NAME: build-log | |
| 96 | #+begin_example | |
| 97 | hello world | |
| 98 | #+end_example | |
| 99 | """) | |
| 100 | ||
| 101 | #expect(html.contains(#"<figure class="org-block" id="build-log">"#)) | |
| 102 | #expect(html.contains("<figcaption>Build output</figcaption>")) | |
| 103 | #expect(!html.contains("#+CAPTION")) | |
| 104 | #expect(!html.contains("#+NAME")) | |
| 105 | } | |
| 106 | ||
| 107 | @Test | |
| 108 | func orgLinkedImageRenders() { | |
| 109 | let html = render("[[https://example.com][[https://img.cleberg.net/apps/hutch/screenshots/ipad/01_patch.jpg]]]") | |
| 110 | ||
| 111 | #expect(html.contains(#"<a href="https://example.com">"#)) | |
| 112 | #expect(html.contains(#"<img src="https://img.cleberg.net/apps/hutch/screenshots/ipad/01_patch.jpg" alt="">"#)) | |
| 113 | } | |
| 114 | ||
| 115 | @Test | |
| 116 | func orgLinkedRelativeImageRenders() { | |
| 117 | // In hutch this passed an explicit imageURLResolver closure; here the same | |
| 118 | // resolution is expressed through OrgRenderOptions (owner/repositoryName/host). | |
| 119 | let html = render( | |
| 120 | "[[https://example.com/docs][[./images/badge.svg]]]", | |
| 121 | options: OrgRenderOptions(owner: "~ccleberg", repositoryName: "Hutch") | |
| 122 | ) | |
| 123 | ||
| 124 | #expect(html.contains(#"<a href="https://example.com/docs">"#)) | |
| 125 | #expect(html.contains(#"<img src="https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg" alt="">"#)) | |
| 126 | } | |
| 127 | ||
| 128 | @Test | |
| 129 | func orgNestedBulletListRendersNestedMarkup() { | |
| 130 | let html = render(""" | |
| 131 | * Lists | |
| 132 | ** Unordered | |
| 133 | - First bullet | |
| 134 | - Second bullet | |
| 135 | - Third bullet with /italic/ and *bold* | |
| 136 | - Bullet with wrapped | |
| 137 | continuation line | |
| 138 | - Bullet with nested list | |
| 139 | - Nested child one | |
| 140 | - Nested child two | |
| 141 | - Bullet with inline code ~let x = 1~ | |
| 142 | """) | |
| 143 | ||
| 144 | #expect(html.contains("<ul>")) | |
| 145 | #expect(html.contains("<li>Bullet with nested list\n<ul>")) | |
| 146 | #expect(html.contains("<li>Nested child one</li>")) | |
| 147 | #expect(html.contains("<li>Nested child two</li>")) | |
| 148 | #expect(html.contains("<li>Bullet with wrapped continuation line</li>")) | |
| 149 | } | |
| 150 | ||
| 151 | @Test | |
| 152 | func orgTableAlignmentRendersStyles() { | |
| 153 | let html = render(""" | |
| 154 | | Left | Center | Right | | |
| 155 | |:-----+:-----:+------:| | |
| 156 | | a | b | c | | |
| 157 | | 1 | 2 | 3 | | |
| 158 | """) | |
| 159 | ||
| 160 | #expect(html.contains("text-align: left;")) | |
| 161 | #expect(html.contains("text-align: center;")) | |
| 162 | #expect(html.contains("text-align: right;")) | |
| 163 | } | |
| 164 | } | |