Commit cd8a73c8ec
Unsigned
Layout: unified · split
.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 | } | ||