A dependency-free Swift library that renders org-mode to sanitized HTML.

html library org-mode swift

Sources/OrgSwift/Skeleton.swift

218 lines · 8298 bytes

  1// HTML  semantic skeleton reduction.
  2//
  3// A faithful port of orgo's `skeleton()` (src/skeleton.rs) and the contract described in
  4// the org-conformance corpus (SKELETON.md). It exists so OrgSwift can be measured against
  5// the same goldens every other org renderer is: reduce this renderer's HTML to a
  6// skeleton, reduce orgo's reference HTML to a skeleton, and compare. The two renderers
  7// wrap and class things completely differently; the skeleton is the part they can
  8// meaningfully agree on.
  9//
 10// The reduction must be byte-identical to orgo's, or a comparison means nothing. The
 11// package's conformance test self-checks that first, by feeding each corpus `.html`
 12// through this port and requiring it to reproduce the checked-in `.skeleton`.
 13
 14import Foundation
 15
 16public enum OrgSkeleton {
 17    /// Elements dropped entirely: `div` is layout, `span` is per-token highlighter noise.
 18    private static let ignored: Set<String> = ["div", "span"]
 19
 20    /// The only content-bearing attributes; everything else is generated or cosmetic.
 21    private static let keptAttrs = ["href", "src"]
 22
 23    /// HTML void elements never emit a close event.
 24    private static let void: Set<String> = [
 25        "br", "hr", "img", "input", "meta", "link", "col", "area", "base", "source", "wbr",
 26    ]
 27
 28    /// Reduce an HTML fragment to one line per element open, element close, or text run.
 29    public static func skeleton(_ html: String) -> [String] {
 30        var out: [String] = []
 31        let chars = Array(html)
 32        var i = 0
 33        var text = ""
 34
 35        while i < chars.count {
 36            if chars[i] != "<" {
 37                text.append(chars[i])
 38                i += 1
 39                continue
 40            }
 41
 42            // Comments and doctypes carry nothing.
 43            if i + 1 < chars.count, chars[i + 1] == "!" {
 44                if let end = findFrom(chars, i, ">") {
 45                    i = end + 1
 46                } else {
 47                    break
 48                }
 49                continue
 50            }
 51            guard let end = findFrom(chars, i, ">") else { break }
 52            let rawFull = String(chars[(i + 1)..<end])
 53            i = end + 1
 54
 55            // trim  strip trailing '/'  trim
 56            var raw = rawFull.trimmingCharacters(in: .whitespaces)
 57            if raw.hasSuffix("/") { raw.removeLast() }
 58            raw = raw.trimmingCharacters(in: .whitespaces)
 59
 60            if raw.hasPrefix("/") {
 61                let name = raw.dropFirst().trimmingCharacters(in: .whitespaces).lowercased()
 62                if !ignored.contains(name) && !void.contains(name) {
 63                    flushText(&text, &out)
 64                    out.append("</\(name)>")
 65                }
 66                continue
 67            }
 68
 69            let (name, rest) = splitFirstWhitespace(raw)
 70            let lname = name.lowercased()
 71            if lname.isEmpty || ignored.contains(lname) {
 72                continue
 73            }
 74            let attrs = keptAttributes(rest)
 75            flushText(&text, &out)
 76            out.append("<\(lname)\(attrs)>")
 77        }
 78        flushText(&text, &out)
 79        return out
 80    }
 81
 82    private static func flushText(_ text: inout String, _ out: inout [String]) {
 83        let decoded = decodeEntities(text)
 84        let collapsed = decoded.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ")
 85        if !collapsed.isEmpty {
 86            out.append(debugQuote(collapsed))
 87        }
 88        text = ""
 89    }
 90
 91    /// Reproduce Rust's `format!("{:?}", s)` for whitespace-collapsed text: wrap in double
 92    /// quotes and escape `\` and `"`. No control characters survive whitespace collapse,
 93    /// so no further escaping is needed to match the goldens.
 94    private static func debugQuote(_ s: String) -> String {
 95        var escaped = ""
 96        escaped.reserveCapacity(s.count + 2)
 97        for c in s {
 98            if c == "\\" || c == "\"" { escaped.append("\\") }
 99            escaped.append(c)
100        }
101        return "\"\(escaped)\""
102    }
103
104    private static func findFrom(_ chars: [Character], _ from: Int, _ needle: Character) -> Int? {
105        var k = from
106        while k < chars.count {
107            if chars[k] == needle { return k }
108            k += 1
109        }
110        return nil
111    }
112
113    private static func splitFirstWhitespace(_ s: String) -> (String, String) {
114        guard let idx = s.firstIndex(where: { $0.isWhitespace }) else { return (s, "") }
115        let name = String(s[s.startIndex..<idx])
116        let rest = String(s[s.index(after: idx)...])
117        return (name, rest)
118    }
119
120    private static func keptAttributes(_ rest: String) -> String {
121        var result = ""
122        for attr in keptAttrs {
123            if let value = attributeValue(rest, attr) {
124                result += " \(attr)=\"\(decodeEntities(value))\""
125            }
126        }
127        return result
128    }
129
130    private static func attributeValue(_ rest: String, _ name: String) -> String? {
131        let chars = Array(rest)
132        let nameChars = Array(name)
133        var search = 0
134        while let pos = indexOf(chars, nameChars, from: search) {
135            let beforeOK = pos == 0 || chars[pos - 1].isWhitespace
136            var after = pos + nameChars.count
137            while after < chars.count && chars[after].isWhitespace { after += 1 }
138            if beforeOK && after < chars.count && chars[after] == "=" {
139                var value = after + 1
140                while value < chars.count && chars[value].isWhitespace { value += 1 }
141                guard value < chars.count else { return nil }
142                let quote = chars[value]
143                if quote == "\"" || quote == "'" {
144                    var end = value + 1
145                    while end < chars.count && chars[end] != quote { end += 1 }
146                    guard end < chars.count else { return nil }
147                    return String(chars[(value + 1)..<end])
148                }
149                var end = value
150                while end < chars.count && !chars[end].isWhitespace { end += 1 }
151                return String(chars[value..<end])
152            }
153            search = pos + nameChars.count
154        }
155        return nil
156    }
157
158    private static func indexOf(_ haystack: [Character], _ needle: [Character], from: Int) -> Int? {
159        if needle.isEmpty { return from }
160        var k = from
161        while k + needle.count <= haystack.count {
162            if Array(haystack[k..<(k + needle.count)]) == needle { return k }
163            k += 1
164        }
165        return nil
166    }
167
168    /// Decode the entities either exporter is likely to emit.
169    static func decodeEntities(_ s: String) -> String {
170        var out = ""
171        out.reserveCapacity(s.count)
172        let chars = Array(s)
173        var i = 0
174        while i < chars.count {
175            guard chars[i] == "&" else { out.append(chars[i]); i += 1; continue }
176            // find ';' within 12 chars
177            var semi: Int? = nil
178            var k = i + 1
179            while k < chars.count && k - i <= 12 {
180                if chars[k] == ";" { semi = k; break }
181                k += 1
182            }
183            guard let semiIdx = semi else { out.append("&"); i += 1; continue }
184            let entity = String(chars[(i + 1)..<semiIdx])
185            let decoded = decodeEntity(entity)
186            if let d = decoded {
187                out.append(d == "\u{a0}" ? " " : String(d))
188                i = semiIdx + 1
189            } else {
190                out.append("&")
191                i += 1
192            }
193        }
194        return out
195    }
196
197    private static func decodeEntity(_ entity: String) -> Character? {
198        switch entity {
199        case "amp": return "&"
200        case "lt": return "<"
201        case "gt": return ">"
202        case "quot": return "\""
203        case "apos": return "'"
204        case "nbsp": return "\u{a0}"
205        default:
206            guard entity.hasPrefix("#") else { return nil }
207            let num = String(entity.dropFirst())
208            let scalar: UInt32?
209            if num.hasPrefix("x") || num.hasPrefix("X") {
210                scalar = UInt32(num.dropFirst(), radix: 16)
211            } else {
212                scalar = UInt32(num)
213            }
214            guard let s = scalar, let u = Unicode.Scalar(s) else { return nil }
215            return Character(u)
216        }
217    }
218}