Search code examples
phpdomdomdocumentxmldom

How can I replace a DOMNode with a DOMNodeList in PHP?


Up until now I've been able to replace nodes pretty easily because I've only needed to replace them 1:1 and because they were only text. I was using something like this:

$element->parentNode->replaceChild($element->ownerDocument->createTextNode($value),$element);

The problem now is that I need to accept strings that may or may not include some HTML. For instance I can no longer use createTextNode() with the string:

This is some <span style="font-weight:bold;"></span> text.

because I'll end up with a mix of html entities in my actual html. Nor can I do this one:

<p>Paragraph 1</p>
<p>&nbsp;</p>
<p>Paragraph 3</p>

I've revised my code to the following, the first part creates a new dom node by importing the text/html mix with a wrapper I can use to pull it back out as a node with, and the second part imports the new <fubar> DOMNode, and replaces the original node with it:

$temp = new DOMDocument('1.0','UTF-8');
$temp->loadHTML('<fubar id="replacement">'.$val.'</fubar>');
$replacement = $temp->getElementById('replacement');

$replacement = $element->ownerDocument->importNode($replacement, TRUE);
$element->parentNode->replaceChild($replacement,$element);

The problem that remains, which I can't get my head around, is that the document now contains all of the new node including the <fubar> element, but it's the only way to do the 1:1 replacement because replaceChild() requires the parameter to be a DOMNode, so I can't use the nodes DOMNodeList of children directly.

What is the easiest solution to either remove the <fubar> node but keep its child nodes (the actual content I want), or to replace the original node with multiple nodes directly?


EDIT: The complete intention would be to take:

<html>
    <body>
        <p>Opening content....<placeholder>REPLACE_ME_FIRST</placeholder></p>
        <placeholder>REPLACE_ME_SECOND</placeholder>
        <p>Closing content....</p>
    </body>
</html>

then replace the <placeholder>REPLACE_ME_FIRST</placeholder> with...

This is some <span style="font-weight:bold;"></span> text.

and replace the <placeholder>REPLACE_ME_SECOND</placeholder> with...

<p>Paragraph 1</p>
<p>&nbsp;</p>
<p>Paragraph 3</p>

Resulting in:

<html>
    <body>
        <p>Opening content....This is some <span style="font-weight:bold;"></span> text.</p>
        <p>Paragraph 1</p>
        <p>&nbsp;</p>
        <p>Paragraph 3</p>
        <p>Closing content....</p>
    </body>
</html>

... and in my original question, in the code example, $element would represent the <placeholder> node.


Solution

  • Thanks to some conversation in the OP comments, I was able to come up with the following replacement strategy which remains performant and compatible with all of the examples I posed.

    $temp = new DOMDocument('1.0', 'UTF-8');
    $temp->loadHTML('<fubar id="replacement">'.$val.'</fubar>');
    $replacement = $temp->getElementById('replacement');
    
    // If element is a text node just add a new node with the value, otherwise if it's an element with child nodes, iterate over them adding them to a fragment which can be imported as a whole.
    if ($replacement->nodeType === XML_TEXT_NODE || ($replacement->nodeValue && $replacement->childNodes->length === 1 && $replacement->childNodes->item(1) === NULL)) {
        // Text Node
        $new_node = $element->ownerDocument->createTextNode($replacement->nodeValue);
    } else {
        // Node List
        $new_node = $element->ownerDocument->createDocumentFragment();
        $children = $replacement->childNodes->length - 1;
        for ($i = 0; $i <= $children; $i++) {
            $child = $element->ownerDocument->importNode($replacement->childNodes->item($i), TRUE);
            $new_node->appendChild($child);
        }
    }
    $element->parentNode->replaceChild($new_node,$element);
    unset($replacement);
    unset($temp);
    

    --- N.B. ---

    I struggled a LOT with the iteration over childNodes. I was able to see the childNodes existed in $replacement but they seemed to always be empty.

    That is until I realized that the documentFragment needed to be created in the original element's doc rather than the temp one, AND the new child appended AFTER importing to the doc.

    The root cause was that the child node ($replacement->childNodes->item($i)) couldn't be appended to a doc that it already existed in.