// HTML → semantic skeleton reduction. // // A faithful port of orgo's `skeleton()` (src/skeleton.rs) and the contract described in // the org-conformance corpus (SKELETON.md). It exists so OrgSwift can be measured against // the same goldens every other org renderer is: reduce this renderer's HTML to a // skeleton, reduce orgo's reference HTML to a skeleton, and compare. The two renderers // wrap and class things completely differently; the skeleton is the part they can // meaningfully agree on. // // The reduction must be byte-identical to orgo's, or a comparison means nothing. The // package's conformance test self-checks that first, by feeding each corpus `.html` // through this port and requiring it to reproduce the checked-in `.skeleton`. import Foundation public enum OrgSkeleton { /// Elements dropped entirely: `div` is layout, `span` is per-token highlighter noise. private static let ignored: Set = ["div", "span"] /// The only content-bearing attributes; everything else is generated or cosmetic. private static let keptAttrs = ["href", "src"] /// HTML void elements never emit a close event. private static let void: Set = [ "br", "hr", "img", "input", "meta", "link", "col", "area", "base", "source", "wbr", ] /// Reduce an HTML fragment to one line per element open, element close, or text run. public static func skeleton(_ html: String) -> [String] { var out: [String] = [] let chars = Array(html) var i = 0 var text = "" while i < chars.count { if chars[i] != "<" { text.append(chars[i]) i += 1 continue } // Comments and doctypes carry nothing. if i + 1 < chars.count, chars[i + 1] == "!" { if let end = findFrom(chars, i, ">") { i = end + 1 } else { break } continue } guard let end = findFrom(chars, i, ">") else { break } let rawFull = String(chars[(i + 1)..") } continue } let (name, rest) = splitFirstWhitespace(raw) let lname = name.lowercased() if lname.isEmpty || ignored.contains(lname) { continue } let attrs = keptAttributes(rest) flushText(&text, &out) out.append("<\(lname)\(attrs)>") } flushText(&text, &out) return out } private static func flushText(_ text: inout String, _ out: inout [String]) { let decoded = decodeEntities(text) let collapsed = decoded.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") if !collapsed.isEmpty { out.append(debugQuote(collapsed)) } text = "" } /// Reproduce Rust's `format!("{:?}", s)` for whitespace-collapsed text: wrap in double /// quotes and escape `\` and `"`. No control characters survive whitespace collapse, /// so no further escaping is needed to match the goldens. private static func debugQuote(_ s: String) -> String { var escaped = "" escaped.reserveCapacity(s.count + 2) for c in s { if c == "\\" || c == "\"" { escaped.append("\\") } escaped.append(c) } return "\"\(escaped)\"" } private static func findFrom(_ chars: [Character], _ from: Int, _ needle: Character) -> Int? { var k = from while k < chars.count { if chars[k] == needle { return k } k += 1 } return nil } private static func splitFirstWhitespace(_ s: String) -> (String, String) { guard let idx = s.firstIndex(where: { $0.isWhitespace }) else { return (s, "") } let name = String(s[s.startIndex.. String { var result = "" for attr in keptAttrs { if let value = attributeValue(rest, attr) { result += " \(attr)=\"\(decodeEntities(value))\"" } } return result } private static func attributeValue(_ rest: String, _ name: String) -> String? { let chars = Array(rest) let nameChars = Array(name) var search = 0 while let pos = indexOf(chars, nameChars, from: search) { let beforeOK = pos == 0 || chars[pos - 1].isWhitespace var after = pos + nameChars.count while after < chars.count && chars[after].isWhitespace { after += 1 } if beforeOK && after < chars.count && chars[after] == "=" { var value = after + 1 while value < chars.count && chars[value].isWhitespace { value += 1 } guard value < chars.count else { return nil } let quote = chars[value] if quote == "\"" || quote == "'" { var end = value + 1 while end < chars.count && chars[end] != quote { end += 1 } guard end < chars.count else { return nil } return String(chars[(value + 1).. Int? { if needle.isEmpty { return from } var k = from while k + needle.count <= haystack.count { if Array(haystack[k..<(k + needle.count)]) == needle { return k } k += 1 } return nil } /// Decode the entities either exporter is likely to emit. static func decodeEntities(_ s: String) -> String { var out = "" out.reserveCapacity(s.count) let chars = Array(s) var i = 0 while i < chars.count { guard chars[i] == "&" else { out.append(chars[i]); i += 1; continue } // find ';' within 12 chars var semi: Int? = nil var k = i + 1 while k < chars.count && k - i <= 12 { if chars[k] == ";" { semi = k; break } k += 1 } guard let semiIdx = semi else { out.append("&"); i += 1; continue } let entity = String(chars[(i + 1).. Character? { switch entity { case "amp": return "&" case "lt": return "<" case "gt": return ">" case "quot": return "\"" case "apos": return "'" case "nbsp": return "\u{a0}" default: guard entity.hasPrefix("#") else { return nil } let num = String(entity.dropFirst()) let scalar: UInt32? if num.hasPrefix("x") || num.hasPrefix("X") { scalar = UInt32(num.dropFirst(), radix: 16) } else { scalar = UInt32(num) } guard let s = scalar, let u = Unicode.Scalar(s) else { return nil } return Character(u) } } }