extension PureXML.HTML { /// A lenient HTML tokenizer for tag-soup input: case-insensitive tag names, /// quoted/unquoted/boolean attributes, comments, doctype, and raw-text /// elements (`script`, `style`, `textarea`, `title`) whose content is taken /// verbatim. Malformed markup degrades to text rather than failing. struct Tokenizer { private let chars: [Character] private var index = 0 private var rawTextName: String? init(_ html: String) { chars = Array(html) } mutating func tokenize() -> [Token] { var tokens: [Token] = [] while let token = next() { tokens.append(token) } return tokens } private mutating func next() -> Token? { guard !isAtEnd else { return nil } if let raw = rawTextName { rawTextName = nil return rawText(until: raw) } guard peek() == "<" else { return text() } if matches("") { value.append(chars[index]) index += 1 } _ = consume("-->") return .comment(value) } private mutating func doctype() -> Token { index += 2 var value = "" while let character = peek(), character != ">" { value.append(character) advance() } consume(">") return .doctype(value) } private mutating func rawText(until name: String) -> Token { let target = Array(" String { var name = "" while let character = peek(), character.isLetter || character.isNumber || character == "-" { name.append(character) advance() } return name } private mutating func readAttributeName() -> String { var name = "" while let character = peek(), Self.isAttributeNameCharacter(character) { name.append(character) advance() } return name } private static func isAttributeNameCharacter(_ character: Character) -> Bool { !character.isWhitespace && character != "=" && character != ">" && character != "/" } private var isAtEnd: Bool { index >= chars.count } private func peek(_ ahead: Int = 0) -> Character? { let target = index + ahead return target < chars.count ? chars[target] : nil } private func matches(_ literal: String) -> Bool { let target = Array(literal) guard index + target.count <= chars.count else { return false } return Array(chars[index ..< index + target.count]) == target } private func matchesCaseInsensitive(_ target: [Character]) -> Bool { guard index + target.count <= chars.count else { return false } for (offset, character) in target.enumerated() where !Self.sameLetter(character, chars[index + offset]) { return false } return true } private static func sameLetter(_ lhs: Character, _ rhs: Character) -> Bool { lhs.lowercased() == rhs.lowercased() } @discardableResult private mutating func advance() -> Character? { guard !isAtEnd else { return nil } defer { index += 1 } return chars[index] } @discardableResult private mutating func consume(_ literal: String) -> Bool { guard matches(literal) else { return false } index += literal.count return true } private mutating func skipSpace() { while let character = peek(), character.isWhitespace { advance() } } private mutating func skipUntil(_ character: Character) { while let current = peek(), current != character { advance() } advance() } } }