/| / runs in raw page source; inspector copies are balanced).
private static let siblingClosingTags: Set = ["li", "tr", "td", "th", "p", "dt", "dd", "option"]
// Inline tags the sibling-close rule may look "through" — an unclosed
// between two s must not defeat the implicit close. Bounded so
// legitimately nested lists (li inside ul inside li) stay nested.
private static let inlineTags: Set = [
"a", "abbr", "b", "code", "em", "font", "i", "label", "mark",
"s", "small", "span", "strong", "sub", "sup", "time", "u",
]
/// Walks the markup once. openTag receives the tag name, all attributes
/// (lowercased names, entity-decoded values, first occurrence wins), and
/// whether the element can't have children (self-closed or void).
private static func tokenize(_ html: String,
openTag: (String, [String: String], Bool) -> Void,
closeTag: (String) -> Void,
text: (String) -> Void) {
let bytes = Array(html.utf8)
let n = bytes.count
let lt = UInt8(ascii: "<"), gt = UInt8(ascii: ">")
let slash = UInt8(ascii: "/"), bang = UInt8(ascii: "!")
let eq = UInt8(ascii: "="), dash = UInt8(ascii: "-")
let dq = UInt8(ascii: "\""), sq = UInt8(ascii: "'")
func isSpace(_ b: UInt8) -> Bool { b == 0x20 || b == 0x09 || b == 0x0A || b == 0x0D || b == 0x0C }
func isAlpha(_ b: UInt8) -> Bool { (b | 0x20) >= 0x61 && (b | 0x20) <= 0x7A }
func isNameByte(_ b: UInt8) -> Bool {
isAlpha(b) || (b >= 0x30 && b <= 0x39) || b == dash || b == UInt8(ascii: "_") || b == UInt8(ascii: ":")
}
var i = 0
var textStart = 0
var openStack: [String] = []
func flushText(upTo end: Int) {
if end > textStart {
text(String(decoding: bytes[textStart.. and close
// (their terminator overlaps the opening dashes).
var j = i + 2
while j + 2 < n && !(bytes[j] == dash && bytes[j + 1] == dash && bytes[j + 2] == gt) { j += 1 }
i = min(n, j + 3)
} else {
var j = i + 2
while j < n && bytes[j] != gt { j += 1 }
i = min(n, j + 1)
}
textStart = i
} else if next == slash {
flushText(upTo: i)
var j = i + 2
var name = ""
while j < n, isNameByte(bytes[j]) { name.append(Character(UnicodeScalar(bytes[j]))); j += 1 }
while j < n && bytes[j] != gt { j += 1 }
i = min(n, j + 1)
textStart = i
if !name.isEmpty { emitClose(name.lowercased()) }
} else if isAlpha(next) {
flushText(upTo: i)
var j = i + 1
var name = ""
while j < n, isNameByte(bytes[j]) { name.append(Character(UnicodeScalar(bytes[j]))); j += 1 }
let tag = name.lowercased()
var attrs: [String: String] = [:]
var selfClosed = false
attrs: while j < n {
while j < n, isSpace(bytes[j]) { j += 1 }
if j >= n { break }
if bytes[j] == gt { j += 1; break }
if bytes[j] == slash {
if j + 1 < n, bytes[j + 1] == gt { selfClosed = true; j += 2; break }
j += 1
continue
}
var attrName = ""
while j < n, !isSpace(bytes[j]), bytes[j] != eq, bytes[j] != gt, bytes[j] != slash {
attrName.append(Character(UnicodeScalar(bytes[j])))
j += 1
}
var value = ""
while j < n, isSpace(bytes[j]) { j += 1 }
if j < n, bytes[j] == eq {
j += 1
while j < n, isSpace(bytes[j]) { j += 1 }
if j < n, bytes[j] == dq || bytes[j] == sq {
let quote = bytes[j]
j += 1
let start = j
while j < n, bytes[j] != quote { j += 1 }
value = String(decoding: bytes[start..= n { break attrs }
}
i = j
textStart = i
if siblingClosingTags.contains(tag) {
var idx = openStack.count - 1
while idx >= 0, inlineTags.contains(openStack[idx]) { idx -= 1 }
if idx >= 0, openStack[idx] == tag { emitClose(tag) }
}
let childless = selfClosed || voidTags.contains(tag)
openTag(tag, attrs, childless)
if !childless && !rawTextTags.contains(tag) {
openStack.append(tag)
}
if !childless && rawTextTags.contains(tag) {
// Skip raw content (scripts, styles) up to the real closing tag.
let needle = Array("\(tag)".utf8)
var k = i
search: while k + needle.count <= n {
var m = 0
while m < needle.count {
if (bytes[k + m] | 0x20) != needle[m] { k += 1; continue search }
m += 1
}
// ": the spec requires
// '>', '/', or whitespace after the tag name.
let after = k + needle.count
if after < n {
let b = bytes[after]
if !(b == gt || b == slash || isSpace(b)) { k += 1; continue search }
}
break
}
var j2 = min(k, n)
while j2 < n && bytes[j2] != gt { j2 += 1 }
i = min(n, j2 + 1)
textStart = i
closeTag(tag)
}
} else {
i += 1 // stray '<' in text
}
}
flushText(upTo: n)
}
// MARK: - Text helpers
private static func normalizedID(_ attrs: [String: String]) -> String? {
guard let id = attrs["id"]?.trimmingCharacters(in: .whitespaces), !id.isEmpty else { return nil }
return id
}
private static func splitClasses(_ value: String?) -> [String] {
guard let value else { return [] }
return value.split(whereSeparator: { $0.isWhitespace }).map(String.init)
}
private static func selectorString(tag: String, id: String?, classes: [String]) -> String {
var s = tag
if let id, !id.isEmpty { s += "#\(id)" }
for c in classes { s += ".\(c)" }
return s
}
private static func collapseWhitespace(_ s: String) -> String {
s.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ")
}
private static func decodeEntities(_ s: String) -> String {
guard s.contains("&") else { return s }
var result = ""
result.reserveCapacity(s.count)
var i = s.startIndex
while i < s.endIndex {
if s[i] == "&" {
var j = s.index(after: i)
var entity = ""
var terminated = false
var steps = 0
while j < s.endIndex, steps < 12 {
if s[j] == ";" { terminated = true; break }
entity.append(s[j])
j = s.index(after: j)
steps += 1
}
if terminated, let decoded = decodeEntity(entity) {
result.append(decoded)
i = s.index(after: j)
continue
}
}
result.append(s[i])
i = s.index(after: i)
}
return result
}
private static func decodeEntity(_ e: String) -> Character? {
switch e {
case "amp": return "&"
case "lt": return "<"
case "gt": return ">"
case "quot": return "\""
case "apos": return "'"
case "nbsp": return " "
case "ndash": return "–"
case "mdash": return "—"
case "hellip": return "…"
default:
if e.hasPrefix("#x") || e.hasPrefix("#X"),
let v = UInt32(e.dropFirst(2), radix: 16), let u = UnicodeScalar(v) {
return Character(u)
}
if e.hasPrefix("#"), let v = UInt32(e.dropFirst()), let u = UnicodeScalar(v) {
return Character(u)
}
return nil
}
}
}
|