textplus/Sources/HTMLScanner.swift
monster 09972ccde1 TextPlus: native macOS plaintext editor with sync, scraping, and a freeform board
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>
2026-06-14 16:08:59 +10:00

740 lines
30 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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