about summary refs log tree commit diff stats
path: root/src/html
diff options
context:
space:
mode:
authorbptato <nincsnevem662@gmail.com>2022-12-19 21:03:15 +0100
committerbptato <nincsnevem662@gmail.com>2022-12-19 21:03:15 +0100
commitea9df035a294bf1cfa715c140d0d22aa018e262e (patch)
tree9f1ec79e96003494666970c16f905c0b0c9608ff /src/html
parentdad0c1c04b6d4f67da407f69cec98221d178c194 (diff)
downloadchawan-ea9df035a294bf1cfa715c140d0d22aa018e262e.tar.gz
More DOM work
Diffstat (limited to 'src/html')
-rw-r--r--src/html/dom.nim777
-rw-r--r--src/html/env.nim2
-rw-r--r--src/html/htmlparser.nim15
-rw-r--r--src/html/tags.nim4
4 files changed, 548 insertions, 250 deletions
diff --git a/src/html/dom.nim b/src/html/dom.nim
index 4dfe5c3a..ed96173d 100644
--- a/src/html/dom.nim
+++ b/src/html/dom.nim
@@ -81,9 +81,13 @@ type
     jsrt*: JSRuntime
     jsctx*: JSContext
     document* {.jsget.}: Document
-    console* {.jsget.}: Console
+    console* {.jsget.}: console
 
-  Console* = ref object
+  # "For historical reasons, console is lowercased."
+  # Also, for a more practical reason: so the javascript macros don't confuse
+  # this and the Client console.
+  # TODO: merge those two
+  console* = ref object
     err: Stream
 
   NamedNodeMap = ref object
@@ -95,15 +99,35 @@ type
 
   EventTarget* = ref object of RootObj
 
+  #TODO this has caching, but invalidation is pretty expensive... not sure if
+  # it's worth the trouble at all...
+  Collection = ref CollectionObj
+  CollectionObj = object of RootObj
+    islive: bool
+    invalid: bool
+    childonly: bool
+    root: Node
+    match: proc(node: Node): bool {.noSideEffect.}
+    snapshot: seq[Node]
+    livelen: int
+
+  NodeList = ref object of Collection
+
+  HTMLCollection = ref object of Collection
+
   Node* = ref object of EventTarget
     nodeType* {.jsget.}: NodeType
-    childNodes* {.jsget.}: seq[Node]
-    nextSibling*: Node
-    previousSibling*: Node
+    childList*: seq[Node]
+    nextSibling* {.jsget.}: Node
+    previousSibling* {.jsget.}: Node
     parentNode* {.jsget.}: Node
     parentElement* {.jsget.}: Element
     root: Node
     document*: Document
+    # Live collection cache: if parentHasCollections is true, recursively
+    # invalidate all of them on insert.
+    parentHasCollections: bool
+    liveCollections: seq[Collection]
 
   Attr* = ref object of Node
     namespace: Namespace
@@ -156,8 +180,8 @@ type
     localName*: string
     tagType*: TagType
 
-    id*: string
-    classList*: seq[string]
+    id* {.jsget.}: string
+    classList* {.jsget.}: seq[string] #TODO should be DomTokenList
     attrs*: Table[string, string]
     attributes* {.jsget.}: NamedNodeMap
     hover*: bool
@@ -174,7 +198,6 @@ type
     autofocus*: bool
     required*: bool
     value* {.jsget.}: string
-    size*: int
     checked*: bool
     xcoord*: int
     ycoord*: int
@@ -184,7 +207,6 @@ type
 
   HTMLSelectElement* = ref object of FormAssociatedElement
     form* {.jsget.}: HTMLFormElement
-    size*: int
 
   HTMLSpanElement* = ref object of HTMLElement
 
@@ -253,10 +275,17 @@ type
 
   HTMLTextAreaElement* = ref object of FormAssociatedElement
     form* {.jsget.}: HTMLFormElement
-    rows*: int
-    cols*: int
     value* {.jsget.}: string
 
