Skip to content

html5qp() documents lose the closing "?" of a PHP block when serialized with html()/writeHTML() #86

Description

@jakejackson1

Description of the bug

A document parsed with html5qp() loses the closing ? of every processing instruction when it is
serialized with html() or writeHTML(). A <?php … ?> block comes back out as <?php … >, which
is not valid PHP.

$html = '<html><body><h1><?php echo $title; ?></h1></body></html>';

echo html5qp($html)->top()->html();
// <h1><?php echo $title; ></h1>      <-- terminator dropped

echo htmlqp($html)->top()->html();
// <h1><?php echo $title; ?></h1>     <-- correct

The same happens with writeHTML(). Note this is the mirror image of #65: there the terminator was
doubled, here it is dropped.

Scope

Only two methods, and only for html5qp(). Every other serializer is fine, because
html5()/writeHTML5() go through Masterminds and the xml()/inner*() family go through
libxml's XML serializer — both of which append their own ?>.

htmlqp() html5qp() qp() (XML)
html() ok dropped ok
writeHTML() ok dropped ok
html5(), writeHTML5() ok ok ok
xml(), writeXML() ok ok ok
innerHTML(), innerHTML5(), innerXML() ok ok ok

This is pre-existing, not a regression — it reproduces on 4.1.0 and on main. It is called out
separately from #65 because the two have opposite symptoms and the fix for one does not reach the
other.

Root cause

libxml's HTML serializer writes a processing instruction verbatim as <?target data> and, unlike
its XML serializer and the Masterminds serializer, never appends the closing ? itself. It relies
on the terminator already being part of the node's data, which is exactly what libxml's HTML
parser leaves there.

Masterminds does not leave it there, so the two do not pair up:

$html5 = new Masterminds\HTML5();
$doc   = $html5->loadHTML('<html><body><h1><?php echo $t; ?></h1></body></html>');

$doc->saveHTML();      // <h1><?php echo $t; ></h1>     libxml drops it
$html5->saveHTML($doc); // <h1><?php echo $t; ?></h1>    Masterminds appends it

Why the fix in #68 does not cover this

#68 normalises PI data on load so that no parser leaves the terminator in data, and re-adds it
around saveHTML()/saveHTMLFile() — the one output path that needs it. It knows a document is
normalised because QueryPath parses into a QueryPath\Document subclass; the type is the marker.

QueryPath::withHTML5() hands the parse to Masterminds, which builds a plain DOMDocument. That
document does satisfy the invariant — Masterminds never writes the terminator into data — but
QueryPath has no way to say so, so saveDocumentHTML() treats it like a caller-supplied document
and writes it as-is.

Candidate fixes

Both verified locally against the current issue-65 branch.

1. Parse into a QueryPath\Document via Masterminds' target_document option. Masterminds
honours DOMTreeBuilder::OPT_TARGET_DOC, so withHTML5() can hand it a QueryPath\Document and
the terminator problem disappears. The catch is the doctype: Masterminds only creates
<!DOCTYPE html> when it creates the document itself, so with a target document $doc->doctype
is NULL and html()/writeHTML() lose the doctype they emit today. html5()/writeHTML5() are
unaffected, since Masterminds' own serializer writes the doctype unconditionally. Attaching a
doctype afterwards does not work — DOMDocument::importNode() returns false for a
DOMDocumentType, and the only way to attach one is DOMImplementation::createDocument(), which
returns a plain DOMDocument rather than the subclass. So this route needs a way to give the
target document a doctype first.

2. Mark the document through registerNodeClass('DOMElement', …) instead of the document type.
Unlike registerNodeClass('DOMDocument', …), which has no effect on a document whose wrapper is
already live, registering an element class works retroactively on a Masterminds document and leaves
the doctype intact:

$doc->registerNodeClass('DOMElement', Marker::class);
$doc->documentElement instanceof Marker;  // true
$doc->doctype->name;                      // 'html'

The cost is that it changes the class of every element node a caller gets back, which is a much
larger surface change than a marker on the document.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions