+ *
+ * @since 5.10
+ * @see Node#xpath(String)
+ * @see XPath
+ */
+public class BoundXPath
+{
+ private final XPath xpath;
+
+ private final Object contextNode;
+
+ BoundXPath(XPath xpath, Object contextNode)
+ {
+ assert xpath != null;
+ assert contextNode != null;
+
+ this.xpath = xpath;
+ this.contextNode = contextNode;
+ }
+
+ /**
+ * Evaluates the XPath and returns all matching DOM nodes.
+ *
+ * @return list of matching {@link Node}s; never {@code null}
+ * @throws XPathException if evaluation fails
+ * @see XPath#selectNodes(Object)
+ */
+ public List nodes()
+ {
+ return xpath.selectNodes(contextNode);
+ }
+
+ /**
+ * As {@link #nodes()} but assumes every result is an {@link Element}.
+ *
+ * @return list of matched {@link Element}s; never {@code null}
+ * @throws XPathException if evaluation fails
+ * @see XPath#selectElements(Object)
+ */
+ public List elements()
+ {
+ return xpath.selectElements(contextNode);
+ }
+
+ /**
+ * As {@link #nodes()} but assumes every result is an {@link Attribute}.
+ *
+ * @return list of matched {@link Attribute}s; never {@code null}
+ * @throws XPathException if evaluation fails
+ * @see XPath#selectAttributes(Object)
+ */
+ public List attributes()
+ {
+ return xpath.selectAttributes(contextNode);
+ }
+
+ /**
+ * Returns the first matching node, or {@code null} if there is no match.
+ *
+ * @return the first matched node, or {@code null}
+ * @throws XPathException if evaluation fails
+ * @see XPath#selectSingleNode(Object)
+ */
+ public Object singleNode()
+ {
+ return xpath.selectSingleNode(contextNode);
+ }
+
+ /**
+ * As {@link #singleNode()} but assumes the result is an {@link Element}.
+ *
+ * @return the first matched {@link Element}, or {@code null}
+ * @throws XPathException if evaluation fails
+ * @see XPath#selectSingleElement(Object)
+ */
+ public Element singleElement()
+ {
+ return xpath.selectSingleElement(contextNode);
+ }
+
+ /**
+ * Returns the XPath string-value of each matched node.
+ *
+ * @return list of string values; never {@code null}
+ * @throws XPathException if evaluation fails
+ * @see XPath#stringValuesOf(Object)
+ */
+ public List stringValuesOf()
+ {
+ return xpath.stringValuesOf(contextNode);
+ }
+
+ /**
+ * Returns {@link Element#getChildMarkup()} for each matched element.
+ *
+ * @return list of child-markup strings; never {@code null}
+ * @throws XPathException if evaluation fails
+ * @see XPath#selectElementsChildMarkup(Object)
+ */
+ public List elementsChildMarkup()
+ {
+ return xpath.selectElementsChildMarkup(contextNode);
+ }
+
+ /**
+ * Returns {@link Element#getAttribute(String)} for each matched element.
+ *
+ * Unlike XPath attribute selection, elements that lack the attribute contribute
+ * a {@code null} entry so the result list is always the same size as the matched
+ * element list.
+ *
+ * @param attributeName name of the attribute to retrieve; must not be {@code null} or empty
+ * @return list of attribute values, with {@code null} for missing attributes; never {@code null}
+ * @throws IllegalArgumentException if {@code attributeName} is {@code null} or empty
+ * @throws XPathException if evaluation fails
+ * @see XPath#selectElementsAttribute(Object, String)
+ */
+ public List elementsAttribute(String attributeName)
+ {
+ if (attributeName == null || attributeName.isEmpty())
+ {
+ throw new IllegalArgumentException("attributeName must not be null or empty");
+ }
+
+ return xpath.selectElementsAttribute(contextNode, attributeName);
+ }
+
+ /**
+ * Selects a single node and concatenates all its descendant text, then normalises
+ * whitespace via {@link XPath#normalizeText(String)}.
+ *
+ * @return normalised text string; never {@code null}
+ * @throws XPathException if evaluation fails
+ * @see XPath#singleNormalizedDescendantText(Object)
+ */
+ public String singleNormalizedDescendantText()
+ {
+ return xpath.singleNormalizedDescendantText(contextNode);
+ }
+
+ /**
+ * For each matched node, concatenates all descendant text and normalises
+ * whitespace via {@link XPath#normalizeText(String)}.
+ *
+ * @return list of normalised text strings, one per matched node; never {@code null}
+ * @throws XPathException if evaluation fails
+ * @see XPath#normalizedDescendantText(Object)
+ */
+ public List normalizedDescendantText()
+ {
+ return xpath.normalizedDescendantText(contextNode);
+ }
+}
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/CData.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/CData.java
index 24ac3cf921..efb9e130ba 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/dom/CData.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/CData.java
@@ -1,4 +1,4 @@
-// Copyright 2007, 2008, 2009 The Apache Software Foundation
+// Copyright 2007-2009, 2026 The Apache Software Foundation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -32,6 +32,15 @@ public CData(Element container, String content)
this.content = content;
}
+ /**
+ * Returns the raw character content stored in this node, without any CDATA wrapping or entity encoding.
+ * @since 5.10
+ */
+ public String getContent()
+ {
+ return content;
+ }
+
@Override
void toMarkup(Document document, PrintWriter writer, Map namespaceURIToPrefix)
{
@@ -49,4 +58,15 @@ void toMarkup(Document document, PrintWriter writer, Map namespa
writer.print(model.encode(content));
}
+
+ /**
+ * Returns a deep copy of this CDATA node, detached from any parent.
+ *
+ * @since 5.10
+ */
+ @Override
+ public CData deepClone()
+ {
+ return new CData(null, content);
+ }
}
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Comment.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Comment.java
index 2e74fe6d77..aaefee6166 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Comment.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Comment.java
@@ -1,4 +1,4 @@
-// Copyright 2006, 2008, 2009, 2010 The Apache Software Foundation
+// Copyright 2006, 2008-2010, 2026 The Apache Software Foundation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -38,4 +38,15 @@ void toMarkup(Document document, PrintWriter writer, Map namespa
writer.print(comment);
writer.print("-->");
}
+
+ /**
+ * Returns a deep copy of this comment node, detached from any parent.
+ *
+ * @since 5.10
+ */
+ @Override
+ public Comment deepClone()
+ {
+ return new Comment(null, comment);
+ }
}
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Document.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Document.java
index f48ca8fac4..462cd10027 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Document.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Document.java
@@ -1,4 +1,4 @@
-// Copyright 2006, 2007, 2008, 2009, 2010, 2014 The Apache Software Foundation
+// Copyright 2006-2010, 2014, 2026 The Apache Software Foundation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -248,6 +248,18 @@ void visit(Visitor visitor)
rootElement.visit(visitor);
}
+ /**
+ * Visits all nodes in the document tree, starting from the root element, invoking the
+ * appropriate {@link NodeVisitor} method for each node type encountered.
+ *
+ * @param visitor callback
+ * @since 5.10
+ */
+ public void visit(NodeVisitor visitor)
+ {
+ rootElement.visit(visitor);
+ }
+
private T newChild(T child)
{
if (preamble == null)
@@ -305,6 +317,40 @@ public CData cdata(String content)
return newChild(new CData(null, content));
}
+ /**
+ * Returns an independent deep copy of this document, including its DTD, preamble nodes, and
+ * entire element tree.
+ *
+ * @return a fully independent deep copy of this document
+ * @since 5.10
+ */
+ @Override
+ public Document deepClone()
+ {
+ Document clone = new Document(model, encoding, mimeType);
+
+ // DTD is immutable after construction, so sharing the reference is safe.
+ clone.dtd = dtd;
+
+ if (preamble != null)
+ {
+ clone.preamble = CollectionFactory.newList();
+ for (Node n : preamble)
+ clone.preamble.add(n.deepClone());
+ }
+
+ if (rootElement != null)
+ {
+ // Use the Document-accepting constructor so the clone's root element
+ // has its 'document' back-reference set correctly.
+ Element cloneRoot = new Element(clone, rootElement.getNamespace(), rootElement.getName());
+ clone.rootElement = cloneRoot;
+ rootElement.copyInto(cloneRoot);
+ }
+
+ return clone;
+ }
+
/**
* Returns the MIME type of this document.
* @return the MIME type.
@@ -314,5 +360,4 @@ public String getMimeType()
{
return mimeType;
}
-
}
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Element.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Element.java
index 99eb740994..3e0b843cab 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Element.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Element.java
@@ -33,7 +33,7 @@ public final class Element extends Node
private final String name;
- private Node firstChild;
+ Node firstChild;
private Node lastChild;
@@ -705,6 +705,64 @@ public void pop()
remove();
}
+ /**
+ * Inserts {@code node} as the first child of this element, shifting any existing children
+ * forward. If {@code node} is currently attached to another parent, it is detached first.
+ *
+ * This is the anchor-centric complement to {@link Node#moveToTop(Element)}.
+ *
+ * @param node the node to prepend; must not be {@code null} or an ancestor of this element
+ * @return this element, for method chaining
+ * @throws IllegalArgumentException if {@code node} is {@code null} or is this element or an ancestor of it
+ * @since 5.10
+ */
+ public Element prependChild(Node node)
+ {
+ if (node == null)
+ throw new IllegalArgumentException("node must not be null");
+
+ Node search = this;
+ while (search != null)
+ {
+ if (search == node)
+ throw new IllegalArgumentException("Cannot prepend a node to itself or one of its ancestors");
+ search = search.container;
+ }
+
+ node.detach();
+ insertChildAt(0, node);
+ return this;
+ }
+
+ /**
+ * Appends {@code node} as the last child of this element. If {@code node} is currently
+ * attached to another parent, it is detached first.
+ *
+ * This is the anchor-centric complement to {@link Node#moveToBottom(Element)}.
+ *
+ * @param node the node to append; must not be {@code null} or an ancestor of this element
+ * @return this element, for method chaining
+ * @throws IllegalArgumentException if {@code node} is {@code null} or is this element or an ancestor of it
+ * @since 5.10
+ */
+ public Element appendChild(Node node)
+ {
+ if (node == null)
+ throw new IllegalArgumentException("node must not be null");
+
+ Node search = this;
+ while (search != null)
+ {
+ if (search == node)
+ throw new IllegalArgumentException("Cannot append a node to itself or one of its ancestors");
+ search = search.container;
+ }
+
+ node.detach();
+ addChild(node);
+ return this;
+ }
+
/**
* Removes all children from this element.
*
@@ -865,6 +923,46 @@ public void visit(Visitor visitor)
}
}
+ /**
+ * Depth-first visitor traversal of this Element and all its descendant nodes,
+ * including {@link Text}, {@link Comment}, {@link CData}, and {@link Raw} nodes. The traversal
+ * order is the same as render order (pre-order).
+ *
+ * All {@link NodeVisitor} methods have default no-op implementations, so implementors only
+ * need to override the node types they are interested in.
+ *
+ * @param visitor callback
+ * @since 5.10
+ */
+ public void visit(NodeVisitor visitor)
+ {
+ Stack queue = CollectionFactory.newStack();
+
+ queue.push(this);
+
+ while (!queue.isEmpty())
+ {
+ Node n = queue.pop();
+
+ if (n instanceof Element)
+ {
+ Element e = (Element) n;
+ visitor.visit(e);
+
+ List children = CollectionFactory.newList();
+ for (Node cursor = e.firstChild; cursor != null; cursor = cursor.nextSibling)
+ children.add(cursor);
+ Collections.reverse(children);
+ for (Node child : children)
+ queue.push(child);
+ }
+ else if (n instanceof Text) visitor.visit((Text) n);
+ else if (n instanceof Comment) visitor.visit((Comment) n);
+ else if (n instanceof CData) visitor.visit((CData) n);
+ else if (n instanceof Raw) visitor.visit((Raw) n);
+ }
+ }
+
private void queueChildren(Stack queue)
{
if (firstChild == null)
@@ -945,12 +1043,15 @@ void writeChildMarkup(Document document, PrintWriter writer, Map
/**
* @return the concatenation of the String representations {@link #toString()} of its children.
+ * Uses {@link DefaultMarkupModel} as a fallback when the element is not attached to a document.
*/
public final String getChildMarkup()
{
PrintOutCollector collector = new PrintOutCollector();
- writeChildMarkup(getDocument(), collector.getPrintWriter(), null);
+ Document doc = getDocument();
+
+ writeChildMarkup(doc != null ? doc : new Document(), collector.getPrintWriter(), null);
return collector.getPrintOut();
}
@@ -976,6 +1077,137 @@ public List getChildren()
return result;
}
+ /**
+ * Returns a deep copy of this element and its entire subtree, detached from any parent or
+ * document.
+ *
+ * The clone has no {@link #getContainer() container} and, for elements, no owning
+ * {@link Document}. It needs to be attached to a tree before calling rendering or
+ * document-traversal methods.
+ *
+ * @return a fully independent deep copy of this element
+ * @since 5.10
+ */
+ @Override
+ public Element deepClone()
+ {
+ Element clone = new Element((Element) null, namespace, name);
+ copyInto(clone);
+ return clone;
+ }
+
+ /**
+ * Detaches this element from its containing element and returns it as an {@link Element},
+ * allowing fluent use without a cast. If already detached, this is a no-op.
+ *
+ * @return this element, now detached
+ * @since 5.10
+ */
+ @Override
+ public Element detach()
+ {
+ return (Element) super.detach();
+ }
+
+ /**
+ * Copies this element's namespace mappings, attributes, and children (recursively deep-cloned)
+ * into {@code target}. Used internally by {@link #deepClone()} and
+ * {@link Document#deepClone()}.
+ */
+ void copyInto(Element target)
+ {
+ if (namespaceToPrefix != null)
+ {
+ target.namespaceToPrefix = CollectionFactory.newMap();
+ target.namespaceToPrefix.putAll(namespaceToPrefix);
+ }
+
+ // Rebuild the attribute linked-list in the same order.
+ Attribute srcAttr = firstAttribute;
+ Attribute prevCloneAttr = null;
+ Attribute firstCloneAttr = null;
+
+ while (srcAttr != null)
+ {
+ Attribute cloneAttr = new Attribute(target,
+ srcAttr.getNamespace(), srcAttr.getName(), srcAttr.getValue(), null);
+
+ if (prevCloneAttr == null)
+ firstCloneAttr = cloneAttr;
+ else
+ prevCloneAttr.nextAttribute = cloneAttr;
+
+ prevCloneAttr = cloneAttr;
+ srcAttr = srcAttr.nextAttribute;
+ }
+
+ target.firstAttribute = firstCloneAttr;
+
+ // Recursively clone children.
+ Node cursor = firstChild;
+ while (cursor != null)
+ {
+ target.addChild(cursor.deepClone());
+ cursor = cursor.nextSibling;
+ }
+ }
+
+ /**
+ * Returns the next sibling of this element that is itself an {@link Element}, skipping over
+ * any intervening text, comment, or other non-element nodes. Returns {@code null} if no such
+ * sibling exists, or if this element has no container (root element or detached).
+ *
+ * @return the next sibling element, or {@code null}
+ * @since 5.10
+ */
+ public Element getNextSiblingElement()
+ {
+ Node cursor = nextSibling;
+
+ while (cursor != null)
+ {
+ if (cursor instanceof Element)
+ return (Element) cursor;
+
+ cursor = cursor.nextSibling;
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns the previous sibling of this element that is itself an {@link Element}, skipping
+ * over any intervening text, comment, or other non-element nodes. Returns {@code null} if no
+ * such sibling exists, or if this element has no container (root element or detached).
+ *
+ * Because the DOM uses a singly-linked sibling list, this method walks forward from the
+ * parent's first child; it is O(n) in the number of siblings.
+ *
+ * @return the previous sibling element, or {@code null}
+ * @since 5.10
+ */
+ public Element getPreviousSiblingElement()
+ {
+ if (container == null)
+ return null;
+
+ Element previousElement = null;
+ Node cursor = container.firstChild;
+
+ while (cursor != null)
+ {
+ if (cursor == this)
+ return previousElement;
+
+ if (cursor instanceof Element)
+ previousElement = (Element) cursor;
+
+ cursor = cursor.nextSibling;
+ }
+
+ throw new IllegalStateException("Element is not a child of its own container.");
+ }
+
void remove(Node node)
{
Node prior = null;
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Node.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Node.java
index fb5c351a00..19c20a7b5c 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Node.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Node.java
@@ -15,8 +15,11 @@
package org.apache.tapestry5.dom;
import java.io.PrintWriter;
+import java.util.Collections;
import java.util.Map;
+import org.apache.tapestry5.dom.xpath.XPath;
+import org.apache.tapestry5.dom.xpath.XPathException;
import org.apache.tapestry5.internal.util.PrintOutCollector;
/**
@@ -51,14 +54,18 @@ public Element getContainer()
return container;
}
+ /**
+ * Returns the owning {@link Document}, or {@code null} if this node is detached (not part of
+ * any document tree).
+ */
public Document getDocument()
{
- return container.getDocument();
+ return container != null ? container.getDocument() : null;
}
-
/**
* Invokes {@link #toMarkup(PrintWriter)}, collecting output in a string, which is returned.
+ * When the node is detached from a document, {@link DefaultMarkupModel} is used as a fallback.
*/
@Override
public String toString()
@@ -70,20 +77,46 @@ public String toString()
return collector.getPrintOut();
}
+ /**
+ * Renders this node to a {@code String} using the specified {@link MarkupModel}, without
+ * requiring the node to be attached to a {@link Document}.
+ *
+ * This is the preferred rendering method for detached nodes (such as those returned by
+ * {@link #deepClone()}). For attached nodes, {@link #toString()} uses the document's
+ * model automatically.
+ *
+ * @param model the markup model controlling encoding and tag-style decisions; must not be null
+ * @return the rendered markup as a string
+ * @since 5.10
+ */
+ public String toMarkup(MarkupModel model)
+ {
+ assert model != null;
+
+ PrintOutCollector collector = new PrintOutCollector();
+
+ toMarkup(new Document(model), collector.getPrintWriter(), getNamespaceURIToPrefix());
+
+ return collector.getPrintOut();
+ }
/**
- * Writes the markup for this node to the writer.
+ * Writes the markup for this node to the writer, using {@link DefaultMarkupModel} as a
+ * fallback when the node is not attached to a document.
*/
public void toMarkup(PrintWriter writer)
{
- toMarkup(getDocument(), writer, getNamespaceURIToPrefix());
+ Document doc = getDocument();
+
+ toMarkup(doc != null ? doc : new Document(), writer, getNamespaceURIToPrefix());
}
protected Map getNamespaceURIToPrefix()
{
// For non-Elements, the container (which should be an Element) will provide the mapping.
+ // For detached nodes the container is null; return an empty map (no ancestor namespaces).
- return container.getNamespaceURIToPrefix();
+ return container != null ? container.getNamespaceURIToPrefix() : Collections.emptyMap();
}
/**
@@ -160,6 +193,150 @@ public Node moveToBottom(Element element)
return this;
}
+ /**
+ * Inserts {@code node} immediately before {@code this} in the parent's child list, detaching
+ * it from any prior parent first.
+ *
+ * This is the anchor-centric complement to {@link #moveBefore(Element)}; unlike
+ * {@code moveBefore}, the anchor ({@code this}) may be any {@link Node} type, not just an
+ * {@link Element}.
+ *
+ * @param node the node to insert; must not be {@code null} or {@code this}
+ * @return the inserted {@code node}, now in position
+ * @throws IllegalArgumentException if {@code node} is {@code null}, is {@code this}, or is an ancestor of {@code this}
+ * @throws IllegalStateException if {@code this} is detached (has no parent)
+ * @since 5.10
+ */
+ public Node insertBefore(Node node)
+ {
+ if (node == null)
+ {
+ throw new IllegalArgumentException("node must not be null");
+ }
+ if (node == this)
+ {
+ throw new IllegalArgumentException("A node cannot be inserted relative to itself");
+ }
+ if (container == null)
+ {
+ throw new IllegalStateException("Cannot insert relative to a detached node");
+ }
+
+ Node search = container;
+ while (search != null)
+ {
+ if (search == node)
+ {
+ throw new IllegalArgumentException("Cannot insert an ancestor node");
+ }
+
+ search = search.container;
+ }
+
+ node.detach();
+ container.insertChildBefore(this, node);
+
+ return node;
+ }
+
+ /**
+ * Inserts {@code node} immediately after {@code this} in the parent's child list, detaching
+ * it from any prior parent first.
+ *
+ * This is the anchor-centric complement to {@link #moveAfter(Element)}; unlike
+ * {@code moveAfter}, the anchor ({@code this}) may be any {@link Node} type, not just an
+ * {@link Element}.
+ *
+ * @param node the node to insert; must not be {@code null} or {@code this}
+ * @return the inserted {@code node}, now in position
+ * @throws IllegalArgumentException if {@code node} is {@code null}, is {@code this}, or is an ancestor of {@code this}
+ * @throws IllegalStateException if {@code this} is detached (has no parent)
+ * @since 5.10
+ */
+ public Node insertAfter(Node node)
+ {
+ if (node == null)
+ {
+ throw new IllegalArgumentException("node must not be null");
+ }
+ if (node == this)
+ {
+ throw new IllegalArgumentException("A node cannot be inserted relative to itself");
+ }
+ if (container == null)
+ {
+ throw new IllegalStateException("Cannot insert relative to a detached node");
+ }
+
+ Node search = container;
+ while (search != null)
+ {
+ if (search == node)
+ {
+ throw new IllegalArgumentException("Cannot insert an ancestor node");
+ }
+
+ search = search.container;
+ }
+
+ node.detach();
+ container.insertChildAfter(this, node);
+
+ return node;
+ }
+
+ /**
+ * Returns the next sibling node within the containing element, or {@code null}
+ * if {@code this} is:
+ *
+ *
the last child
+ *
not part of a sibling list (root element, document-preamble node, or
+ * detached node)
+ *
+ *
+ * @return the next sibling node, or {@code null}
+ * @since 5.10
+ */
+ public Node getNextSibling()
+ {
+ return nextSibling;
+ }
+
+ /**
+ * Returns the previous sibling node within the containing element, or {@code null}
+ * if {@this} is:
+ *
+ *
the first child
+ *
not part of a sibling list (root element, document-preamble node, or detached node).
+ *
+ *
+ * @return the previous sibling node, or {@code null}
+ * @since 5.10
+ */
+ public Node getPreviousSibling()
+ {
+ if (container == null)
+ {
+ return null;
+ }
+
+ Node previous = null;
+ Node cursor = container.firstChild;
+
+ while (cursor != null)
+ {
+ if (cursor == this)
+ {
+ return previous;
+ }
+
+ previous = cursor;
+ cursor = cursor.nextSibling;
+ }
+
+ throw new IllegalStateException("Node is not a child of its own container.");
+ }
+
private void validateElement(Element element)
{
assert element != null;
@@ -187,6 +364,69 @@ public void remove()
container = null;
}
+ /**
+ * Replaces this node in the DOM with the given {@code replacement} node, detaching this node and
+ * inserting the replacement in its place.
+ *
+ * If {@code replacement} is currently attached to another parent, it is detached first.
+ *
+ * @param replacement the node to put in place of this node, must not be {@code null} or {@this}
+ * @return the replacement node, now in position
+ * @throws IllegalArgumentException if {@code replacement} is {@code null}, is {@this}, or is an ancestor of {@this}
+ * @throws IllegalStateException if {@this} is detached (has no parent)
+ * @since 5.10
+ */
+ public Node replaceWith(Node replacement)
+ {
+ if (replacement == null)
+ {
+ throw new IllegalArgumentException("replacement must not be null");
+ }
+ if (replacement == this)
+ {
+ throw new IllegalArgumentException("A node cannot replace itself");
+ }
+ if (container == null)
+ {
+ throw new IllegalStateException("Cannot replace a detached node");
+ }
+
+ // Guard against ancestor cycles: replacement must not be an ancestor of this node.
+ Node search = container;
+ while (search != null)
+ {
+ if (search == replacement)
+ {
+ throw new IllegalArgumentException("Cannot replace a node with one of its ancestors");
+ }
+
+ search = search.container;
+ }
+
+ Element parent = container;
+ replacement.detach();
+ parent.insertChildBefore(this, replacement);
+ remove();
+
+ return replacement;
+ }
+
+ /**
+ * Detaches this node from its containing element and returns it.
+ *
+ * If the node is already detached (no container), this method is a no-op.
+ *
+ * @return this node, now detached
+ * @since 5.10
+ */
+ public Node detach()
+ {
+ if (container != null)
+ remove();
+
+ return this;
+ }
+
/**
* Wraps a node inside a new element. The new element is created before the node, then the node is moved inside the
* new element.
@@ -207,4 +447,39 @@ public Element wrap(String elementName, String... namesAndValues)
return element;
}
+
+ /**
+ * Returns a deep copy of this node and its entire subtree, detached from any parent or
+ * document.
+ *
+ * Detached nodes can be still be rendered via {@link #toString()} (which falls back to
+ * {@link DefaultMarkupModel}) or with an explicit model via {@link #toMarkup(MarkupModel)}.
+ *
+ * @return a fully independent deep copy of this node
+ * @since 5.10
+ */
+ public abstract Node deepClone();
+
+ /**
+ * Creates a {@link BoundXPath} for the given XPath expression with this node as the context,
+ * allowing fluent queries without repeating the context node:
+ *
+ *
+ * @param expression XPath expression; must not be null or empty
+ * @return a {@link BoundXPath} bound to this node
+ * @throws IllegalArgumentException if {@code expression} is null or empty
+ * @throws XPathException if the expression cannot be parsed
+ * @since 5.10
+ */
+ public BoundXPath xpath(String expression)
+ {
+ if (expression == null || expression.isEmpty())
+ {
+ throw new IllegalArgumentException("XPath expression must not be null or empty");
+ }
+
+ return new BoundXPath(XPath.of(expression), this);
+ }
}
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/NodeVisitor.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/NodeVisitor.java
new file mode 100644
index 0000000000..9f75cec17d
--- /dev/null
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/NodeVisitor.java
@@ -0,0 +1,67 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.dom;
+
+/**
+ * A callback interface used to traverse every kind of {@link Node} in a DOM subtree:
+ * {@link Element}, {@link Text}, {@link Comment}, {@link CData}, and {@link Raw}.
+ *
+ * All methods have empty default implementations so implementors only override the node types
+ * they care about. Traversal is depth-first pre-order (render order), matching the behaviour of
+ * {@link Element#visit(Visitor)} but including non-element nodes.
+ *
+ * Use {@link Element#visit(NodeVisitor)} or {@link Document#visit(NodeVisitor)} to start a
+ * traversal.
+ *
+ * @since 5.10
+ * @see Visitor
+ */
+public interface NodeVisitor
+{
+ /**
+ * Called for each {@link Element} encountered during traversal.
+ *
+ * @param element the element being visited
+ */
+ default void visit(Element element) {}
+
+ /**
+ * Called for each {@link Text} node encountered during traversal.
+ *
+ * @param text the text node being visited
+ */
+ default void visit(Text text) {}
+
+ /**
+ * Called for each {@link Comment} node encountered during traversal.
+ *
+ * @param comment the comment node being visited
+ */
+ default void visit(Comment comment) {}
+
+ /**
+ * Called for each {@link CData} node encountered during traversal.
+ *
+ * @param cdata the CDATA node being visited
+ */
+ default void visit(CData cdata) {}
+
+ /**
+ * Called for each {@link Raw} node encountered during traversal.
+ *
+ * @param raw the raw-markup node being visited
+ */
+ default void visit(Raw raw) {}
+}
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Raw.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Raw.java
index 5aa74fa312..63a764e745 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Raw.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Raw.java
@@ -1,4 +1,4 @@
-// Copyright 2007, 2008, 2009 The Apache Software Foundation
+// Copyright 2007, 2008, 2009, 2026 The Apache Software Foundation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -39,4 +39,15 @@ void toMarkup(Document document, PrintWriter writer, Map namespa
{
writer.print(text);
}
+
+ /**
+ * Returns a deep copy of this raw-markup node, detached from any parent.
+ *
+ * @since 5.10
+ */
+ @Override
+ public Raw deepClone()
+ {
+ return new Raw(null, text);
+ }
}
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Text.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Text.java
index 02a2abf39a..186196db56 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Text.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Text.java
@@ -1,4 +1,4 @@
-// Copyright 2006, 2008, 2009 The Apache Software Foundation
+// Copyright 2006, 2008, 2009, 2026 The Apache Software Foundation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -22,20 +22,20 @@
*/
public final class Text extends Node
{
- private final StringBuilder buffer;
+ private final StringBuilder builder;
Text(Element container, String text)
{
super(container);
- buffer = new StringBuilder(text.length());
+ builder = new StringBuilder(text.length());
write(text);
}
boolean isEmpty()
{
- return buffer.length() == 0 || buffer.toString().trim().length() == 0;
+ return builder.length() == 0 || builder.toString().trim().length() == 0;
}
/**
@@ -43,7 +43,7 @@ boolean isEmpty()
*/
public void write(String text)
{
- buffer.append(text);
+ builder.append(text);
}
public void writef(String format, Object... args)
@@ -54,8 +54,19 @@ public void writef(String format, Object... args)
@Override
void toMarkup(Document document, PrintWriter writer, Map namespaceURIToPrefix)
{
- String encoded = document.getMarkupModel().encode(buffer.toString());
+ String encoded = document.getMarkupModel().encode(builder.toString());
writer.print(encoded);
}
+
+ /**
+ * Returns a deep copy of this text node, detached from any parent.
+ *
+ * @since 5.10
+ */
+ @Override
+ public Text deepClone()
+ {
+ return new Text(null, builder.toString());
+ }
}
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Visitor.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Visitor.java
index 577ed8840a..6bc8a9d72b 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/dom/Visitor.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/Visitor.java
@@ -15,16 +15,49 @@
package org.apache.tapestry5.dom;
/**
- * A callback interface used navigate the {@link org.apache.tapestry5.dom.Element}s of a document.
+ * A callback interface used to traverse the nodes of a DOM subtree. Traversal is depth-first
+ * pre-order (render order), visiting {@link Element}, {@link Text}, {@link Comment}, {@link CData},
+ * and {@link Raw} nodes.
+ *
+ * {@link #visit(Element)} is the only required method; the remaining overloads have empty default
+ * implementations so implementors only override the node types they care about.
*
* @since 5.1.0.0
*/
public interface Visitor
{
/**
- * Called for each Element being visited.
+ * Called for each {@link Element} encountered during traversal.
*
- * @param element visited
+ * @param element the element being visited
*/
void visit(Element element);
+
+ /**
+ * Called for each {@link Text} node encountered during traversal.
+ *
+ * @param text the text node being visited
+ */
+ default void visit(Text text) {}
+
+ /**
+ * Called for each {@link Comment} node encountered during traversal.
+ *
+ * @param comment the comment node being visited
+ */
+ default void visit(Comment comment) {}
+
+ /**
+ * Called for each {@link CData} node encountered during traversal.
+ *
+ * @param cdata the CDATA node being visited
+ */
+ default void visit(CData cdata) {}
+
+ /**
+ * Called for each {@link Raw} node encountered during traversal.
+ *
+ * @param raw the raw-markup node being visited
+ */
+ default void visit(Raw raw) {}
}
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/xpath/DocumentNavigator.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/xpath/DocumentNavigator.java
new file mode 100644
index 0000000000..8441364e9f
--- /dev/null
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/xpath/DocumentNavigator.java
@@ -0,0 +1,430 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.dom.xpath;
+
+import java.util.Iterator;
+
+import org.apache.tapestry5.dom.Attribute;
+import org.apache.tapestry5.dom.CData;
+import org.apache.tapestry5.dom.Comment;
+import org.apache.tapestry5.dom.Document;
+import org.apache.tapestry5.dom.Element;
+import org.apache.tapestry5.dom.Node;
+import org.apache.tapestry5.dom.Text;
+import org.jaxen.BaseXPath;
+import org.jaxen.DefaultNavigator;
+import org.jaxen.JaxenConstants;
+import org.jaxen.JaxenException;
+import org.jaxen.UnsupportedAxisException;
+import org.jaxen.saxpath.SAXPathException;
+import org.jaxen.util.SingleObjectIterator;
+
+/**
+ * Jaxen {@link org.jaxen.Navigator} implementation that adapts Tapestry's DOM
+ * nodes.
+ *
+ *
Intentionally unsupported: namespace nodes and processing
+ * instructions, because Tapestry's DOM does not model either.
+ *
+ * @since 5.10
+ * @see XPath
+ */
+class DocumentNavigator extends DefaultNavigator
+{
+ public static final DocumentNavigator INSTANCE = new DocumentNavigator();
+
+ private static final int COMMENT_PREFIX_LENGTH = "".length();
+
+ /**
+ * Returns an iterator over the attributes of {@code contextNode}.
+ *
+ * Only {@link Element} nodes have attributes, all other node types
+ * yield an empty iterator.
+ *
+ * @param contextNode the node whose attribute axis is requested
+ * @return iterator of {@link Attribute} objects, or an empty iterator
+ */
+ @Override
+ public Iterator> getAttributeAxisIterator(Object contextNode)
+ {
+ if (contextNode instanceof Element)
+ {
+ return ((Element) contextNode).getAttributes().iterator();
+ }
+
+ return JaxenConstants.EMPTY_ITERATOR;
+ }
+
+ /**
+ * Returns an iterator over the children of {@code contextNode}.
+ *
+ *
{@link Document}: its single root {@link Element}.
+ *
{@link Element}: all child {@link Node}s in document order.
+ *
Other node types: empty iterator (leaf nodes).
+ *
+ *
+ * @param contextNode the node whose child axis is requested
+ * @return iterator of child nodes, or an empty iterator
+ */
+ @Override
+ public Iterator> getChildAxisIterator(Object contextNode)
+ {
+ if (contextNode instanceof Document)
+ {
+ return new SingleObjectIterator(((Document) contextNode).getRootElement());
+ }
+
+ if (contextNode instanceof Element)
+ {
+ return ((Element) contextNode).getChildren().iterator();
+ }
+
+ return JaxenConstants.EMPTY_ITERATOR;
+ }
+
+ /**
+ * Returns an iterator over the parent of {@code contextNode}.
+ *
+ *
Non-root {@link Element}: the containing {@link Element}.
+ *
Root {@link Element}: the owning {@link Document}.
{@link Document} and anything else: delegates to the default
+ * implementation (returns empty).
+ *
+ *
+ * @param contextNode the node whose parent axis is requested
+ * @return single-element iterator containing the parent node, or an empty iterator
+ * @throws UnsupportedAxisException if the axis is not supported for the node type
+ */
+ @Override
+ public Iterator> getParentAxisIterator(Object contextNode) throws UnsupportedAxisException
+ {
+ if (contextNode instanceof Element)
+ {
+ Element e = (Element) contextNode;
+ Element parent = e.getContainer();
+ // Root element's logical parent in XPath is the Document, not null
+ return new SingleObjectIterator(parent != null ? parent : e.getDocument());
+ }
+
+ if (contextNode instanceof Node)
+ {
+ // Text, Comment, CData, Raw: parent is always their containing Element
+ Element container = ((Node) contextNode).getContainer();
+ return container != null
+ ? new SingleObjectIterator(container)
+ : JaxenConstants.EMPTY_ITERATOR;
+ }
+
+ return super.getParentAxisIterator(contextNode);
+ }
+
+ /**
+ * Returns the {@link Document} that owns {@code contextNode}.
+ *
+ * @param contextNode any {@link Node} in the document
+ * @return the owning {@link Document}; never {@code null}
+ */
+ @Override
+ public Object getDocumentNode(Object contextNode)
+ {
+ return ((Node) contextNode).getDocument();
+ }
+
+ /**
+ * Looks up an element by its {@code id} attribute within the document that
+ * contains {@code contextNode}.
+ *
+ * @param contextNode any {@link Node} used to locate the owning {@link Document}
+ * @param elementId the value of the {@code id} attribute to search for
+ * @return the matching {@link Element}, or {@code null} if not found
+ */
+ @Override
+ public Object getElementById(Object contextNode, String elementId)
+ {
+ return ((Node) contextNode).getDocument().getElementById(elementId);
+ }
+
+ /**
+ * Namespace nodes are not supported.
+ * Always returns {@code null}.
+ *
+ * @param contextNode ignored
+ * @return {@code null}
+ */
+ @Override
+ public String getNamespacePrefix(Object contextNode)
+ {
+ return null;
+ }
+
+ /**
+ * Namespace nodes are not supported.
+ * Always returns {@code null}.
+ *
+ * @param contextNode ignored
+ * @return {@code null}
+ */
+ @Override
+ public String getNamespaceStringValue(Object contextNode)
+ {
+ return null;
+ }
+
+ /**
+ * Returns the local name of {@code element}.
+ *
+ * @param element an {@link Element} node
+ * @return the element's local name; never {@code null}
+ */
+ @Override
+ public String getElementName(Object element)
+ {
+ return ((Element) element).getName();
+ }
+
+ /**
+ * Namespace URIs are not supported.
+ * Always returns {@code null}.
+ *
+ * @param arg0 ignored
+ * @return {@code null}
+ */
+ @Override
+ public String getElementNamespaceUri(Object arg0)
+ {
+ return null;
+ }
+
+ /**
+ * Qualified names are not supported.
+ * Always returns {@code null}.
+ *
+ * @param contextNode ignored
+ * @return {@code null}
+ */
+ @Override
+ public String getElementQName(Object contextNode)
+ {
+ return null;
+ }
+
+ /**
+ * Returns the XPath string-value of {@code element}, which is the concatenated
+ * text of the element's rendered child markup.
+ *
+ * @param element an {@link Element} node
+ * @return the element's child markup string, never {@code null}
+ */
+ @Override
+ public String getElementStringValue(Object element)
+ {
+ return ((Element) element).getChildMarkup();
+ }
+
+ /**
+ * Returns the local name of {@code attr}.
+ *
+ * @param attr an {@link Attribute}
+ * @return the attribute's name, never {@code null}
+ */
+ @Override
+ public String getAttributeName(Object attr)
+ {
+ return ((Attribute) attr).getName();
+ }
+
+ /**
+ * Namespace URIs are not supported.
+ * Always returns {@code null}.
+ *
+ * @param contextNode ignored
+ * @return {@code null}
+ */
+ @Override
+ public String getAttributeNamespaceUri(Object contextNode)
+ {
+ return null;
+ }
+
+ /**
+ * Qualified names are not supported.
+ * Always returns {@code null}.
+ *
+ * @param contextNode ignored
+ * @return {@code null}
+ */
+ @Override
+ public String getAttributeQName(Object contextNode)
+ {
+ return null;
+ }
+
+ /**
+ * Returns the value of {@code attr}.
+ *
+ * @param attr an {@link Attribute}
+ * @return the attribute's value; never {@code null}
+ */
+ @Override
+ public String getAttributeStringValue(Object attr)
+ {
+ return ((Attribute) attr).getValue();
+ }
+
+ /**
+ * Returns the text content of {@code comment}.
+ * The comment string gets stripped of its {@code } suffix.
+ *
+ * @param comment a {@link Comment} node
+ * @return the comment's inner text, including any surrounding whitespace
+ */
+ @Override
+ public String getCommentStringValue(Object comment)
+ {
+ String fullComment = ((Comment) comment).toString();
+ return fullComment.substring(COMMENT_PREFIX_LENGTH, fullComment.length() - COMMENT_SUFFIX_LENGTH);
+ }
+
+ /**
+ * Returns the string value of a text or CDATA node.
+ * Both {@link Text} and {@link CData} are treated as XPath text nodes.
+ *
+ * @param text a {@link Text} or {@link CData} node
+ * @return the node's text content, never {@code null}
+ */
+ @Override
+ public String getTextStringValue(Object text)
+ {
+ if (text instanceof CData)
+ {
+ return ((CData) text).getContent();
+ }
+
+ return ((Text) text).toString();
+ }
+
+ /**
+ * Returns whether the given object is an attribute node.
+ *
+ * @param object the object to test
+ * @return {@code true} if {@code object} is an {@link Attribute}
+ */
+ @Override
+ public boolean isAttribute(Object object)
+ {
+ return object instanceof Attribute;
+ }
+
+ /**
+ * Returns whether the given object is a comment node.
+ *
+ * @param object the object to test
+ * @return {@code true} if {@code object} is a {@link Comment}
+ */
+ @Override
+ public boolean isComment(Object object)
+ {
+ return object instanceof Comment;
+ }
+
+ /**
+ * Returns whether the given object is a document node.
+ *
+ * @param object the object to test
+ * @return {@code true} if {@code object} is a {@link Document}
+ */
+ @Override
+ public boolean isDocument(Object object)
+ {
+ return object instanceof Document;
+ }
+
+ /**
+ * Returns whether the given object is an element node.
+ *
+ * @param object the object to test
+ * @return {@code true} if {@code object} is an {@link Element}
+ */
+ @Override
+ public boolean isElement(Object object)
+ {
+ return object instanceof Element;
+ }
+
+ /**
+ * Namespace nodes are not supported.
+ * Always returns {@code false}.
+ *
+ * @param object ignored
+ * @return {@code false}
+ */
+ @Override
+ public boolean isNamespace(Object object)
+ {
+ return false;
+ }
+
+ /**
+ * Processing instructions are not supported.
+ * Always returns {@code false}.
+ *
+ * @param object ignored
+ * @return {@code false}
+ */
+ @Override
+ public boolean isProcessingInstruction(Object object)
+ {
+ return false;
+ }
+
+ /**
+ * Returns {@code true} for {@link Text} and {@link CData} nodes.
+ * CDATA sections are text nodes in the XPath data model.
+ *
+ * @param object the object to test
+ * @return {@code true} if {@code object} is a {@link Text} or {@link CData}
+ */
+ @Override
+ public boolean isText(Object object)
+ {
+ return object instanceof Text || object instanceof CData;
+ }
+
+ /**
+ * Parses {@code xpath} and returns a Jaxen {@link org.jaxen.XPath} instance backed
+ * by this navigator. Used internally by Jaxen to evaluate sub-expressions such as
+ * those inside predicates.
+ *
+ * @param xpath XPath expression string
+ * @return compiled Jaxen {@link org.jaxen.XPath}
+ * @throws SAXPathException if the expression cannot be parsed
+ */
+ @Override
+ public org.jaxen.XPath parseXPath(String xpath) throws SAXPathException
+ {
+ try
+ {
+ return new BaseXPath(xpath, INSTANCE);
+ }
+ catch (JaxenException e)
+ {
+ throw new SAXPathException(e.getMessage());
+ }
+ }
+}
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/xpath/XPath.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/xpath/XPath.java
new file mode 100644
index 0000000000..6a68d38368
--- /dev/null
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/xpath/XPath.java
@@ -0,0 +1,393 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.dom.xpath;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.tapestry5.dom.Attribute;
+import org.apache.tapestry5.dom.BoundXPath;
+import org.apache.tapestry5.dom.Document;
+import org.apache.tapestry5.dom.Element;
+import org.apache.tapestry5.dom.Node;
+import org.jaxen.BaseXPath;
+import org.jaxen.JaxenException;
+import org.jaxen.function.StringFunction;
+
+/**
+ * XPath engine to navigate Tapestry's DOM.
+ *
+ * Use the factory method {@link #of(String)} to create instances, then call one of
+ * the {@code select*} methods with a {@link Document} or {@link Node} as the context.
+ *
+ * For a node-first, fluent style without repeating the context, obtain a
+ * {@link BoundXPath} via {@link Node#xpath(String)} instead.
+ *
+ * @since 5.10
+ * @see Node#xpath(String)
+ * @see BoundXPath
+ */
+public class XPath
+{
+ private final BaseXPath delegate;
+
+ private static final XPath TEXT_DESCENDANTS = XPath.of("descendant::text()");
+
+ private XPath(String expression) throws JaxenException
+ {
+ this.delegate = new BaseXPath(expression, DocumentNavigator.INSTANCE);
+ }
+
+ /**
+ * Creates a new {@code XPath} for the given {@code expression}.
+ *
+ * @param expression XPath expression to compile; must not be {@code null} or empty
+ * @return an {@code XPath} instance
+ * @throws IllegalArgumentException if {@code expression} is {@code null} or empty
+ * @throws XPathException if the expression cannot be parsed
+ */
+ public static XPath of(String expression)
+ {
+ if (expression == null || expression.isEmpty())
+ {
+ throw new IllegalArgumentException("XPath expression must not be null or empty");
+ }
+
+ try
+ {
+ return new XPath(expression);
+ }
+ catch (JaxenException e)
+ {
+ throw new XPathException("Invalid XPath expression: " + expression, e);
+ }
+ }
+
+ /**
+ * Evaluates this instance against {@code node} and returns all matching DOM
+ * objects as Tapestry {@link Node} instances.
+ *
+ * @param node context {@link Document} or {@link Node} to evaluate against
+ * @return list of matching nodes; never {@code null}
+ * @throws XPathException if evaluation fails
+ */
+ @SuppressWarnings("unchecked")
+ public List selectNodes(Object node)
+ {
+ try
+ {
+ return (List) delegate.selectNodes(node);
+ }
+ catch (JaxenException e)
+ {
+ throw new XPathException("XPath evaluation failed", e);
+ }
+ }
+
+ /**
+ * Uses {@link #selectNodes(Object)} but assumes every result is an {@link Element}.
+ *
+ * @param node context {@link Document} or {@link Node}
+ * @return list of matched {@link Element}s, never {@code null}
+ * @throws XPathException if evaluation fails
+ */
+ @SuppressWarnings("unchecked")
+ public List selectElements(Object node)
+ {
+ try
+ {
+ return (List) delegate.selectNodes(node);
+ }
+ catch (JaxenException e)
+ {
+ throw new XPathException("XPath evaluation failed", e);
+ }
+ }
+
+ /**
+ * Uses {@link #selectNodes(Object)} but assumes every result is an {@link Attribute}.
+ *
+ * @param node context {@link Document} or {@link Node}
+ * @return list of matched {@link Attribute}s, never {@code null}
+ * @throws XPathException if evaluation fails
+ */
+ @SuppressWarnings("unchecked")
+ public List selectAttributes(Object node)
+ {
+ try
+ {
+ return (List) delegate.selectNodes(node);
+ }
+ catch (JaxenException e)
+ {
+ throw new XPathException("XPath evaluation failed", e);
+ }
+ }
+
+ /**
+ * Evaluates this instance and converts each matched node to its XPath string-value.
+ *
+ * @param node context {@link Document} or {@link Node}
+ * @return list of string values; never {@code null}
+ * @throws XPathException if evaluation fails
+ * @see XPath Spec: string-value
+ * @see #normalizedDescendantText(Object)
+ */
+ public List stringValuesOf(Object node)
+ {
+ try
+ {
+ List> resultNodes = delegate.selectNodes(node);
+
+ List resultStrings = new ArrayList(resultNodes.size());
+
+ for (Object result : resultNodes)
+ {
+ resultStrings.add(StringFunction.evaluate(result, DocumentNavigator.INSTANCE));
+ }
+
+ return resultStrings;
+ }
+ catch (JaxenException e)
+ {
+ throw new XPathException("XPath evaluation failed", e);
+ }
+ }
+
+ /**
+ * Returns {@link Element#getChildMarkup()} for each element matched by this instance.
+ *
+ * @param node context {@link Document} or {@link Node}
+ * @return list of child-markup strings; never {@code null}
+ * @throws XPathException if evaluation fails
+ */
+ public List selectElementsChildMarkup(Object node)
+ {
+ List selectedNodes = selectElements(node);
+
+ List childMarkup = new ArrayList(selectedNodes.size());
+
+ for (Element element : selectedNodes)
+ {
+ childMarkup.add(element.getChildMarkup());
+ }
+
+ return childMarkup;
+ }
+
+ /**
+ * Returns {@link Element#getAttribute(String)} for each element matched by this XPath.
+ *
+ * Unlike using XPath attribute selection, elements that lack the attribute contribute a
+ * {@code null} entry, so the result list is always the same size as the matched element list.
+ *
+ * @param node context {@link Document} or {@link Node}
+ * @param attributeName name of the attribute to retrieve from each matched element; must not be {@code null} or empty
+ * @return list of attribute values, with {@code null} for missing attributes; never {@code null}
+ * @throws IllegalArgumentException if {@code attributeName} is {@code null} or empty
+ * @throws XPathException if evaluation fails
+ */
+ public List selectElementsAttribute(Object node, String attributeName)
+ {
+ if (attributeName == null || attributeName.isEmpty())
+ {
+ throw new IllegalArgumentException("attributeName must not be null or empty");
+ }
+
+ List selectedNodes = selectElements(node);
+
+ List attributeValues = new ArrayList(selectedNodes.size());
+
+ for (Element element : selectedNodes)
+ {
+ attributeValues.add(element.getAttribute(attributeName));
+ }
+
+ return attributeValues;
+ }
+
+ /**
+ * Uses {@link #selectSingleNode(Object)} but assumes the result is an {@link Element}.
+ *
+ * @param node context {@link Document} or {@link Node}
+ * @return the first matched {@link Element}, or {@code null} if there is no match
+ * @throws XPathException if evaluation fails
+ */
+ public Element selectSingleElement(Object node)
+ {
+ return (Element) selectSingleNode(node);
+ }
+
+ /**
+ * Returns the first node matched by this instance, or {@code null} if there is no match.
+ *
+ * @param node context {@link Document} or {@link Node}
+ * @return the first matched node, or {@code null}
+ * @throws XPathException if evaluation fails
+ */
+ public Object selectSingleNode(Object node)
+ {
+ try
+ {
+ return delegate.selectSingleNode(node);
+ }
+ catch (JaxenException e)
+ {
+ throw new XPathException("XPath evaluation failed", e);
+ }
+ }
+
+ /**
+ * Selects a single node and concatenates all its descendant text nodes, then
+ * normalizes whitespace via {@link #normalizeText(String)}.
+ *
+ * Useful for getting an approximation of the text a browser would display.
+ *
+ * @param node context {@link Document} or {@link Node}
+ * @return normalised descendant text; never {@code null}
+ * @throws XPathException if evaluation fails
+ * @see #normalizeText(String)
+ */
+ public String singleNormalizedDescendantText(Object node)
+ {
+ Object foundNode = selectSingleNode(node);
+
+ List textNodeValues = TEXT_DESCENDANTS.stringValuesOf(foundNode);
+
+ return normalizeText(concatenate(textNodeValues));
+ }
+
+ /**
+ * Xoncatenates all descendant text nodes for each node matched by this XPath
+ * and normalizes whitespace via {@link #normalizeText(String)}.
+ *
+ * Useful for getting an approximation of the text a browser would display.
+ *
+ * @param node context {@link Document} or {@link Node}
+ * @return list of normalised text strings, one per matched node; never {@code null}
+ * @throws XPathException if evaluation fails
+ * @see #normalizeText(String)
+ */
+ public List normalizedDescendantText(Object node)
+ {
+ List nodes = selectNodes(node);
+
+ List results = new ArrayList(nodes.size());
+
+ for (Node foundNode : nodes)
+ {
+ List textNodeValues = TEXT_DESCENDANTS.stringValuesOf(foundNode);
+ results.add(normalizeText(concatenate(textNodeValues)));
+ }
+ return results;
+ }
+
+ /**
+ * Normalizes text from the DOM to approximate what a browser would display for
+ * Latin scripts: all whitespace characters (including non-breaking space) are
+ * replaced with a plain space, consecutive spaces are collapsed to one, and
+ * leading/trailing spaces are removed.
+ *
+ * This method is package-private so it can be tested.
+ *
+ * @param text raw text to normalise; may be {@code null} or empty
+ * @return normalised text, or the original value if it was {@code null} or empty
+ */
+ String normalizeText(String text)
+ {
+ if (text == null || text.isEmpty())
+ {
+ return text;
+ }
+
+ int len = text.length();
+ int start = 0;
+
+ // Skip leading whitespace without allocating anything
+ while (start < len && isWhitespace(text.charAt(start)))
+ {
+ start++;
+ }
+
+ if (start == len)
+ {
+ return "";
+ }
+
+ // Find trailing whitespace to reduce scope
+ int end = len - 1;
+ while (end >= start && isWhitespace(text.charAt(end)))
+ {
+ end--;
+ }
+
+ StringBuilder builder = new StringBuilder(end - start + 1);
+ boolean inWhitespace = false;
+
+ for (int i = start; i <= end; i++)
+ {
+ char c = text.charAt(i);
+ if (isWhitespace(c))
+ {
+ if (!inWhitespace)
+ {
+ builder.append(' ');
+ inWhitespace = true;
+ }
+ } else
+ {
+ builder.append(c);
+ inWhitespace = false;
+ }
+ }
+
+ return builder.toString();
+ }
+
+ // Private helper (JIT compiler will likely inline this)
+ private static boolean isWhitespace(char c) {
+ // 0x100002600L is a bitmask where bits 9, 10, 13, and 32 are set to 1
+ // Checks for: c == ' ' || c == '\t' || c == '\r' || c == '\n'
+ return (c <= 32 && (0x100002600L & (1L << c)) != 0) || c == '\u00A0';
+ }
+
+ private String concatenate(List strings)
+ {
+ if (strings.isEmpty())
+ {
+ return "";
+ }
+
+ if (strings.size() == 1)
+ {
+ return strings.get(0);
+ }
+
+ int totalLength = 0;
+ for (String s : strings)
+ {
+ if (s != null) totalLength += s.length();
+ }
+
+ StringBuilder builder = new StringBuilder(totalLength);
+
+ for (String string : strings)
+ {
+ builder.append(string);
+ }
+
+ return builder.toString();
+ }
+}
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/dom/xpath/XPathException.java b/tapestry-core/src/main/java/org/apache/tapestry5/dom/xpath/XPathException.java
new file mode 100644
index 0000000000..ae394a345c
--- /dev/null
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/dom/xpath/XPathException.java
@@ -0,0 +1,34 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.dom.xpath;
+
+/**
+ * Unchecked exception thrown when an XPath expression cannot be parsed or evaluated.
+ *
+ * Wraps Jaxen's checked {@link org.jaxen.JaxenException} so callers do not need to
+ * handle it explicitly.
+ *
+ * @since 5.10
+ */
+public class XPathException extends RuntimeException {
+
+ /**
+ * @param message human-readable description of the failure
+ * @param cause the underlying {@link org.jaxen.JaxenException}
+ */
+ public XPathException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/dom/BoundXPathTest.java b/tapestry-core/src/test/java/org/apache/tapestry5/dom/BoundXPathTest.java
new file mode 100644
index 0000000000..da362f2ccf
--- /dev/null
+++ b/tapestry-core/src/test/java/org/apache/tapestry5/dom/BoundXPathTest.java
@@ -0,0 +1,222 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.dom;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertIterableEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.tapestry5.dom.xpath.XPathException;
+import org.apache.tapestry5.test.PageTester;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+class BoundXPathTest {
+
+ private static Document page;
+
+ @BeforeAll
+ static void setup()
+ {
+ PageTester pageTester = new PageTester("org.apache.tapestry5.dom", "app");
+ page = pageTester.renderPage("XPathTest");
+ pageTester.shutdown();
+ }
+
+ @Nested
+ @DisplayName("Element selection")
+ class ElementSelection
+ {
+ @Test
+ void selectsAllMatchingElements()
+ {
+ List children = page.xpath("id('child-elements')/*").elements();
+
+ assertEquals(2, children.size());
+ assertEquals("Div Child", children.get(0).getChildMarkup());
+ assertEquals("Span Child", children.get(1).getChildMarkup());
+ }
+
+ @Test
+ void selectsSingleElement()
+ {
+ Element div = page.xpath("id('single-div')").singleElement();
+
+ assertEquals("single-div", div.getAttribute("id"));
+ }
+
+ @Test
+ void selectsSingleElementFromElementContext()
+ {
+ Element childElements = page.xpath("id('child-elements')").singleElement();
+ Element span = childElements.xpath("span").singleElement();
+
+ assertEquals("Span Child", span.getChildMarkup());
+ }
+
+ @Test
+ void selectsSingleElementReturnsNullWhenNotFound()
+ {
+ Element element = page.xpath("id('does-not-exist')").singleElement();
+
+ assertNull(element);
+ }
+ }
+
+ @Nested
+ @DisplayName("Node selection")
+ class NodeSelection
+ {
+ @Test
+ void selectsCommentNodes()
+ {
+ List comments = page.xpath("id('comment-parent')//comment()").nodes();
+
+ assertEquals(2, comments.size());
+ assertEquals("", comments.get(0).toString());
+ assertEquals("", comments.get(1).toString());
+ }
+
+ @Test
+ void selectsSingleNode()
+ {
+ Object node = page.xpath("id('single-div')").singleNode();
+
+ assertEquals("single-div", ((Element) node).getAttribute("id"));
+ }
+
+ @Test
+ void selectsSingleNodeReturnsNullWhenNotFound()
+ {
+ Object node = page.xpath("id('does-not-exist')").singleNode();
+
+ assertNull(node);
+ }
+ }
+
+ @Nested
+ @DisplayName("Attribute selection")
+ class AttributeSelection
+ {
+ @Test
+ void selectsAllAttributesOfElement()
+ {
+ // Tapestry outputs attributes in reverse template order (chain-of-attributes implementation),
+ // so the XPath attribute order here is: title, class, id.
+ List attrs = page.xpath("id('attr-with-id')/@*").attributes();
+
+ assertEquals(3, attrs.size());
+ assertAttributeEquals("title", "First", attrs.get(0));
+ assertAttributeEquals("class", "cls-first", attrs.get(1));
+ assertAttributeEquals("id", "attr-with-id", attrs.get(2));
+ }
+
+ @Test
+ void selectsAttributeValuePerElement()
+ {
+ // Elements without the requested attribute contribute null, keeping list size == element count.
+ List ids = page.xpath("id('attr-parent')/*").elementsAttribute("id");
+
+ assertEquals(Arrays.asList("attr-with-id", null), ids);
+ }
+ }
+
+ @Nested
+ @DisplayName("String and text methods")
+ class StringAndTextMethods
+ {
+ @Test
+ void returnsStringValuesOfAttributes()
+ {
+ List classes = page.xpath("id('attr-parent')//@class").stringValuesOf();
+
+ assertIterableEquals(Arrays.asList("cls-first", "cls-second"), classes);
+ }
+
+ @Test
+ void returnsChildMarkupPerElement()
+ {
+ List markup = page.xpath("id('child-elements')/*").elementsChildMarkup();
+
+ assertIterableEquals(Arrays.asList("Div Child", "Span Child"), markup);
+ }
+
+ @Test
+ void returnsSingleNormalizedDescendantText()
+ {
+ // The span contains a deliberate double-space that normalization collapses.
+ String text = page.xpath("id('mixed-text')").singleNormalizedDescendantText();
+
+ assertEquals("before inner text after", text);
+ }
+
+ @Test
+ void returnsNormalizedDescendantTextForMultipleNodes()
+ {
+ List text = page.xpath("id('multi-text')/div").normalizedDescendantText();
+
+ assertIterableEquals(Arrays.asList("left center right", "top middle bottom"), text);
+ }
+ }
+
+ @Nested
+ @DisplayName("Validation and exceptions")
+ class ValidationAndExceptions
+ {
+ @Test
+ void invalidExpressionThrowsXPathException()
+ {
+ assertThrows(XPathException.class, () -> page.xpath("^^^"));
+ }
+
+ @Test
+ void nullExpressionThrowsIllegalArgumentException()
+ {
+ assertThrows(IllegalArgumentException.class, () -> page.xpath(null));
+ }
+
+ @Test
+ void emptyExpressionThrowsIllegalArgumentException()
+ {
+ assertThrows(IllegalArgumentException.class, () -> page.xpath(""));
+ }
+
+ @Test
+ void nullAttributeNameThrowsIllegalArgumentException()
+ {
+ assertThrows(IllegalArgumentException.class, () ->
+ page.xpath("id('attr-parent')/*").elementsAttribute(null));
+ }
+
+ @Test
+ void emptyAttributeNameThrowsIllegalArgumentException()
+ {
+ assertThrows(IllegalArgumentException.class, () ->
+ page.xpath("id('attr-parent')/*").elementsAttribute(""));
+ }
+ }
+
+ private void assertAttributeEquals(String expectedName, String expectedValue, Attribute attribute)
+ {
+ assertEquals(expectedName, attribute.getName());
+ assertEquals(expectedValue, attribute.getValue());
+ }
+}
diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/dom/DOMTest.java b/tapestry-core/src/test/java/org/apache/tapestry5/dom/DOMTest.java
index 922c5bd7a1..d8d5806eb4 100644
--- a/tapestry-core/src/test/java/org/apache/tapestry5/dom/DOMTest.java
+++ b/tapestry-core/src/test/java/org/apache/tapestry5/dom/DOMTest.java
@@ -1,4 +1,4 @@
-// Copyright 2006, 2007, 2008, 2009, 2010, 2011, 2012 The Apache Software Foundation
+// Copyright 2006-2012, 2026 The Apache Software Foundation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -14,45 +14,51 @@
package org.apache.tapestry5.dom;
+import org.apache.tapestry5.MarkupWriter;
+import org.apache.tapestry5.commons.util.CollectionFactory;
+import org.apache.tapestry5.internal.services.MarkupWriterImpl;
+import org.junit.jupiter.api.Test;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Set;
+import java.util.stream.Collectors;
-import org.apache.tapestry5.MarkupWriter;
-import org.apache.tapestry5.commons.util.CollectionFactory;
-import org.apache.tapestry5.internal.services.MarkupWriterImpl;
-import org.apache.tapestry5.internal.test.InternalBaseTestCase;
-import org.testng.annotations.Test;
+import static org.junit.jupiter.api.Assertions.*;
/**
* Tests for a number of DOM node classes, including {@link org.apache.tapestry5.dom.Element} and {@link
* org.apache.tapestry5.dom.Document}.
*/
-public class DOMTest extends InternalBaseTestCase
+class DOMTest
{
@Test
- public void document_with_empty_root_element()
+ void document_with_empty_root_element()
{
Document d = new Document();
d.newRootElement("empty");
- assertEquals(d.toString(), "");
+ assertEquals("", d.toString());
}
@Test
- public void xml_style_empty_element()
+ void xml_style_empty_element()
{
Document d = new Document(new XMLMarkupModel());
d.newRootElement("empty");
- assertEquals(d.toString(), "\n");
+ assertEquals("\n", d.toString());
}
@Test
- public void namespaced_elements() throws Exception
+ void namespaced_elements() throws Exception
{
Document d = new Document(new XMLMarkupModel());
@@ -65,11 +71,11 @@ public void namespaced_elements() throws Exception
nested.elementNS("barneyns", "deepest");
- assertEquals(d.toString(), readFile("namespaced_elements.txt"));
+ assertEquals(readFile("namespaced_elements.txt"), d.toString());
}
@Test
- public void quote_using_apostrophes() throws Exception
+ void quote_using_apostrophes() throws Exception
{
Document d = new Document(new XMLMarkupModel(true));
@@ -84,11 +90,11 @@ public void quote_using_apostrophes() throws Exception
nested.elementNS("barneyns", "deepest");
- assertEquals(d.toString(), readFile("quote_using_apostrophes.txt"));
+ assertEquals(readFile("quote_using_apostrophes.txt"), d.toString());
}
@Test
- public void namespace_element_without_a_prefix() throws Exception
+ void namespace_element_without_a_prefix() throws Exception
{
Document d = new Document(new XMLMarkupModel());
@@ -104,11 +110,11 @@ public void namespace_element_without_a_prefix() throws Exception
barney.attribute("bettyns", "betty", "b");
barney.attribute("wilmans", "wilma", "c");
- assertEquals(d.toString(), readFile("namespace_element_without_a_prefix.txt"));
+ assertEquals(readFile("namespace_element_without_a_prefix.txt"), d.toString());
}
@Test
- public void default_namespace()
+ void default_namespace()
{
Document d = new Document(new XMLMarkupModel());
@@ -120,14 +126,14 @@ public void default_namespace()
root.attribute(namespaceURI, "gnip", "gnop");
- assertEquals(d.toString(), "\n");
+ assertEquals("\n", d.toString());
}
/**
* Also demonstrates that attributes are provided in alphabetical order.
*/
@Test
- public void document_with_root_element_and_attributes() throws Exception
+ void document_with_root_element_and_attributes() throws Exception
{
Document d = new Document();
@@ -136,11 +142,11 @@ public void document_with_root_element_and_attributes() throws Exception
e.attribute("fred", "flintstone");
e.attribute("barney", "rubble");
- assertEquals(d.toString(), readFile("document_with_root_element_and_attributes.txt"));
+ assertEquals(readFile("document_with_root_element_and_attributes.txt"), d.toString());
}
@Test
- public void nested_elements() throws Exception
+ void nested_elements() throws Exception
{
Document d = new Document();
@@ -150,29 +156,29 @@ public void nested_elements() throws Exception
p.attribute("first-name", "Fred");
p.attribute("last-name", "Flintstone");
- assertSame(p.getContainer(), e);
+ assertSame(e, p.getContainer());
p = e.element("person");
p.attribute("first-name", "Barney");
p.attribute("last-name", "Rubble");
- assertSame(p.getContainer(), e);
+ assertSame(e, p.getContainer());
- assertEquals(d.toString(), readFile("nested_elements.txt"));
+ assertEquals(readFile("nested_elements.txt"), d.toString());
}
- @Test(expectedExceptions = AssertionError.class)
- public void attribute_names_may_not_be_blank()
+ @Test
+ void attribute_names_may_not_be_blank()
{
Document d = new Document();
Element e = d.newRootElement("fred");
- e.attribute("", "value");
+ assertThrows(AssertionError.class, () -> e.attribute("", "value"));
}
@Test
- public void element_name_may_not_be_blank()
+ void element_name_may_not_be_blank()
{
Document d = new Document();
@@ -180,7 +186,7 @@ public void element_name_may_not_be_blank()
}
@Test
- public void attribute_value_null_is_no_op()
+ void attribute_value_null_is_no_op()
{
Document d = new Document();
@@ -190,19 +196,19 @@ public void attribute_value_null_is_no_op()
final String expected = "";
- assertEquals(d.toString(), expected);
+ assertEquals(expected, d.toString());
e.attribute("foo", null);
- assertEquals(d.toString(), expected);
+ assertEquals(expected, d.toString());
e.attribute("gnip", null);
- assertEquals(d.toString(), expected);
+ assertEquals(expected, d.toString());
}
@Test
- public void comments() throws Exception
+ void comments() throws Exception
{
Document d = new Document();
@@ -212,11 +218,11 @@ public void comments() throws Exception
e.comment(" Created by Tapestry 5.0 ");
- assertEquals(d.toString(), "");
+ assertEquals("", d.toString());
}
@Test
- public void text()
+ void text()
{
Document d = new Document();
@@ -224,11 +230,11 @@ public void text()
e.text("Tapestry does DOM.");
- assertEquals(d.toString(), "Tapestry does DOM.");
+ assertEquals("Tapestry does DOM.", d.toString());
}
@Test
- public void text_with_control_characters()
+ void text_with_control_characters()
{
Document d = new Document();
@@ -236,11 +242,11 @@ public void text_with_control_characters()
e.text(" & ");
- assertEquals(d.toString(), "<this> & <that>");
+ assertEquals("<this> & <that>", d.toString());
}
@Test
- public void specify_attributes_with_new_element()
+ void specify_attributes_with_new_element()
{
Document d = new Document();
@@ -248,11 +254,11 @@ public void specify_attributes_with_new_element()
e.element("foo", "alpha", "legion");
- assertEquals(d.toString(), "");
+ assertEquals("", d.toString());
}
@Test
- public void writef_with_text()
+ void writef_with_text()
{
Document d = new Document();
@@ -262,34 +268,34 @@ public void writef_with_text()
t.writef("** %s: %d **", "foo", 5);
- assertEquals(d.toString(), "Start: ** foo: 5 **");
+ assertEquals("Start: ** foo: 5 **", d.toString());
}
@Test
- public void get_element_by_id()
+ void get_element_by_id()
{
Document d = new Document();
Element e = d.newRootElement("root");
Element e1 = e.element("e1", "id", "x");
Element e2 = e.element("e2", "id", "y");
- assertSame(e1.getElementById("x"), e1);
- assertSame(e.getElementById("y"), e2);
+ assertSame(e1, e1.getElementById("x"));
+ assertSame(e2, e.getElementById("y"));
assertNull(e.getElementById("z"));
}
@Test
- public void get_child_markup()
+ void get_child_markup()
{
Document d = new Document();
Element e0 = d.newRootElement("root");
Element e1 = e0.element("e1");
e1.text("123");
- assertEquals(e1.getChildMarkup(), "123");
- assertEquals(e0.getChildMarkup(), "123");
+ assertEquals("123", e1.getChildMarkup());
+ assertEquals("123", e0.getChildMarkup());
}
@Test
- public void document_find_no_root_element()
+ void document_find_no_root_element()
{
Document d = new Document();
@@ -297,7 +303,7 @@ public void document_find_no_root_element()
}
@Test
- public void document_find_not_a_match()
+ void document_find_not_a_match()
{
Document d = new Document();
@@ -308,17 +314,17 @@ public void document_find_not_a_match()
}
@Test
- public void document_find_root_is_match()
+ void document_find_root_is_match()
{
Document d = new Document();
Element root = d.newRootElement("fred");
- assertSame(d.find("fred"), root);
+ assertSame(root, d.find("fred"));
}
@Test
- public void document_find_match()
+ void document_find_match()
{
Document d = new Document();
@@ -328,12 +334,12 @@ public void document_find_match()
Element barney = root.element("barney");
Element bambam = barney.element("bambam");
- assertSame(d.find("fred/barney/bambam"), bambam);
- assertSame(root.find("barney/bambam"), bambam);
+ assertSame(bambam, d.find("fred/barney/bambam"));
+ assertSame(bambam, root.find("barney/bambam"));
}
@Test
- public void document_find_no_match()
+ void document_find_no_match()
{
Document d = new Document();
@@ -348,7 +354,7 @@ public void document_find_no_match()
}
@Test
- public void insert_element_at()
+ void insert_element_at()
{
Document d = new Document(new XMLMarkupModel());
@@ -360,12 +366,12 @@ public void insert_element_at()
root.elementAt(1, "one").element("tiny");
root.elementAt(2, "two").element("bubbles");
- assertEquals(d.toString(),
- "\n");
+ assertEquals(
+ "\n", d.toString());
}
@Test
- public void force_attributes_overrides_existing()
+ void force_attributes_overrides_existing()
{
Document d = new Document(new XMLMarkupModel());
@@ -373,18 +379,18 @@ public void force_attributes_overrides_existing()
root.attributes("hi", "ho", "gnip", "gnop");
- assertEquals(root.toString(), "");
+ assertEquals("", root.toString());
root.forceAttributes("hi", "bit", "gnip", null);
- assertEquals(root.toString(), "");
+ assertEquals("", root.toString());
}
/**
* TAP5-708
*/
@Test
- public void namespace_element_force_attributes_overrides_existing()
+ void namespace_element_force_attributes_overrides_existing()
{
Document d = new Document(new XMLMarkupModel());
@@ -392,16 +398,16 @@ public void namespace_element_force_attributes_overrides_existing()
root.attributes("hi", "ho", "gnip", "gnop");
- assertEquals(root.toString(), "");
+ assertEquals("", root.toString());
root.forceAttributes("hi", "bit", "gnip", null);
- assertEquals(root.toString(), "");
+ assertEquals("", root.toString());
}
@Test
- public void raw_output()
+ void raw_output()
{
Document d = new Document(new XMLMarkupModel());
@@ -416,36 +422,38 @@ public void raw_output()
// The '<' and '>' are filtered into entities, but the '&' in is left alone (left
// raw).
- assertEquals(root.toString(), "< >");
+ assertEquals("< >", root.toString());
}
@Test
- public void dtd_with_markup()
+ void dtd_with_markup()
{
Document d = new Document(new XMLMarkupModel());
Element root = d.newRootElement("prime");
root.element("slag");
d.dtd("prime", "-//TF", "tf");
String expected = "\n";
- assertEquals(d.toString(), expected);
+ assertEquals(expected, d.toString());
}
@Test
- public void dtd_with_nullids()
+ void dtd_with_nullids()
{
Document d = new Document(new XMLMarkupModel());
d.newRootElement("prime");
+
d.dtd("prime", null, null);
- assertEquals(d.toString(), "\n");
+ assertEquals("\n", d.toString());
+
d.dtd("prime", "-//TF", null);
- assertEquals(d.toString(), "\n");
+ assertEquals("\n", d.toString());
d.dtd("prime", null, "tf");
- assertEquals(d.toString(), "\n");
+ assertEquals("\n", d.toString());
}
@Test
- public void markup_characters_inside_attributes_are_escaped()
+ void markup_characters_inside_attributes_are_escaped()
{
Document d = new Document(new XMLMarkupModel());
@@ -454,11 +462,11 @@ public void markup_characters_inside_attributes_are_escaped()
root.attribute("alpha-only", "abcdef");
root.attribute("entities", "\"<>&");
- assertEquals(root.toString(), "");
+ assertEquals("", root.toString());
}
@Test
- public void apostrophes_are_escaped()
+ void apostrophes_are_escaped()
{
Document d = new Document(new XMLMarkupModel(true));
@@ -466,40 +474,40 @@ public void apostrophes_are_escaped()
root.attribute("apostrophie", "some'thing");
- assertEquals(root.toString(), "");
+ assertEquals("", root.toString());
}
@Test
- public void add_class_names()
+ void add_class_names()
{
Document d = new Document(new XMLMarkupModel());
Element root = d.newRootElement("div");
- assertSame(root.addClassName("fred"), root);
+ assertSame(root, root.addClassName("fred"));
- assertEquals(root.toString(), "