+proc `=destroy`(collection: var CollectionObj) =
+  var i = -1
+  for j in 0 ..< collection.root.liveCollections.len:
+    if cast[pointer](collection.root.liveCollections[j]) == addr collection:
+      i = j
+      break
+  assert i != -1
+  collection.root.liveCollections.del(i)
+
 const NamespaceMap = (func(): Table[string, Namespace] =
   for ns in Namespace:
     result[$ns] = ns
@@ -290,7 +319,7 @@ func escapeText(s: string, attribute_mode = false): string =
       result &= c
 
 func `$`*(node: Node): string =
-  if node == nil: return "nil" #TODO this isn't standard compliant but helps debugging
+  if node == nil: return "null" #TODO this isn't standard compliant but helps debugging
   case node.nodeType
   of ELEMENT_NODE:
     let element = Element(node)
@@ -298,7 +327,7 @@ func `$`*(node: Node): string =
     for k, v in element.attrs:
       result &= ' ' & k & "=\"" & v.escapeText(true) & "\""
     result &= ">\n"
-    for node in element.childNodes:
+    for node in element.childList:
       for line in ($node).split('\n'):
         result &= "\t" & line & "\n"
     result &= "</" & $element.tagType.tostr() & ">"
@@ -314,22 +343,17 @@ func `$`*(node: Node): string =
   else:
     result = "Node of " & $node.nodeType
 
-iterator children*(node: Node): Element {.inline.} =
-  for child in node.childNodes:
+iterator elementList*(node: Node): Element {.inline.} =
+  for child in node.childList:
     if child.nodeType == ELEMENT_NODE:
       yield Element(child)
 
-iterator children_rev*(node: Node): Element {.inline.} =
-  for i in countdown(node.childNodes.high, 0):
-    let child = node.childNodes[i]
+iterator elementList_rev*(node: Node): Element {.inline.} =
+  for i in countdown(node.childList.high, 0):
+    let child = node.childList[i]
     if child.nodeType == ELEMENT_NODE:
       yield Element(child)
 
-#TODO TODO TODO this should return a live view instead
-proc children*(node: Node): seq[Element] {.jsfget.} =
-  for child in node.children:
-    result.add(child)
-
 # Returns the node's ancestors
 iterator ancestors*(node: Node): Element {.inline.} =
   var element = node.parentElement
@@ -350,9 +374,9 @@ iterator descendants*(node: Node): Node {.inline.} =
   stack.add(node)
   while stack.len > 0:
     let node = stack.pop()
-    for i in countdown(node.childNodes.high, 0):
-      yield node.childNodes[i]
-      stack.add(node.childNodes[i])
+    for i in countdown(node.childList.high, 0):
+      yield node.childList[i]
+      stack.add(node.childList[i])
 
 iterator elements*(node: Node): Element {.inline.} =
   for child in node.descendants:
@@ -389,16 +413,16 @@ iterator radiogroup*(input: HTMLInputElement): HTMLInputElement {.inline.} =
       yield input
 
 iterator textNodes*(node: Node): Text {.inline.} =
-  for node in node.childNodes:
+  for node in node.childList:
     if node.nodeType == TEXT_NODE:
       yield Text(node)
   
 iterator options*(select: HTMLSelectElement): HTMLOptionElement {.inline.} =
-  for child in select.children:
+  for child in select.elementList:
     if child.tagType == TAG_OPTION:
       yield HTMLOptionElement(child)
     elif child.tagType == TAG_OPTGROUP:
-      for opt in child.children:
+      for opt in child.elementList:
         if opt.tagType == TAG_OPTION:
           yield HTMLOptionElement(child)
 
@@ -406,6 +430,75 @@ iterator items(attributes: NamedNodeMap): Attr {.inline.} =
   for attr in attributes.attrlist:
     yield attr
 
+proc populateCollection(collection: Collection) =
+  if collection.childonly:
+    for child in collection.root.childList:
+      if collection.match == nil or collection.match(child):
+        collection.snapshot.add(child)
+  else:
+    for desc in collection.root.descendants:
+      if collection.match == nil or collection.match(desc):
+        collection.snapshot.add(desc)
+
+proc refreshCollection(collection: Collection) =
+  if collection.invalid:
+    collection.snapshot.setLen(0)
+    collection.populateCollection()
+    collection.invalid = false
+
+func ownerDocument(node: Node): Document {.jsfget.} =
+  if node.nodeType == DOCUMENT_NODE:
+    return nil
+  return node.document
+
+func hasChildNodes(node: Node): bool {.jsfget.} =
+  return node.childList.len > 0
+
+func len(collection: Collection): int =
+  collection.refreshCollection()
+  return collection.snapshot.len
+
+func newCollection[T: Collection](root: Node, match: proc(node: Node): bool {.noSideEffect.}, islive: bool): T =
+  result = T(
+    islive: islive,
+    match: match,
+    root: root
+  )
+  result.populateCollection()
+  if islive:
+    root.liveCollections.add(result)
+    for desc in root.descendants:
+      desc.parentHasCollections = true
+
+func isElement(node: Node): bool =
+  return node.nodeType == ELEMENT_NODE
+
+func children*(node: Node): HTMLCollection {.jsfget.} =
+  return newCollection[HTMLCollection](node, isElement, true)
+
+func childNodes(node: Node): NodeList {.jsfget.} =
+  return newCollection[NodeList](node, nil, true)
+
+func length(nodeList: NodeList): int {.jsfget.} =
+  return nodeList.len
+
+func hasprop(nodeList: NodeList, i: int): bool {.jshasprop.} =
+  return i < nodeList.len
+
+func getter(nodeList: NodeList, i: int): Option[Node] {.jsgetprop.} =
+  if i < nodeList.len:
+    return some(nodeList.snapshot[i])
+
+func length(collection: HTMLCollection): int {.jsfget.} =
+  return collection.len
+
+func hasprop(collection: HTMLCollection, i: int): bool {.jshasprop.} =
+  return i < collection.len
+
+func getter(collection: HTMLCollection, i: int): Option[Element] {.jsgetprop.} =
+  if i < collection.len:
+    return some(Element(collection.snapshot[i]))
+
 func newAttr(parent: Element, localName, value: string, prefix = "", namespace = NO_NAMESPACE): Attr =
   return Attr(
     nodeType: ATTRIBUTE_NODE,
@@ -569,12 +662,12 @@ func select*(option: HTMLOptionElement): HTMLSelectElement =
   return nil
 
 func countChildren(node: Node, nodeType: NodeType): int =
-  for child in node.childNodes:
+  for child in node.childList:
     if child.nodeType == nodeType:
       inc result
 
 func hasChild(node: Node, nodeType: NodeType): bool =
-  for child in node.childNodes:
+  for child in node.childList:
     if child.nodeType == nodeType:
       return false
 
@@ -592,37 +685,40 @@ func hasPreviousSibling(node: Node, nodeType: NodeType): bool =
     node = node.previousSibling
   return false
 
+func nodeValue(node: Node): Option[string] {.jsfget.} =
+  case node.nodeType
+  of CharacterDataNodes:
+    return some(CharacterData(node).data)
+  of ATTRIBUTE_NODE:
+    return some(Attr(node).value)
+  else: discard
+
+func textContent*(node: Node): string {.jsfget.} =
+  case node.nodeType
+  of DOCUMENT_NODE, DOCUMENT_TYPE_NODE:
+    return "" #TODO null
+  of CharacterDataNodes:
+    return CharacterData(node).data
+  else:
+    for child in node.childList:
+      if child.nodeType != COMMENT_NODE:
+        result &= child.textContent
+
+func childTextContent*(node: Node): string =
+  for child in node.childList:
+    if child.nodeType == TEXT_NODE:
+      result &= Text(child).data
+
 func rootNode*(node: Node): Node =
   if node.root == nil: return node
   return node.root
 
-func connected*(node: Node): bool =
+func isConnected*(node: Node): bool {.jsfget.} =
   return node.rootNode.nodeType == DOCUMENT_NODE #TODO shadow root
 
 func inSameTree*(a, b: Node): bool =
   a.rootNode == b.rootNode
 
-func filterDescendants*(element: Element, predicate: (proc(child: Element): bool)): seq[Element] =
-  var stack: seq[Element]
-  for child in element.children_rev:
-    stack.add(child)
-  while stack.len > 0:
-    let child = stack.pop()
-    if predicate(child):
-      result.add(child)
-    for child in element.children_rev:
-      stack.add(child)
-
-func all_descendants*(element: Element): seq[Element] =
-  var stack: seq[Element]
-  for child in element.children_rev:
-    stack.add(child)
-  while stack.len > 0:
-    let child = stack.pop()
-    result.add(child)
-    for child in element.children_rev:
-      stack.add(child)
-
 # a == b or b in a's ancestors
 func contains*(a, b: Node): bool =
   for node in a.branch:
@@ -630,41 +726,108 @@ func contains*(a, b: Node): bool =
   return false
 
 func firstChild*(node: Node): Node {.jsfget.} =
-  if node.childNodes.len == 0:
+  if node.childList.len == 0:
     return nil
-  return node.childNodes[0]
+  return node.childList[0]
 
 func lastChild*(node: Node): Node {.jsfget.} =
-  if node.childNodes.len == 0:
+  if node.childList.len == 0:
     return nil
-  return node.childNodes[^1]
+  return node.childList[^1]
 
 func firstElementChild*(node: Node): Element {.jsfget.} =
-  for child in node.children:
+  for child in node.elementList:
     return child
   return nil
 
 func lastElementChild*(node: Node): Element {.jsfget.} =
-  for child in node.children:
+  for child in node.elementList:
     return child
   return nil
 
+func findAncestor*(node: Node, tagTypes: set[TagType]): Element =
+  for element in node.ancestors:
+    if element.tagType in tagTypes:
+      return element
+  return nil
+
+func getElementById*(node: Node, id: string): Element {.jsfunc.} =
+  if id.len == 0:
+    return nil
+  for child in node.elements:
+    if child.id == id:
+      return child
+
+func getElementsByTag*(node: Node, tag: TagType): seq[Element] =
+  for element in node.elements(tag):
+    result.add(element)
+
+func getElementsByTagName(node: Node, tagName: string): HTMLCollection {.jsfunc.} =
+  if tagName == "*":
+    return newCollection[HTMLCollection](node, func(node: Node): bool = node.isElement, true)
+  let t = tagType(tagName)
+  if t != TAG_UNKNOWN:
+    return newCollection[HTMLCollection](node, func(node: Node): bool = node.isElement and Element(node).tagType == t, true)
+
+func getElementsByClassName(node: Node, classNames: string): HTMLCollection {.jsfunc.} =
+  var classes = classNames.split(AsciiWhitespace)
+  let isquirks = node.document.mode == QUIRKS
+  if isquirks:
+    for i in 0 .. classes.high:
+      classes[i].toLowerAsciiInPlace()
+  return newCollection[HTMLCollection](node,
+    func(node: Node): bool =
+      if node.nodeType == ELEMENT_NODE:
+        if isquirks:
+          var cl = Element(node).classList
+          for i in 0 .. cl.high:
+            cl[i].toLowerAsciiInPlace()
+          for class in classes:
+            if class notin cl:
+              return false
+        else:
+          for class in classes:
+            if class notin Element(node).classList:
+              return false
+        return true, true)
+
+func filterDescendants*(element: Element, predicate: (proc(child: Element): bool)): seq[Element] =
+  var stack: seq[Element]
+  for child in element.elementList_rev:
+    stack.add(child)
+  while stack.len > 0:
+    let child = stack.pop()
+    if predicate(child):
+      result.add(child)
+    for child in element.elementList_rev:
+      stack.add(child)
+
+func all_descendants*(element: Element): seq[Element] =
+  var stack: seq[Element]
+  for child in element.elementList_rev:
+    stack.add(child)
+  while stack.len > 0:
+    let child = stack.pop()
+    result.add(child)
+    for child in element.elementList_rev:
+      stack.add(child)
+
 func previousElementSibling*(elem: Element): Element {.jsfget.} =
   if elem.parentNode == nil: return nil
-  var i = elem.parentNode.childNodes.find(elem)
+  var i = elem.parentNode.childList.find(elem)
   dec i
   while i >= 0:
-    if elem.parentNode.childNodes[i].nodeType == ELEMENT_NODE:
+    if elem.parentNode.childList[i].nodeType == ELEMENT_NODE:
       return elem
     dec i
   return nil
 
 func nextElementSibling*(elem: Element): Element {.jsfget.} =
   if elem.parentNode == nil: return nil
-  var i = elem.parentNode.childNodes.find(elem)
+  var i = elem.parentNode.childList.find(elem)
   inc i
-  while i < elem.parentNode.childNodes.len:
-    if elem.parentNode.childNodes[i].nodeType == ELEMENT_NODE:
+  while i < elem.parentNode.childList.len:
+    if elem.parentNode.childList[i].nodeType == ELEMENT_NODE:
       return elem
     inc i
   return nil
@@ -682,34 +845,44 @@ func attri*(element: Element, s: string): Option[int] =
   except ValueError:
     return none(int)
 
+func attrigz*(element: Element, s: string): Option[int] =
+  let a = element.attr(s)
+  try:
+    let i = parseInt(a)
+    if i > 0:
+      return some(i)
+  except ValueError:
+    discard
+
 func attrb*(element: Element, s: string): bool =
   if s in element.attrs:
     return true
   return false
 
+# Element attribute reflection (getters)
+func className(element: Element): string {.jsfget.} =
+  element.attr("class")
+
+#TODO implement JS union types for ref object...
+func size*(element: HTMLInputElement): int {.jsfget.} =
+  element.attrigz("size").get(20)
+
+func size*(element: HTMLSelectElement): int {.jsfget.} =
+  element.attrigz("size").get(20)
+
+func cols*(element: HTMLTextAreaElement): int {.jsfget.} =
+  element.attrigz("cols").get(20)
+
+func rows*(element: HTMLTextAreaElement): int {.jsfget.} =
+  element.attrigz("rows").get(1)
+
 func innerHTML*(element: Element): string {.jsfget.} =
-  for child in element.childNodes:
+  for child in element.childList:
     result &= $child
 
 func outerHTML*(element: Element): string {.jsfget.} =
   return $element
 
-func textContent*(node: Node): string {.jsfget.} =
-  case node.nodeType
-  of DOCUMENT_NODE, DOCUMENT_TYPE_NODE:
-    return "" #TODO null
-  of CDATA_SECTION_NODE, COMMENT_NODE, PROCESSING_INSTRUCTION_NODE, TEXT_NODE:
-    return CharacterData(node).data
-  else:
-    for child in node.childNodes:
-      if child.nodeType != COMMENT_NODE:
-        result &= child.textContent
-
-func childTextContent*(node: Node): string =
-  for child in node.childNodes:
-    if child.nodeType == TEXT_NODE:
-      result &= Text(child).data
-
 func crossorigin(element: HTMLScriptElement): CORSAttribute =
   if not element.attrb("crossorigin"):
     return NO_CORS
@@ -724,7 +897,7 @@ func referrerpolicy(element: HTMLScriptElement): Option[ReferrerPolicy] =
   getReferrerPolicy(element.attr("referrerpolicy"))
 
 proc sheets*(element: Element): seq[CSSStylesheet] =
-  for child in element.children:
+  for child in element.elementList:
     if child.tagType == TAG_STYLE:
       let child = HTMLStyleElement(child)
       if child.sheet_invalid:
@@ -845,39 +1018,29 @@ func target*(element: Element): string {.jsfunc.} =
       return base.attr("target")
   return ""
 
-func findAncestor*(node: Node, tagTypes: set[TagType]): Element =
-  for element in node.ancestors:
-    if element.tagType in tagTypes:
-      return element
-  return nil
-
 func newText*(document: Document, data: string = ""): Text {.jsctor.} =
   new(result)
   result.nodeType = TEXT_NODE
   result.document = document
   result.data = data
 
-func textContent*(node: Node, data: string) {.jsfset.} =
-  node.childNodes.setLen(0)
-  node.childNodes.add(node.document.newText(data))
-
 func newComment*(document: Document = nil, data: string = ""): Comment {.jsctor.} =
   new(result)
   result.nodeType = COMMENT_NODE
   result.document = document
   result.data = data
 
+proc attr*(element: Element, name, value: string)
+
 #TODO custom elements
-func newHTMLElement*(document: Document, tagType: TagType, namespace = Namespace.HTML, prefix = none[string]()): HTMLElement =
+func newHTMLElement*(document: Document, tagType: TagType, namespace = Namespace.HTML, prefix = none[string](), attrs = Table[string, string]()): HTMLElement =
   case tagType
   of TAG_INPUT:
     result = new(HTMLInputElement)
-    HTMLInputElement(result).size = 20
   of TAG_A:
     result = new(HTMLAnchorElement)
   of TAG_SELECT:
     result = new(HTMLSelectElement)
-    HTMLSelectElement(result).size = 1
   of TAG_OPTGROUP:
     result = new(HTMLOptGroupElement)
   of TAG_OPTION:
@@ -919,16 +1082,20 @@ func newHTMLElement*(document: Document, tagType: TagType, namespace = Namespace
     result = new(HTMLTextAreaElement)
   else:
     result = new(HTMLElement)
-
   result.nodeType = ELEMENT_NODE
   result.tagType = tagType
   result.namespace = namespace
   result.namespacePrefix = prefix
   result.document = document
   result.attributes = NamedNodeMap(element: result)
-
-func newHTMLElement*(document: Document, localName: string, namespace = Namespace.HTML, prefix = none[string](), tagType = tagType(localName)): Element =
-  result = document.newHTMLElement(tagType, namespace, prefix)
+  {.cast(noSideEffect).}:
+    for k, v in attrs:
+      result.attr(k, v)
+  if tagType == TAG_SCRIPT:
+    HTMLScriptElement(result).internalNonce = result.attr("nonce")
+
+func newHTMLElement*(document: Document, localName: string, namespace = Namespace.HTML, prefix = none[string](), tagType = tagType(localName), attrs = Table[string, string]()): Element =
+  result = document.newHTMLElement(tagType, namespace, prefix, attrs)
   if tagType == TAG_UNKNOWN:
     result.localName = localName
 
@@ -940,32 +1107,12 @@ func newDocument*(): Document {.jsctor.} =
 
 func newDocumentType*(document: Document, name: string, publicId = "", systemId = ""): DocumentType {.jsctor.} =
   new(result)
+  result.nodeType = DOCUMENT_TYPE_NODE
   result.document = document
   result.name = name
   result.publicId = publicId
   result.systemId = systemId
 
-func getElementById*(node: Node, id: string): Element {.jsfunc.} =
-  if id.len == 0:
-    return nil
-  for child in node.elements:
-    if child.id == id:
-      return child
-
-func getElementsByTag*(node: Node, tag: TagType): seq[Element] =
-  for element in node.elements(tag):
-    result.add(element)
-
-func getElementsByTagName(node: Node, tagName: string): seq[Element] {.jsfunc.} =
-  let tagName = tagType(tagName)
-  if tagName != TAG_UNKNOWN:
-    return node.getElementsByTag(tagName)
-
-func getElementsByClassName(node: Node, class: string): seq[Element] {.jsfunc.} =
-  for element in node.elements:
-    if class in element.classList:
-      result.add(element)
-
 func inHTMLNamespace*(element: Element): bool = element.namespace == Namespace.HTML
 func inMathMLNamespace*(element: Element): bool = element.namespace == Namespace.MATHML
 func inSVGNamespace*(element: Element): bool = element.namespace == Namespace.SVG
@@ -1040,59 +1187,136 @@ func value*(option: HTMLOptionElement): string {.jsfget.} =
     return option.attr("value")
   return option.childTextContent.stripAndCollapse()
 
-# WARNING the ordering of the arguments in the standard is whack so this doesn't match that
-func preInsertionValidity*(parent, node, before: Node): bool =
-  if parent.nodeType notin {DOCUMENT_NODE, DOCUMENT_FRAGMENT_NODE, ELEMENT_NODE}:
-    # HierarchyRequestError
-    return false
-  if node.isHostIncludingInclusiveAncestor(parent):
-    # HierarchyRequestError
-    return false
-  if before != nil and before.parentNode != parent:
-    # NotFoundError
-    return false
-  if node.nodeType notin {DOCUMENT_FRAGMENT_NODE, DOCUMENT_TYPE_NODE, ELEMENT_NODE, CDATA_SECTION_NODE}:
-    # HierarchyRequestError
-    return false
-  if (node.nodeType == TEXT_NODE and parent.nodeType == DOCUMENT_NODE) or
-      (node.nodeType == DOCUMENT_TYPE_NODE and parent.nodeType != DOCUMENT_NODE):
-    # HierarchyRequestError
-    return false
-  if parent.nodeType == DOCUMENT_NODE:
-    case node.nodeType
-    of DOCUMENT_FRAGMENT_NODE:
-      let elems = node.countChildren(ELEMENT_NODE)
-      if elems > 1 or node.hasChild(TEXT_NODE):
-        # HierarchyRequestError
-        return false
-      elif elems == 1 and (parent.hasChild(ELEMENT_NODE) or before != nil and (before.nodeType == DOCUMENT_TYPE_NODE or before.hasNextSibling(DOCUMENT_TYPE_NODE))):
-        # HierarchyRequestError
-        return false
-    of ELEMENT_NODE:
-      if parent.hasChild(ELEMENT_NODE) or before != nil and (before.nodeType == DOCUMENT_TYPE_NODE or before.hasNextSibling(DOCUMENT_TYPE_NODE)):
-        # HierarchyRequestError
-        return false
-    of DOCUMENT_TYPE_NODE:
-      if parent.hasChild(DOCUMENT_TYPE_NODE) or before != nil and before.hasPreviousSibling(ELEMENT_NODE) or before == nil and parent.hasChild(ELEMENT_NODE):
-        # HierarchyRequestError
-        return false
-    else: discard
-  return true # no exception reached
+proc invalidateCollections(node: Node): bool {.discardable.} =
+  for collection in node.liveCollections:
+    collection.invalid = true
+  if node.parentHasCollections:
+    if not node.parentNode.invalidateCollections():
+      node.parentHasCollections = false
+  return node.liveCollections.len != 0 or node.parentHasCollections
+
+proc delAttr(element: Element, i: int) =
+  if i != -1:
+    let attr = element.attributes.attrlist[i]
+    element.attrs.del(attr.name)
+    element.attributes.attrlist.delete(i)
+    element.invalidateCollections()
+    element.invalid = true
 
 proc delAttr(element: Element, name: string) =
   let i = element.attributes.findAttr(name)
   if i != -1:
     element.attributes.attrlist.delete(i)
+    element.invalidateCollections()
+    element.invalid = true
+
+proc reflectAttrs(element: Element, name, value: string) =
+  template reflect_str(element: Element, n: static string, val: untyped) =
+    if name == n:
+      element.val = value
+      return
+  template reflect_str(element: Element, n: static string, val, fun: untyped) =
+    if name == n:
+      element.val = fun(value)
+  template reflect_bool(element: Element, name: static string, val: untyped) =
+    if name in element.attrs:
+      element.val = true
+  element.reflect_str "id", id
+  if name == "class":
+    element.classList.setLen(0)
+    let classList = value.split(AsciiWhitespace)
+    for x in classList:
+      if x != "" and x notin element.classList:
+        element.classList.add(x)
+    return
+  case element.tagType
+  of TAG_INPUT:
+    let input = HTMLInputElement(element)
+    input.reflect_str "value", value
+    input.reflect_str "type", inputType, inputType
+    input.reflect_bool "checked", checked
+  of TAG_OPTION:
+    let option = HTMLOptionElement(element)
+    option.reflect_bool "selected", selected
+  of TAG_BUTTON:
+    let button = HTMLButtonElement(element)
+    button.reflect_str "type", ctype, (func(s: string): ButtonType =
+      case s
+      of "submit": return BUTTON_SUBMIT
+      of "reset": return BUTTON_RESET
+      of "button": return BUTTON_BUTTON)
+  else: discard
 
-proc attr(element: Element, name, value: string) =
-  if name in element.attrs:
-    element.delAttr(name)
-  element.attrs[name] = value
-  element.attributes.attrlist.add(element.newAttr(name, value))
+proc attr0(element: Element, name, value: string) =
+  element.attrs.withValue(name, val):
+    val[] = value
+    element.invalidateCollections()
+    element.invalid = true
+  do: # else
+    element.attrs[name] = value
+  element.reflectAttrs(name, value)
+
+proc attr*(element: Element, name, value: string) =
+  let i = element.attributes.findAttr(name)
+  if i != -1:
+    element.attributes.attrlist[i].value = value
+  else:
+    element.attributes.attrlist.add(element.newAttr(name, value))
+  element.attr0(name, value)
+
+proc attrigz(element: Element, name: string, value: int) =
+  if value > 0:
+    element.attr(name, $value)
 
 proc setAttribute(element: Element, qualifiedName, value: string) {.jsfunc.} =
   element.attr(qualifiedName, value)
 
+proc setAttributeNS(element: Element, namespace, qualifiedName, value: string) {.jsfunc.} =
+  if namespace == "" or namespace == $Namespace.HTML:
+    element.attr(qualifiedName, value)
+  if namespace notin NamespaceMap:
+    return
+  #TODO validate and extract
+  element.attr0(qualifiedName, value)
+  let ns = NamespaceMap[namespace]
+  let i = element.attributes.findAttr(qualifiedName)
+  if i == -1:
+    let s = qualifiedName.until(':')
+    if s.len < qualifiedName.len:
+      element.attributes.attrlist.add(element.newAttr(qualifiedName.substr(s.len), value, s, ns))
+    else:
+      element.attributes.attrlist.add(element.newAttr(qualifiedName, value, "", ns))
+  else:
+    element.attributes.attrlist[i].value = value
+
+proc removeAttribute(element: Element, qualifiedName: string) {.jsfunc.} =
+  element.delAttr(qualifiedName)
+
+proc removeAttributeNS(element: Element, namespace, localName: string) {.jsfunc.} =
+  #TODO use namespace
+  element.delAttr(localName)
+
+proc value(attr: Attr, s: string) {.jsfset.} =
+  attr.value = s
+  if attr.ownerElement != nil:
+    attr.ownerElement.attr0(attr.name, s)
+
+# Element attribute reflection (setters)
+proc className(element: Element, s: string) {.jsfset.} =
+  element.attr("class", s)
+
+proc size(element: HTMLInputElement, n: int) {.jsfset.} =
+  element.attrigz("size", n)
+
+proc size(element: HTMLSelectElement, n: int) {.jsfset.} =
+  element.attrigz("size", n)
+
+proc cols(element: HTMLTextAreaElement, n: int) {.jsfset.} =
+  element.attrigz("cols", n)
+
+proc rows(element: HTMLTextAreaElement, n: int) {.jsfset.} =
+  element.attrigz("rows", n)
+
 proc setNamedItem*(map: NamedNodeMap, attr: Attr): Option[Attr] {.jserr, jsfunc.} =
   if attr.ownerElement != nil and attr.ownerElement != map.element:
     #TODO should be DOMException
@@ -1109,20 +1333,39 @@ proc setNamedItem*(map: NamedNodeMap, attr: Attr): Option[Attr] {.jserr, jsfunc.
 proc setNamedItemNS*(map: NamedNodeMap, attr: Attr): Option[Attr] {.jsfunc.} =
   map.setNamedItem(attr)
 
-proc remove*(node: Node) =
+proc removeNamedItem*(map: NamedNodeMap, qualifiedName: string): Attr {.jserr, jsfunc.} =
+  let i = map.findAttr(qualifiedName)
+  if i != -1:
+    let attr = map.attrlist[i]
+    map.element.delAttr(i)
+    return attr
+  #TODO should be DOMException
+  JS_ERR JS_TypeError, "Not found"
+
+proc removeNamedItemNS*(map: NamedNodeMap, namespace, localName: string): Attr =
+  #TODO TODO TODO
+  map.removeNamedItem(localName)
+
+proc id(element: Element, id: string) {.jsfset.} =
+  element.id = id
+  element.attr("id", id)
+
+# Pass an index to avoid searching for it.
+proc remove*(node: Node, index: int, suppressObservers: bool) =
   let parent = node.parentNode
   assert parent != nil
-  let index = parent.childNodes.find(node)
   assert index != -1
   #TODO live ranges
   #TODO NodeIterator
   let oldPreviousSibling = node.previousSibling
   let oldNextSibling = node.nextSibling
-  parent.childNodes.delete(index)
+  parent.childList.delete(index)
   if oldPreviousSibling != nil:
     oldPreviousSibling.nextSibling = oldNextSibling
   if oldNextSibling != nil:
     oldNextSibling.previousSibling = oldPreviousSibling
+  discard node.parentNode.invalidateCollections()
+  node.parentHasCollections = false
   node.parentNode = nil
   node.parentElement = nil
   node.root = nil
@@ -1130,6 +1373,10 @@ proc remove*(node: Node) =
   #TODO assigned, shadow root, shadow root again, custom nodes, registered observers
   #TODO not suppress observers => queue tree mutation record
 
+proc remove*(node: Node, suppressObservers = false) =
+  let index = node.parentNode.childList.find(node)
+  node.remove(index, suppressObservers)
+
 proc adopt(document: Document, node: Node) =
   if node.parentNode != nil:
     remove(node)
@@ -1141,11 +1388,14 @@ proc applyChildInsert(parent, child: Node, index: int) =
   if parent.nodeType == ELEMENT_NODE:
     child.parentElement = Element(parent)
   if index - 1 >= 0:
-    child.previousSibling = parent.childNodes[index - 1]
+    child.previousSibling = parent.childList[index - 1]
     child.previousSibling.nextSibling = child
-  if index + 1 < parent.childNodes.len:
-    child.nextSibling = parent.childNodes[index + 1]
+  if index + 1 < parent.childList.len:
+    child.nextSibling = parent.childList[index + 1]
     child.nextSibling.previousSibling = child
+  child.invalidateCollections()
+  child.parentHasCollections = parent.liveCollections.len > 0 or parent.parentHasCollections
+  child.invalidateCollections()
 
 proc resetElement*(element: Element) = 
   case element.tagType
@@ -1217,7 +1467,7 @@ proc resetFormOwner(element: FormAssociatedElement) =
       element.findAncestor({TAG_FORM}) == element.form:
     return
   element.form = nil
-  if element.tagType in ListedElements and element.attrb("form") and element.connected:
+  if element.tagType in ListedElements and element.attrb("form") and element.isConnected:
     let form = element.attr("form")
     for desc in element.elements(TAG_FORM):
       if desc.id == form:
@@ -1245,114 +1495,135 @@ proc insertionSteps(insertedNode: Node) =
         return
       element.resetFormOwner()
 
+# WARNING the ordering of the arguments in the standard is whack so this doesn't match that
+func preInsertionValidity*(parent, node, before: Node): bool =
+  if parent.nodeType notin {DOCUMENT_NODE, DOCUMENT_FRAGMENT_NODE, ELEMENT_NODE}:
+    # HierarchyRequestError
+    return false
+  if node.isHostIncludingInclusiveAncestor(parent):
+    # HierarchyRequestError
+    return false
+  if before != nil and before.parentNode != parent:
+    # NotFoundError
+    return false
+  if node.nodeType notin {DOCUMENT_FRAGMENT_NODE, DOCUMENT_TYPE_NODE, ELEMENT_NODE} + CharacterDataNodes:
+    # HierarchyRequestError
+    return false
+  if (node.nodeType == TEXT_NODE and parent.nodeType == DOCUMENT_NODE) or
+      (node.nodeType == DOCUMENT_TYPE_NODE and parent.nodeType != DOCUMENT_NODE):
+    # HierarchyRequestError
+    return false
+  if parent.nodeType == DOCUMENT_NODE:
+    case node.nodeType
+    of DOCUMENT_FRAGMENT_NODE:
+      let elems = node.countChildren(ELEMENT_NODE)
+      if elems > 1 or node.hasChild(TEXT_NODE):
+        # HierarchyRequestError
+        return false
+      elif elems == 1 and (parent.hasChild(ELEMENT_NODE) or before != nil and (before.nodeType == DOCUMENT_TYPE_NODE or before.hasNextSibling(DOCUMENT_TYPE_NODE))):
+        # HierarchyRequestError
+        return false
+    of ELEMENT_NODE:
+      if parent.hasChild(ELEMENT_NODE) or before != nil and (before.nodeType == DOCUMENT_TYPE_NODE or before.hasNextSibling(DOCUMENT_TYPE_NODE)):
+        # HierarchyRequestError
+        return false
+    of DOCUMENT_TYPE_NODE:
+      if parent.hasChild(DOCUMENT_TYPE_NODE) or before != nil and before.hasPreviousSibling(ELEMENT_NODE) or before == nil and parent.hasChild(ELEMENT_NODE):
+        # HierarchyRequestError
+        return false
+    else: discard
+  return true # no exception reached
+
 # WARNING ditto
 proc insert*(parent, node, before: Node) =
-  let nodes = if node.nodeType == DOCUMENT_FRAGMENT_NODE: node.childNodes
+  let nodes = if node.nodeType == DOCUMENT_FRAGMENT_NODE: node.childList
   else: @[node]
   let count = nodes.len
   if count == 0:
     return
   if node.nodeType == DOCUMENT_FRAGMENT_NODE:
-    for child in node.childNodes:
-      child.remove()
+    for i in countdown(node.childList.high, 0):
+      node.childList[i].remove(i, true)
     #TODO tree mutation record
   if before != nil:
     #TODO live ranges
     discard
-  #let previousSibling = if before == nil: parent.lastChild
-  #else: before.previousSibling
   for node in nodes:
     parent.document.adopt(node)
     if before == nil:
-      parent.childNodes.add(node)
-      parent.applyChildInsert(node, parent.childNodes.high)
+      parent.childList.add(node)
+      parent.applyChildInsert(node, parent.childList.high)
     else:
-      let index = parent.childNodes.find(before)
-      parent.childNodes.insert(node, index)
+      let index = parent.childList.find(before)
+      parent.childList.insert(node, index)
       parent.applyChildInsert(node, index)
     if node.nodeType == ELEMENT_NODE:
       #TODO shadow root
       insertionSteps(node)
 
-# WARNING ditto
-proc preInsert*(parent, node, before: Node) =
+proc insertBefore(parent, node, before: Node): Node {.jserr, jsfunc.} =
   if parent.preInsertionValidity(node, before):
-    let referenceChild = if before == node: node.nextSibling
-    else: before
+    let referenceChild = if before == node:
+      node.nextSibling
+    else:
+      before
     parent.insert(node, referenceChild)
+    return node
+  #TODO use preInsertionValidity result
+  JS_ERR JS_TypeError, "Pre-insertion validity violated"
+
+proc appendChild(parent, node: Node): Node {.jsfunc.} =
+  return parent.insertBefore(node, nil)
 
 proc append*(parent, node: Node) =
-  parent.preInsert(node, nil)
+  discard parent.appendChild(node)
+
+#TODO replaceChild
+
+proc removeChild(parent, node: Node): Node {.jsfunc.} =
+  #TODO should be DOMException
+  if node.parentNode != parent:
+    JS_ERR JS_TypeError, "NotFoundError"
+  node.remove()
+
+proc replaceAll(parent, node: Node) =
+  for i in countdown(parent.childList.high, 0):
+    parent.childList[i].remove(i, true)
+  if node != nil:
+    if node.nodeType == DOCUMENT_FRAGMENT_NODE:
+      for child in node.childList:
+        parent.append(child)
+    else:
+      parent.append(node)
+  #TODO tree mutation record
+
+proc textContent*(node: Node, data: Option[string]) {.jsfset.} =
+  case node.nodeType
+  of DOCUMENT_FRAGMENT_NODE, ELEMENT_NODE:
+    let x = if data.isSome:
+      node.document.newText(data.get)
+    else:
+      nil
+    node.replaceAll(x)
+  of ATTRIBUTE_NODE:
+    value(Attr(node), data.get(""))
+  of TEXT_NODE, COMMENT_NODE:
+    CharacterData(node).data = data.get("")
+  else: discard
 
 proc reset*(form: HTMLFormElement) =
   for control in form.controls:
     control.resetElement()
     control.invalid = true
 
-proc appendAttributes*(element: Element, attrs: Table[string, string]) =
-  for k, v in attrs:
-    element.attr(k, v)
-  template reflect_str(element: Element, name: static string, val: untyped) =
-    element.attrs.withValue(name, val):
-      element.val = val[]
-  template reflect_str(element: Element, name: static string, val, fun: untyped) =
-    element.attrs.withValue(name, val):
-      element.val = fun(val[])
-  template reflect_nonzero_int(element: Element, name: static string, val: untyped, default: int) =
-    element.attrs.withValue(name, val):
-      if val[].isValidNonZeroInt():
-        element.val = parseInt(val[])
-      else:
-        element.val = default
-    do:
-      element.val = default
-  template reflect_bool(element: Element, name: static string, val: untyped) =
-    if name in element.attrs:
-      element.val = true
-  element.reflect_str "id", id
-  let classList = element.attr("class").split(' ')
-  for x in classList:
-    if x != "" and x notin element.classList:
-      element.classList.add(x)
-  case element.tagType
-  of TAG_INPUT:
-    let input = HTMLInputElement(element)
-    input.reflect_str "value", value
-    input.reflect_str "type", inputType, inputType
-    input.reflect_nonzero_int "size", size, 20
-    input.reflect_bool "checked", checked
-  of TAG_OPTION:
-    let option = HTMLOptionElement(element)
-    option.reflect_bool "selected", selected
-  of TAG_SELECT:
-    let select = HTMLSelectElement(element)
-    select.reflect_nonzero_int "size", size, (if "multiple" in element.attrs: 4 else: 1)
-  of TAG_BUTTON:
-    let button = HTMLButtonElement(element)
-    button.reflect_str "type", ctype, (func(s: string): ButtonType =
-      case s
-      of "submit": return BUTTON_SUBMIT
-      of "reset": return BUTTON_RESET
-      of "button": return BUTTON_BUTTON)
-  of TAG_TEXTAREA:
-    let textarea = HTMLTextAreaElement(element)
-    textarea.reflect_nonzero_int "cols", cols, 20
-    textarea.reflect_nonzero_int "rows", rows, 1
-  of TAG_SCRIPT:
-    let element = HTMLScriptElement(element)
-    element.reflect_str "nonce", internalNonce
-  else: discard
-
 proc renderBlocking*(element: Element): bool =
   if "render" in element.attr("blocking").split(AsciiWhitespace):
     return true
-  case element.tagType
-  of TAG_SCRIPT:
+  if element.tagType == TAG_SCRIPT:
     let element = HTMLScriptElement(element)
     if element.ctype == CLASSIC and element.parserDocument != nil and
         not element.attrb("async") and not element.attrb("defer"):
       return true
-  else: discard
   return false
 
 proc blockRendering*(element: Element) =
@@ -1398,6 +1669,19 @@ proc fetchClassicScript(element: HTMLScriptElement, url: URL,
         let script = createClassicScript(source, url, options, false)
         element.markAsReady(ScriptResult(t: RESULT_SCRIPT, script: script))
 
+#TODO TODO TODO do something with this (redirect stderr?)
+proc log*(console: console, ss: varargs[string]) {.jsfunc.} =
+  var s = ""
+  for i in 0..<ss.len:
+    s &= ss[i]
+    #console.err.write(ss[i])
+    if i != ss.high:
+      s &= ' '
+      #console.err.write(' ')
+  eprint s
+  #console.err.write('\n')
+  #console.err.flush()
+
 proc execute*(element: HTMLScriptElement) =
   let document = element.document
   if document != element.preparationTimeDocument:
@@ -1419,7 +1703,7 @@ proc execute*(element: HTMLScriptElement) =
         let ss = newStringStream()
         document.window.jsctx.writeException(ss)
         ss.setPosition(0)
-        eprint "Exception in document", document.url, ss.readAll()
+        document.window.console.log("Exception in document", $document.url, ss.readAll())
     document.currentScript = oldCurrentScript
   else: discard #TODO
 
@@ -1434,7 +1718,7 @@ proc prepare*(element: HTMLScriptElement) =
   let sourceText = element.childTextContent
   if not element.attrb("src") and sourceText == "":
     return
-  if not element.connected:
+  if not element.isConnected:
     return
   let typeString = if element.attr("type") != "":
     element.attr("type").strip(chars = AsciiWhitespace).toLowerAscii()
@@ -1557,9 +1841,15 @@ proc querySelectorAll*(node: Node, q: string): seq[Element] {.jsfunc.} =
 proc querySelector*(node: Node, q: string): Element {.jsfunc.} =
   return doqs(node, q)
 
+proc addconsoleModule*(ctx: JSContext) =
+  #TODO console should not have a prototype
+  ctx.registerType(console, nointerface = true)
+
 proc addDOMModule*(ctx: JSContext) =
   let eventTargetCID = ctx.registerType(EventTarget)
   let nodeCID = ctx.registerType(Node, parent = eventTargetCID)
+  ctx.registerType(NodeList)
+  ctx.registerType(HTMLCollection)
   ctx.registerType(Document, parent = nodeCID)
   let characterDataCID = ctx.registerType(CharacterData, parent = nodeCID)
   ctx.registerType(Comment, parent = characterDataCID)
@@ -1588,3 +1878,4 @@ proc addDOMModule*(ctx: JSContext) =
   ctx.registerType(HTMLUnknownElement, parent = htmlElementCID)
   ctx.registerType(HTMLScriptElement, parent = htmlElementCID)
   ctx.registerType(HTMLButtonElement, parent = htmlElementCID)
+  ctx.registerType(HTMLTextAreaElement, parent = htmlElementCID)
diff --git a/src/html/env.nim b/src/html/env.nim
index 6db018c9..29b1de09 100644
--- a/src/html/env.nim
+++ b/src/html/env.nim
@@ -6,6 +6,7 @@ import types/url
 
 proc newWindow*(scripting: bool, loader = none(FileLoader)): Window =
   result = Window(
+    console: console(),
     loader: loader,
     settings: EnvironmentSettings(
       scripting: scripting
@@ -21,6 +22,7 @@ proc newWindow*(scripting: bool, loader = none(FileLoader)): Window =
     ctx.setOpaque(global, result)
     ctx.setProperty(global, "window", global)
     JS_FreeValue(ctx, global)
+    ctx.addconsoleModule()
     ctx.addDOMModule()
     ctx.addURLModule()
     ctx.addHTMLModule()
diff --git a/src/html/htmlparser.nim b/src/html/htmlparser.nim
index 999625a8..f306d5f4 100644
--- a/src/html/htmlparser.nim
+++ b/src/html/htmlparser.nim
@@ -205,8 +205,7 @@ func createElement(parser: HTML5Parser, token: Token, namespace: Namespace, inte
   #TODO custom elements
   let document = intendedParent.document
   let localName = token.tagname
-  let element = document.newHTMLElement(localName, namespace, tagType = token.tagtype)
-  element.appendAttributes(token.attrs)
+  let element = document.newHTMLElement(localName, namespace, tagType = token.tagtype, attrs = token.attrs)
   if element.isResettable():
     element.resetElement()
 
@@ -1086,10 +1085,12 @@ proc processInHTMLContent(parser: var HTML5Parser, token: Token, insertionMode =
         let token = parser.activeFormatting[formattingIndex][1]
         let element = parser.createElement(token, Namespace.HTML, furthestBlock)
         var tomove: seq[Node]
-        for j in countdown(furthestBlock.childNodes.high, 0):
-          let child = furthestBlock.childNodes[j]
-          child.remove()
+        j = furthestBlock.childList.high
+        while j >= 0:
+          let child = furthestBlock.childList[j]
+          child.remove(j, true)
           tomove.add(child)
+          dec j
         for child in tomove:
           element.append(child)
         furthestBlock.append(element)
@@ -1139,7 +1140,7 @@ proc processInHTMLContent(parser: var HTML5Parser, token: Token, insertionMode =
         else:
           for k, v in token.attrs:
             if k notin parser.openElements[0].attrs:
-              parser.openElements[0].attrs[k] = v
+              parser.openElements[0].attr(k, v)
       )
       ("<base>", "<basefont>", "<bgsound>", "<link>", "<meta>", "<noframes>", "<script>", "<style>", "<template>", "<title>",
        "</template>") => (block: parser.processInHTMLContent(token, IN_HEAD))
@@ -1151,7 +1152,7 @@ proc processInHTMLContent(parser: var HTML5Parser, token: Token, insertionMode =
           parser.framesetOk = false
           for k, v in token.attrs:
             if k notin parser.openElements[1].attrs:
-              parser.openElements[1].attrs[k] = v
+              parser.openElements[1].attr(k, v)
       )
       "<frameset>" => (block:
         parse_error
diff --git a/src/html/tags.nim b/src/html/tags.nim
index ff6a3a30..8b0713d7 100644
--- a/src/html/tags.nim
+++ b/src/html/tags.nim
@@ -142,6 +142,10 @@ const LabelableElements* = {
   TAG_BUTTON, TAG_INPUT, TAG_METER, TAG_OUTPUT, TAG_PROGRESS, TAG_SELECT, TAG_TEXTAREA
 }
 
+const CharacterDataNodes* = {
+  TEXT_NODE, CDATA_SECTION_NODE, PROCESSING_INSTRUCTION_NODE, COMMENT_NODE
+}
+
 #https://html.spec.whatwg.org/multipage/parsing.html#the-stack-of-open-elements
 #NOTE MathML not implemented
 #TODO SVG foreignObject, SVG desc, SVG title