A no-Xcode AppKit app (swiftc + build.sh): - Plaintext editor: 5 themes, line numbers, soft wrap, font controls, find, live Markdown preview (split mode), selection-aware Markdown toolbar. - Freeform Board mode: zoomable/pannable canvas of page cards (portrait/landscape, US Letter/A4/Legal/Square/Wide/Note), clean boxes, images (paste/drop/panel), freehand pen/shapes, screen-grab eyedropper, nested right-click menu, rich status bar. The Editor follows the selected page; layout persists to a foo.txt.board sidecar; the .txt stays pure plaintext. - scp-on-save to a Tailscale Mac (key auth, in-flight/quit drain). - HTML scraping pipeline: custom selector engine + Discogs profile, ingests to Postgres (psql) into a marketplace_snapshots schema. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
740 lines
30 KiB
Swift
740 lines
30 KiB
Swift
import Foundation
|
||
|
||
/// Lightweight streaming HTML tokenizer + selector tools for inspector-copied
|
||
/// markup. Not a spec HTML parser — built for "paste <body> from devtools,
|
||
/// find the repeating rows, pull their data" scraping workflows.
|
||
///
|
||
/// Selector syntax: `tag`, `#id`, `.class`, compounds (`tr.shortcut_navigable`),
|
||
/// attribute filters (`[data-id]`, `[!class]`, `[rel=tag]`, `[href^=/seller/]`),
|
||
/// space-separated descendant chains, and a trailing `@attr` to extract an
|
||
/// attribute value from the matched element.
|
||
enum HTMLScanner {
|
||
|
||
// MARK: - Public: selector summary
|
||
|
||
struct SummaryData {
|
||
let entries: [(selector: String, count: Int, sample: String)]
|
||
let elementCount: Int
|
||
}
|
||
|
||
static func summarize(html: String) -> SummaryData {
|
||
var counts: [String: Int] = [:]
|
||
var samples: [String: String] = [:]
|
||
|
||
struct Entry {
|
||
let tag: String
|
||
let key: String?
|
||
var sampling: Bool
|
||
var buf: String
|
||
}
|
||
var stack: [Entry] = []
|
||
var totalElements = 0
|
||
|
||
func finalize(_ entry: Entry) {
|
||
guard let key = entry.key, entry.sampling else { return }
|
||
if samples[key, default: ""].isEmpty {
|
||
samples[key] = String(entry.buf.prefix(90))
|
||
}
|
||
}
|
||
|
||
tokenize(html, openTag: { tag, attrs, closed in
|
||
let id = normalizedID(attrs)
|
||
let classes = splitClasses(attrs["class"])
|
||
var key: String?
|
||
if id != nil || !classes.isEmpty {
|
||
key = selectorString(tag: tag, id: id, classes: classes)
|
||
counts[key!, default: 0] += 1
|
||
totalElements += 1
|
||
}
|
||
if closed { return }
|
||
// Keep sampling until a NON-empty sample lands, so a first
|
||
// occurrence that wraps only an <img> doesn't lose the sample.
|
||
let sampling = key.map { samples[$0, default: ""].isEmpty } ?? false
|
||
stack.append(Entry(tag: tag, key: key, sampling: sampling, buf: ""))
|
||
}, closeTag: { tag in
|
||
guard let matchIdx = stack.lastIndex(where: { $0.tag == tag }) else { return }
|
||
for entry in stack[matchIdx...].reversed() { finalize(entry) }
|
||
stack.removeSubrange(matchIdx...)
|
||
}, text: { raw in
|
||
let collapsed = collapseWhitespace(decodeEntities(raw))
|
||
guard !collapsed.isEmpty else { return }
|
||
for i in stack.indices where stack[i].sampling && stack[i].buf.count < 90 {
|
||
stack[i].buf += (stack[i].buf.isEmpty ? "" : " ") + collapsed
|
||
}
|
||
})
|
||
for entry in stack.reversed() { finalize(entry) }
|
||
|
||
let sorted = counts.sorted { $0.value != $1.value ? $0.value > $1.value : $0.key < $1.key }
|
||
return SummaryData(
|
||
entries: sorted.map { (selector: $0.key, count: $0.value, sample: samples[$0.key] ?? "") },
|
||
elementCount: totalElements
|
||
)
|
||
}
|
||
|
||
static func formatSummary(_ data: SummaryData, sourceName: String) -> String {
|
||
var out = "Selector summary of \(sourceName)\n"
|
||
out += "\(data.elementCount) elements with an id or class, \(data.entries.count) unique selectors.\n"
|
||
out += "Repeating rows (your data) sort to the top. Next: Tools → Extract Text for Selector…\n\n"
|
||
for entry in data.entries {
|
||
out += String(format: "%5d× %@\n", entry.count, entry.selector)
|
||
if !entry.sample.isEmpty {
|
||
out += " ↳ \(entry.sample)\n"
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// MARK: - Public: extract text for a selector
|
||
|
||
struct ExtractResult {
|
||
let matchCount: Int
|
||
let body: String
|
||
}
|
||
|
||
/// Returns nil if the selector string can't be parsed. A trailing `@attr`
|
||
/// prepends that attribute's value as a tab-separated first column.
|
||
static func extractText(html: String, selector: String, sourceName: String) -> ExtractResult? {
|
||
guard let compounds = parseSelector(selector) else { return nil }
|
||
let extractAttr = compounds.last?.extractAttr
|
||
|
||
struct Entry {
|
||
let tag: String
|
||
let id: String?
|
||
let classes: Set<String>
|
||
let attrs: [String: String]
|
||
var isCaptureRoot: Bool
|
||
}
|
||
var stack: [Entry] = []
|
||
var capturing = false
|
||
var buf = ""
|
||
var rootAttrValue = ""
|
||
var records: [String] = []
|
||
|
||
func selectorMatches() -> Bool {
|
||
matchChain(compounds, stack: stack.map { ($0.tag, $0.id, $0.classes, $0.attrs) }, scopeStart: 0)
|
||
}
|
||
|
||
func attrValue(_ attrs: [String: String]) -> String {
|
||
extractAttr.flatMap { attrs[$0] } ?? ""
|
||
}
|
||
|
||
func finishRecord() {
|
||
var record = buf.trimmingCharacters(in: CharacterSet(charactersIn: " \t"))
|
||
if extractAttr != nil {
|
||
record = rootAttrValue + "\t" + record
|
||
}
|
||
records.append(record)
|
||
buf = ""
|
||
rootAttrValue = ""
|
||
capturing = false
|
||
}
|
||
|
||
tokenize(html, openTag: { tag, attrs, closed in
|
||
let entry = Entry(tag: tag, id: normalizedID(attrs),
|
||
classes: Set(splitClasses(attrs["class"])),
|
||
attrs: attrs, isCaptureRoot: false)
|
||
if closed {
|
||
if !capturing {
|
||
stack.append(entry)
|
||
if selectorMatches() {
|
||
records.append(extractAttr != nil ? attrValue(attrs) + "\t" : "")
|
||
}
|
||
stack.removeLast()
|
||
}
|
||
return
|
||
}
|
||
stack.append(entry)
|
||
if selectorMatches() {
|
||
if capturing {
|
||
// A new match opening mid-capture means the previous row's
|
||
// close tag was missing (raw source) or rows nest — either
|
||
// way, finish the old record so rows don't silently merge.
|
||
finishRecord()
|
||
if let oldRoot = stack.firstIndex(where: { $0.isCaptureRoot }) {
|
||
stack[oldRoot].isCaptureRoot = false
|
||
}
|
||
}
|
||
stack[stack.count - 1].isCaptureRoot = true
|
||
capturing = true
|
||
buf = ""
|
||
rootAttrValue = attrValue(attrs)
|
||
}
|
||
}, closeTag: { tag in
|
||
guard let matchIdx = stack.lastIndex(where: { $0.tag == tag }) else { return }
|
||
let popped = stack[matchIdx...]
|
||
stack.removeSubrange(matchIdx...)
|
||
if popped.contains(where: { $0.isCaptureRoot }) {
|
||
finishRecord()
|
||
} else if capturing, tag == "td" || tag == "th",
|
||
!buf.isEmpty, !buf.hasSuffix("\t") {
|
||
buf += "\t" // cell boundary → tab, so table rows come out TSV-ish
|
||
}
|
||
}, text: { raw in
|
||
guard capturing else { return }
|
||
let collapsed = collapseWhitespace(decodeEntities(raw))
|
||
guard !collapsed.isEmpty else { return }
|
||
if !buf.isEmpty && !buf.hasSuffix("\t") { buf += " " }
|
||
buf += collapsed
|
||
})
|
||
if capturing { finishRecord() } // unclosed capture at EOF
|
||
|
||
var out = "# \(records.count) match\(records.count == 1 ? "" : "es") for `\(selector)` in \(sourceName)\n"
|
||
out += records.joined(separator: "\n")
|
||
if !records.isEmpty { out += "\n" }
|
||
return ExtractResult(matchCount: records.count, body: out)
|
||
}
|
||
|
||
// MARK: - Public: structured row extraction (scrape profiles)
|
||
|
||
struct RowExtraction {
|
||
let header: [String]
|
||
let rows: [[String]]
|
||
}
|
||
|
||
/// For each element matching rowSelector, capture named fields by selector
|
||
/// scoped to that row's subtree (the row element itself included, so
|
||
/// "tr@data-release-id" reads an attribute off the row). First match per
|
||
/// field wins. Returns nil if any selector fails to parse.
|
||
static func extractRows(html: String, rowSelector: String,
|
||
fields: [(name: String, selector: String)]) -> RowExtraction? {
|
||
guard let rowCompounds = parseSelector(rowSelector) else { return nil }
|
||
struct FieldSpec {
|
||
let name: String
|
||
let compounds: [Compound]
|
||
let attr: String?
|
||
}
|
||
var specs: [FieldSpec] = []
|
||
for field in fields {
|
||
guard let comps = parseSelector(field.selector) else { return nil }
|
||
specs.append(FieldSpec(name: field.name, compounds: comps, attr: comps.last?.extractAttr))
|
||
}
|
||
|
||
typealias StackEl = (tag: String, id: String?, classes: Set<String>, attrs: [String: String])
|
||
enum FieldState {
|
||
case pending
|
||
case capturing(rootIndex: Int, buf: String)
|
||
case done(String)
|
||
}
|
||
var stack: [StackEl] = []
|
||
var inRow = false
|
||
var rowRootIndex = -1
|
||
var fieldStates: [FieldState] = []
|
||
var rows: [[String]] = []
|
||
|
||
func startRow() {
|
||
inRow = true
|
||
rowRootIndex = stack.count - 1
|
||
fieldStates = Array(repeating: .pending, count: specs.count)
|
||
tryMatchFields(closedAttrs: nil)
|
||
}
|
||
|
||
func finishRow() {
|
||
let columns = fieldStates.map { state -> String in
|
||
switch state {
|
||
case .pending: return ""
|
||
case .capturing(_, let buf): return buf.trimmingCharacters(in: .whitespaces)
|
||
case .done(let value): return value.trimmingCharacters(in: .whitespaces)
|
||
}
|
||
}
|
||
rows.append(columns)
|
||
inRow = false
|
||
rowRootIndex = -1
|
||
fieldStates = []
|
||
}
|
||
|
||
// closedAttrs non-nil = the just-seen element is void/self-closed and
|
||
// sits on top of the stack only transiently.
|
||
func tryMatchFields(closedAttrs: [String: String]?) {
|
||
guard inRow else { return }
|
||
for i in specs.indices {
|
||
guard case .pending = fieldStates[i] else { continue }
|
||
guard matchChain(specs[i].compounds, stack: stack, scopeStart: rowRootIndex) else { continue }
|
||
if let attr = specs[i].attr {
|
||
fieldStates[i] = .done(stack[stack.count - 1].attrs[attr] ?? "")
|
||
} else if closedAttrs != nil {
|
||
fieldStates[i] = .done("") // void element has no text
|
||
} else {
|
||
fieldStates[i] = .capturing(rootIndex: stack.count - 1, buf: "")
|
||
}
|
||
}
|
||
}
|
||
|
||
tokenize(html, openTag: { tag, attrs, closed in
|
||
let el: StackEl = (tag, normalizedID(attrs), Set(splitClasses(attrs["class"])), attrs)
|
||
if closed {
|
||
stack.append(el)
|
||
if inRow { tryMatchFields(closedAttrs: attrs) }
|
||
stack.removeLast()
|
||
return
|
||
}
|
||
stack.append(el)
|
||
if matchChain(rowCompounds, stack: stack, scopeStart: 0) {
|
||
if inRow { finishRow() } // unclosed previous row
|
||
startRow()
|
||
} else if inRow {
|
||
tryMatchFields(closedAttrs: nil)
|
||
}
|
||
}, closeTag: { tag in
|
||
guard let matchIdx = stack.lastIndex(where: { $0.tag == tag }) else { return }
|
||
for i in fieldStates.indices {
|
||
if case .capturing(let root, let buf) = fieldStates[i], root >= matchIdx {
|
||
fieldStates[i] = .done(buf)
|
||
}
|
||
}
|
||
if inRow && rowRootIndex >= matchIdx { finishRow() }
|
||
stack.removeSubrange(matchIdx...)
|
||
}, text: { raw in
|
||
guard inRow else { return }
|
||
let collapsed = collapseWhitespace(decodeEntities(raw))
|
||
guard !collapsed.isEmpty else { return }
|
||
for i in fieldStates.indices {
|
||
if case .capturing(let root, var buf) = fieldStates[i] {
|
||
if !buf.isEmpty { buf += " " }
|
||
buf += collapsed
|
||
fieldStates[i] = .capturing(rootIndex: root, buf: buf)
|
||
}
|
||
}
|
||
})
|
||
if inRow { finishRow() }
|
||
|
||
return RowExtraction(header: specs.map { $0.name }, rows: rows)
|
||
}
|
||
|
||
/// True if the selector parses (for validating user/profile input).
|
||
static func isValidSelector(_ selector: String) -> Bool {
|
||
parseSelector(selector) != nil
|
||
}
|
||
|
||
// MARK: - Selector parsing & matching
|
||
|
||
private static func parseSelector(_ selector: String) -> [Compound]? {
|
||
let parts = selector.split(separator: " ").map(String.init)
|
||
guard !parts.isEmpty else { return nil }
|
||
var compounds: [Compound] = []
|
||
for part in parts {
|
||
guard let c = Compound(token: part) else { return nil }
|
||
compounds.append(c)
|
||
}
|
||
// @attr extraction is only meaningful on the final compound
|
||
for c in compounds.dropLast() where c.extractAttr != nil { return nil }
|
||
return compounds
|
||
}
|
||
|
||
/// Match the last compound against the top of the stack and earlier
|
||
/// compounds against ancestors in order, never looking below scopeStart.
|
||
private static func matchChain(_ compounds: [Compound],
|
||
stack: [(tag: String, id: String?, classes: Set<String>, attrs: [String: String])],
|
||
scopeStart: Int) -> Bool {
|
||
guard let last = stack.last,
|
||
compounds[compounds.count - 1].matches(tag: last.tag, id: last.id, classes: last.classes, attrs: last.attrs)
|
||
else { return false }
|
||
var ci = compounds.count - 2
|
||
var si = stack.count - 2
|
||
while ci >= 0 {
|
||
while si >= scopeStart,
|
||
!compounds[ci].matches(tag: stack[si].tag, id: stack[si].id, classes: stack[si].classes, attrs: stack[si].attrs) {
|
||
si -= 1
|
||
}
|
||
if si < scopeStart { return false }
|
||
ci -= 1
|
||
si -= 1
|
||
}
|
||
return true
|
||
}
|
||
|
||
private struct AttrFilter {
|
||
enum Op { case present, absent, equals, prefix }
|
||
let name: String
|
||
let op: Op
|
||
let value: String
|
||
|
||
init?(_ content: String) {
|
||
let s = content.trimmingCharacters(in: .whitespaces)
|
||
guard !s.isEmpty else { return nil }
|
||
if s.hasPrefix("!") {
|
||
let n = String(s.dropFirst()).trimmingCharacters(in: .whitespaces).lowercased()
|
||
guard !n.isEmpty else { return nil }
|
||
name = n; op = .absent; value = ""
|
||
} else if let r = s.range(of: "^=") {
|
||
let n = String(s[..<r.lowerBound]).trimmingCharacters(in: .whitespaces).lowercased()
|
||
guard !n.isEmpty else { return nil }
|
||
name = n; op = .prefix; value = AttrFilter.stripQuotes(String(s[r.upperBound...]))
|
||
} else if let eq = s.firstIndex(of: "=") {
|
||
let n = String(s[..<eq]).trimmingCharacters(in: .whitespaces).lowercased()
|
||
guard !n.isEmpty else { return nil }
|
||
name = n; op = .equals; value = AttrFilter.stripQuotes(String(s[s.index(after: eq)...]))
|
||
} else {
|
||
name = s.lowercased(); op = .present; value = ""
|
||
}
|
||
}
|
||
|
||
static func stripQuotes(_ v: String) -> String {
|
||
let t = v.trimmingCharacters(in: .whitespaces)
|
||
if t.count >= 2,
|
||
(t.hasPrefix("\"") && t.hasSuffix("\"")) || (t.hasPrefix("'") && t.hasSuffix("'")) {
|
||
return String(t.dropFirst().dropLast())
|
||
}
|
||
return t
|
||
}
|
||
|
||
func matches(_ attrs: [String: String]) -> Bool {
|
||
switch op {
|
||
case .present: return attrs[name] != nil
|
||
case .absent: return attrs[name] == nil
|
||
case .equals: return attrs[name] == value
|
||
case .prefix: return attrs[name]?.hasPrefix(value) ?? false
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct Compound {
|
||
var tag: String?
|
||
var id: String?
|
||
var classes: [String] = []
|
||
var filters: [AttrFilter] = []
|
||
var extractAttr: String?
|
||
|
||
init?(token: String) {
|
||
var mode: Character = "t"
|
||
var current = ""
|
||
|
||
func commit() -> Bool {
|
||
guard !current.isEmpty else { return false }
|
||
switch mode {
|
||
case "t":
|
||
guard tag == nil else { return false } // "div[x]y" — two tags
|
||
let t = current.lowercased()
|
||
// Reject combinators/pseudo junk (">", "+", "td:nth-child(2)")
|
||
// so the "Bad Selector" path fires instead of 0 matches.
|
||
// ':' stays legal (namespaced tags); ids/classes aren't
|
||
// validated — real class values contain odd characters.
|
||
guard t == "*" || t.allSatisfy({
|
||
$0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == ":"
|
||
}) else { return false }
|
||
tag = t
|
||
case "#": id = current
|
||
default: classes.append(current)
|
||
}
|
||
current = ""
|
||
return true
|
||
}
|
||
|
||
let chars = Array(token)
|
||
var i = 0
|
||
while i < chars.count {
|
||
let ch = chars[i]
|
||
if ch == "[" {
|
||
if !current.isEmpty {
|
||
guard commit() else { return nil }
|
||
} else if mode != "t" {
|
||
return nil // ".[x]"
|
||
}
|
||
mode = "t"
|
||
guard let close = chars[i...].firstIndex(of: "]") else { return nil }
|
||
guard let filter = AttrFilter(String(chars[(i + 1)..<close])) else { return nil }
|
||
filters.append(filter)
|
||
i = close + 1
|
||
continue
|
||
}
|
||
if ch == "@" {
|
||
if !current.isEmpty {
|
||
guard commit() else { return nil }
|
||
} else if mode != "t" {
|
||
return nil // "div.@href"
|
||
}
|
||
mode = "t"
|
||
let rest = String(chars[(i + 1)...]).lowercased()
|
||
guard !rest.isEmpty, !rest.contains("["), !rest.contains("]"), !rest.contains("@") else { return nil }
|
||
extractAttr = rest
|
||
i = chars.count
|
||
continue
|
||
}
|
||
if ch == "." || ch == "#" {
|
||
// A '.' glued to a digit is part of the name (Tailwind
|
||
// decimal classes like p-2.5), not a class separator.
|
||
if ch == ".", !current.isEmpty, i + 1 < chars.count, chars[i + 1].isNumber {
|
||
current.append(ch)
|
||
i += 1
|
||
continue
|
||
}
|
||
if !current.isEmpty {
|
||
guard commit() else { return nil }
|
||
} else if mode != "t" {
|
||
return nil // "..", ".#"
|
||
}
|
||
mode = ch
|
||
i += 1
|
||
continue
|
||
}
|
||
current.append(ch)
|
||
i += 1
|
||
}
|
||
if !current.isEmpty {
|
||
guard commit() else { return nil }
|
||
} else if mode != "t" {
|
||
return nil // trailing "." or "#"
|
||
}
|
||
if tag == "*" { tag = nil }
|
||
if tag == nil && id == nil && classes.isEmpty && filters.isEmpty && extractAttr == nil { return nil }
|
||
}
|
||
|
||
func matches(tag t: String, id i: String?, classes cs: Set<String>, attrs: [String: String]) -> Bool {
|
||
if let tag, tag != t { return false }
|
||
if let id, id != i { return false }
|
||
for c in classes where !cs.contains(c) { return false }
|
||
for f in filters where !f.matches(attrs) { return false }
|
||
return true
|
||
}
|
||
}
|
||
|
||
// MARK: - Tokenizer
|
||
|
||
private static let voidTags: Set<String> = [
|
||
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
||
"link", "meta", "param", "source", "track", "wbr",
|
||
]
|
||
private static let rawTextTags: Set<String> = ["script", "style", "textarea", "title"]
|
||
// Tags that implicitly close a same-tag sibling (covers unclosed
|
||
// <li>/<td>/<p> runs in raw page source; inspector copies are balanced).
|
||
private static let siblingClosingTags: Set<String> = ["li", "tr", "td", "th", "p", "dt", "dd", "option"]
|
||
// Inline tags the sibling-close rule may look "through" — an unclosed
|
||
// <b> between two <li>s must not defeat the implicit close. Bounded so
|
||
// legitimately nested lists (li inside ul inside li) stay nested.
|
||
private static let inlineTags: Set<String> = [
|
||
"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..<end], as: UTF8.self))
|
||
}
|
||
}
|
||
|
||
func emitClose(_ tag: String) {
|
||
if let idx = openStack.lastIndex(of: tag) {
|
||
openStack.removeSubrange(idx...)
|
||
}
|
||
closeTag(tag)
|
||
}
|
||
|
||
while i < n {
|
||
guard bytes[i] == lt, i + 1 < n else { i += 1; continue }
|
||
let next = bytes[i + 1]
|
||
|
||
if next == bang {
|
||
flushText(upTo: i)
|
||
if i + 3 < n, bytes[i + 2] == dash, bytes[i + 3] == dash {
|
||
// Scan from i+2 so the empty comments <!--> 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..<j], as: UTF8.self)
|
||
if j < n { j += 1 }
|
||
} else {
|
||
let start = j
|
||
while j < n, !isSpace(bytes[j]), bytes[j] != gt { j += 1 }
|
||
value = String(decoding: bytes[start..<j], as: UTF8.self)
|
||
}
|
||
}
|
||
if !attrName.isEmpty {
|
||
let key = attrName.lowercased()
|
||
if attrs[key] == nil { attrs[key] = decodeEntities(value) }
|
||
}
|
||
if j >= 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
|
||
}
|
||
// "</scripty" must not end a <script>: 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
|
||
}
|
||
}
|
||
}
|