// 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)
}
}
}