<?php declare( strict_types=1 );
// DOM Templating classes v20 © copyright (cc-by) Kroc Camen 2012-2021
// you may do whatever you want with this code as long as you give credit
// documentation at <camendesign.com/dom_templating>
/* Basic API:
new DOMTemplate (source, [namespaces])
(string)
to output the HTML / XML, cast the DOMTemplate object to a string,
e.g. `echo $template;`, or `$output = (string) $template;`
query (query)
make an XPath query
set (queries, [asHTML])
change HTML by specifying an array of ('XPath' => 'value')
setValue (query, value, [asHTML])
change a single HTML value with an XPath query
addClass (query, new_class)
add a class to an HTML element
append (query, content)
append content to the end of the *inside* an element
remove (query)
remove one or more HTML elements, attributes or classes
repeat (query)
return one (or more) elements as sub-templates:
next ()
append the sub-template to the list and reset its content
*/
namespace kroc;
// class DOMTemplateNode
//==============================================================================
// these methods are shared between the base `DOMTemplate` and the repeater
// `DOMTemplateRepeater`. for a good description of 'abstract', see
// <php.net/manual/en/language.oop5.abstract.php#95404>
//
abstract class DOMTemplateNode {
protected $DOMNode; // reference to the `DOMNode` being operated upon
private $DOMXPath; // an internal XPath object,
// so you don't have to manage one externally
protected $namespaces; // optional XML namespaces
// create_fragment : given plain-text, turn it into a small DOM tree
//--------------------------------------------------------------------------
private function create_fragment (string $text) : \DOMDocumentFragment {
$frag = $this->DOMNode->ownerDocument->createDocumentFragment ();
$frag->appendXML (
// if the source document is HTML, filter it
$this->type == DOMTemplate::HTML
? self::toXML ($text) : $text
);
return $frag;
}
// html_entity_decode : convert HTML entities back to UTF-8
//--------------------------------------------------------------------------
public function html_entity_decode (string $html): string {
// a table of HTML entites to reverse:
// '&', '<', '>' are removed so we don’t turn user text into working HTML!
//
// TODO: moving DOMTemplate to a namespace will allow us to use
// a private variable for the namespace, rather than per-instance
// as generating this table is slow
static $htmlentities = null;
// construct the HTML entities table:
// this function was added in PHP 5.3.4, but the extended HTML5
// entities list was not added until later (don't know which version)
//
// the full list of HTML5 entities, as per the spec, is listed here:
// <https://html.spec.whatwg.org/multipage/named-characters.html>
//
if (is_null( $htmlentities )) $htmlentities = array_flip( array_diff(
get_html_translation_table(
HTML_ENTITIES, ENT_NOQUOTES | ENT_HTML401, 'UTF-8'
),
["&", "<", ">"]
));
// because everything is XML, HTML named entities like "©" will
// cause blank output. we need to convert these named entities back
// to real UTF-8 characters (which XML doesn’t mind)
return str_replace (
array_keys ($htmlentities),
array_values ($htmlentities),
$html
);
}
// toXML : convert string input to safe XML for importing into DOM
//--------------------------------------------------------------------------
// TODO: even though this isn't static, we seem to be able to call it
// statically!?
public function toXML (string $text): string {
// [1] because everything is XML, HTML named entities like "©"
// will cause blank output. we need to convert these named entities
// back to real UTF-8 characters (which XML doesn’t mind)
$text = $this->html_entity_decode ($text);
// [2] properly self-close some elements
$text = preg_replace (
'/<(area|base|basefont|br|col|embed|hr|img|input|keygen|link|'.
'menuitem|meta|param|source|track|wbr)\b([^>]*)(?<!\/)>(?!<\/\1>)'.
'/is', '<$1$2 />', $text
);
// [3] convert HTML-style attributes (`<a attr>`)
// to XML style attributes (`<a attr="attr">`)
while (preg_match (
'/(?>(<(?!!)[a-z-]+(?:\s|[a-z-]+="[^"]*")+))([a-z-]+)(?=[>\s])/is',
$text, $m, PREG_OFFSET_CAPTURE
)) $text = substr_replace (
$text, $m[1][0].$m[2][0].'="'.$m[2][0].'"', $m[0][1],
strlen ($m[0][0])
);
// [4] properly escape JavaScript with CDATA
$text = preg_replace (
'/(<script[^>]*>)(.*?)(<\/script>)/is',
"$1<![CDATA[$2]]>$3", $text
);
return $text;
}
// shorthand2xpath : convert our shorthand XPath syntax to full XPath
//--------------------------------------------------------------------------
// actions are performed on elements using xpath, but for brevity
// a shorthand is also recognised in the format of:
//
// #id find an element with a particular ID
// (instead of writing `.//*[@id="…"]`)
// .class find an element with a particular class
// element#id enforce a particular element type
// (ID or class supported)
// #id@attr select the named attribute of the found element
// element#id@attr a fuller example
//
// note also:
// - you can test the value of attributes (e.g. '#id@attr="test"')
// this selects the element, not the attribute
// - sub-trees in shorthand can be expressed with '/',
// e.g. '#id/li/a@attr'
// - an index-number can be provided after the element name,
// e.g. 'li[1]'
//
public static function shorthand2xpath (
// a string to convert
string $query,
// by default, the converted XPath uses a relative prefix
// -- "//" -- to work around a bug in XPath matching.
// see <php.net/manual/en/domxpath.query.php#99760> for details
bool $use_relative = true
) : string {
// return from cache where possible
// (this doubles the speed of repeat loops)
static $cache = [];
if (isset ($cache[$query])) return $cache[$query];
// match the allowed format of shorthand
return $cache[$query] = preg_match (
'/^(?!\/)([a-z0-9:-]+(\[\d+\])?)?(?:([\.#])([a-z0-9:_-]+))?'.
'(@[a-z-]+(="[^"]+")?)?(?:\/(.*))?$/i',
$query, $m)
? // apply the relative prefix
($use_relative ? './/' : '').
// the element name, if specified, otherwise "*"
(@$m[1] ? @$m[1].@$m[2] : '*').
(@$m[4] ? ($m[3] == '#' // is this an ID?
? "[@id=\"${m[4]}\"]" // - yes, match it
// - no, a class. note that class attributes can contain
// multiple classes, separated by spaces, so we have to test
// for the whole-word, and not a partial-match
: "[contains(concat(' ', @class, ' '),\" ${m[4]} \")]"
) : '').
(@$m[5] ? (@$m[6] //optional attribute of the parent element
? "[${m[5]}]" //- an attribute test
: "/${m[5]}" //- or select the attribute
) : '').
(@$m[7] ? '/'.self::shorthand2xpath ($m[7], false) : '')
: $query;
}
// new DOMTemplateNode : instantiation
//--------------------------------------------------------------------------
// you cannot instantiate this class yourself, _always_ work through
// DOMTemplate! why? because you cannot mix nodes from different documents!
// DOMTemplateNodes _must_ come from DOMDocument kept privately inside
// DOMTemplate
//
public function __construct (
\DOMNode $DOMNode,
array $namespaces=[]
) {
// use a DOMNode as a base point for all the XPath queries
// and whatnot (in DOMTemplate this will be the whole template,
// in DOMTemplateRepeater, it will be the chosen element)
$this->DOMNode = $DOMNode;
$this->DOMXPath = new \DOMXPath ($DOMNode->ownerDocument);
// the painful bit: if you have an XMLNS in your template
// then XPath won’t work unless you:
// a. register a default namespace, and
// b. prefix element names in your XPath queries with this namespace
if (!empty ($namespaces)) foreach ($namespaces as $NS=>$URI)
$this->DOMXPath->registerNamespace ($NS, $URI)
;
$this->namespaces = $namespaces;
}
// query : find node(s)
//--------------------------------------------------------------------------
// note that this method returns a PHP DOMNodeList, not a DOMTemplateNode!
// you cannot use `query` and then use other DOMTemplateNode methods off
// of the result. the reason for this is because you cannot yet extend
// DOMNodeList and therefore can't create APIs that affect all the nodes
// returned by an XPath query
//
// TODO: we could use the "decorator" pattern, given as a response
// to me here: <https://bugs.php.net/bug.php?id=48352>
//
public function query (
// an XPath/shorthand (see `shorthand2xpath`) to search for nodes
string $query
) : \DOMNodeList {
// convert each query to real XPath: (multiple targets
// are available by comma separating queries)
$xpath = implode ('|', array_map (
['self', 'shorthand2xpath'], explode (', ', $query)
));
// run the real XPath query and return the DOMNodeList result
If ($result = @$this->DOMXPath->query ($xpath, $this->DOMNode)) {
return $result;
} else {
throw new \Exception ("Invalid XPath Expression: $xpath");
}
}
// set : change multiple nodes in a simple fashion
//--------------------------------------------------------------------------
public function set (
// an array of `'xpath' => 'text'` to find and set
array $queries,
// text is by-default encoded for safety against HTML injection,
// if this parameter is true then the text is added as real HTML
bool $asHTML = false
) {
foreach ($queries as $query => $value)
$this->setValue ($query, $value, $asHTML)
;
return $this;
}
// setValue : set the text on the results of a single xpath query
//--------------------------------------------------------------------------
public function setValue (
// an XPath/shorthand (see `shorthand2xpath`) to search for nodes
string $query,
// what text to replace the node's contents with
string $value,
// if the text should be safety encoded or inserted as HTML
bool $asHTML = false
) {
foreach ($this->query ($query) as $node) switch (true) {
// if the selected node is a "class" attribute,
case $node->nodeType == XML_ATTRIBUTE_NODE
&& $node->nodeName == 'class':
// add the className to it
$this->setClassNode ($node, $value);
break;
// if the selected node is any other element attribute,
case $node->nodeType == XML_ATTRIBUTE_NODE:
// set its value
$node->nodeValue = htmlspecialchars ($value, ENT_QUOTES);
break;
// if the text is to be inserted as HTML
// that will be included into the output
case $asHTML:
// remove existing element's content
$node->nodeValue = '';
// if supplied text is blank end here;
// you can't append a blank!
if (!$value) break;
// attach the HTML/XML fragment to the node
$node->appendChild (
$this->create_fragment($value)
);
break;
// otherwise,
default:
// encode the text to display as-is
$node->nodeValue = htmlspecialchars ($value, ENT_NOQUOTES);
}
return $this;
}
// addClass : add a className to an element,
// appending it to existing classes if they exist
//--------------------------------------------------------------------------
public function addClass (
string $query,
string $new_class
) {
// first determine if there is a 'class' attribute already?
foreach ($this->query ($query) as $node) if (
$node->hasAttributes () && $class = $node->getAttribute ('class')
) {
// if the new class is not already in the list, add it in
$this->setClassNode (
$node->attributes->getNamedItem ('class'), $new_class
);
} else {
// no class attribute to begin with, add it
$node->setAttribute ('class', $new_class);
}
return $this;
}
// add a className to an existing class attribute
// (this is shared between `setValue` & `addClass`)
private function setClassNode (
\DOMNode $DOMNode,
string $class
) : void {
// check if the class node already has the className (don't add twice)
if (!in_array ($class, explode (' ', $DOMNode->nodeValue)))
@$DOMNode->nodeValue = $DOMNode->nodeValue." $class"
;
}
// append: append content to the end of the *inside* an element
//--------------------------------------------------------------------------
public function append(
string $query, // node query to select element(s)
string $content // content to append
) {
// conver the plain-text to an
$frag = $this->create_fragment( $content );
// execute query, loop returned node(s) and
// attach the HTML/XML fragment to the node
foreach ($this->query ($query) as $node) $node->appendChild ($frag);
// chain...
return $this;
}
// remove : remove all the elements / attributes that match an xpath query
//--------------------------------------------------------------------------
public function remove (
// XPath query to select node(s) to remove:
//
// can be either a single string, or an array in the format of
// `'xpath' => true|false`. if the value is true then the xpath will
// be run and the found elements deleted. if the value is false then
// the xpath is skipped. why on earth would you want to provide an
// xpath, but not run it? because you can compact your code by
// providing the same array every time, but precompute the logic
//
// additionally, an array item that targets the class node of an HTML
// element (e.g. 'a@class') can, instead of using true / false for the
// value (as whether to remove the class attribute or not), provide a
// class name to remove from the class attribute, whilst retaining the
// other class names and the node; e.g.
//
// $DOMTemplate->remove ('a@class' => 'undesired');
//
$query // TODO: use union type or extra methods for string|array
) {
// if a string is provided, cast it into an array for assumption below
if (is_string ($query)) $query = [$query => true];
// loop the array, test the logic, and select the node(s)...
foreach ($query as $xpath => $logic) if ($logic) foreach (
$this->query ($xpath) as $node
) if (
// is this an HTML element attribute?
$node->nodeType == XML_ATTRIBUTE_NODE
) {
// is this an HTML class attribute, and has a className
// been given to selectively remove?
if ($node->nodeName == 'class' && is_string ($logic)) {
// reconstruct the class attribute value,
// sans the chosen className
$node->nodeValue = implode (' ',
array_diff (explode (' ', $node->nodeValue), [$logic])
);
// if there are classNames remaining, skip
// removing the whole class attribute
if ($node->nodeValue) continue;
}
// remove the whole attribute:
$node->parentNode->removeAttributeNode ($node);
} else {
// remove an element node, rather than an attribute node
$node->parentNode->removeChild ($node);
} return $this;
}
// output the source code (cast the object to a string)
//--------------------------------------------------------------------------
public function __toString (): string {
// get the document's code, we'll process it
// differently depending on desired output format
$source = $this->DOMNode->ownerDocument->saveXML (
// if you’re calling this function from the template-root
// we don’t specify a node, otherwise the DOCTYPE / XML
// prolog won’t be included
get_class ($this) == 'DOMTemplate' ? NULL : $this->DOMNode,
// expand all self-closed tags if for HTML
$this->type == 0 ? LIBXML_NOEMPTYTAG : 0
);
// XML is already used for the internal representation;
// if outputting XML no filtering is needed
//
// note that `$this->XML` and `$this::XML` don't work consistently
// between PHP versions and `self::XML` isn't working either,
// possibly due to this being either an abstract class definition)
if ($this->type == 1) return $source;
// fix and clean DOM's XML into HTML:
//----------------------------------------------------------------------
// self-close void HTML elements
// <https://html.spec.whatwg.org/#void-elements>
$source = preg_replace (
'/<(area|base|basefont|br|col|embed|hr|img|input|keygen|link|'.
'menuitem|meta|param|source|track|wbr)\b([^>]*)(?<!\/)><\/\1>/is',
'<$1$2 />', $source
);
// convert XML-style attributes (`<a attr="attr">`) to HTML-style
// attributes (`<a attr>`), this needs to be repeated until none are
// left as we must anchor each to the opening bracket of the element,
// otherwise content text might be hit too
while (preg_match (
'/(<(?!!)[^>]+\s)([a-z-]+)=([\'"]?)\2\3/im',
$source, $m, PREG_OFFSET_CAPTURE
)) $source = substr_replace (
$source, $m[1][0].$m[2][0], $m[0][1], strlen ($m[0][0])
);
// strip out CDATA sections
$source = preg_replace ('/<!\[CDATA\[(.*?)\]\]>/s', '$1', $source);
return $source;
}
// repeat : iterate a node
//--------------------------------------------------------------------------
// this will return a DOMTemplateRepeaterArray class that allows you to
// modify the contents the same as with the base template but also append
// the changed sub-template to the end of the list and reset its content
// to go again. this makes creating a list stunningly simple! e.g.
/*
$item = $DOMTemplate->repeat ('.list-item');
foreach ($data as $value) $item->setValue ('.', $value)->next ();
*/
public function repeat (
string $query
) : DOMTemplateRepeaterArray
{
// NOTE: the provided XPath query could return more than one element!
// `DOMTemplateRepeaterArray` therefore acts as a simple wrapper to
// propagate changes to all the matched nodes (`DOMTemplateRepeater`)
return new DOMTemplateRepeaterArray (
$this->query ($query), $this->namespaces
);
}
}
// class DOMTemplate : the overall template controller
//==============================================================================
class DOMTemplate extends DOMTemplateNode {
// internal reference to the PHP `DOMDocument` for the template's XML
private $DOMDocument;
// what type of data are we processing?
protected $type = self::HTML;
public const HTML = 0;
public const XML = 1;
// new DOMTemplate : instantiation
//--------------------------------------------------------------------------
public function __construct (
// a string of the HTML or XML to form the template
string $source,
// an array of XML namespaces if your document uses them,
// in the format of `'namespace' => 'namespace URI'`
array $namespaces=[]
) {
// detect the content type; HTML or XML,
// HTML will need filtering during input and output
// -- does this source have an XML prolog?
$this->type = substr_compare ($source, '<?xml', 0, 4, true) === 0
? self::XML : self::HTML
;
// load the template file to work with,
// it _must_ have only one root (wrapping) element; e.g. `<html>`
$this->DOMDocument = new \DOMDocument ();
if (!$this->DOMDocument->loadXML (
// if the source is HTML add an XML prolog
// to avoid mangling unicode characters, see
// <php.net/manual/en/domdocument.loadxml.php#94291>,
// also convert it to XML for PHP DOM use
$this->type == self::HTML
? "<?xml version=\"1.0\" encoding=\"utf-8\"?>".self::toXML ($source)
: $source,
// <https://www.php.net/manual/en/libxml.constants.php>
LIBXML_COMPACT | // libxml >= 2.6.21
LIBXML_NONET // do not connect to external resources
)) trigger_error (
"Source is invalid XML", E_USER_ERROR
);
// set the root node for all XPath searching
// (handled all internally by `DOMTemplateNode`)
parent::__construct ($this->DOMDocument->documentElement, $namespaces);
}
// output the document (cast the object to a string, i.e. `echo $template;`)
//--------------------------------------------------------------------------
public function __toString (): string {
// if the input was HTML, remove the XML prolog on output
return $this->type == self::HTML
? // we defer to DOMTemplateNode which returns the HTML for any node,
// the top-level template only needs to consider the prolog
preg_replace ('/^<\?xml[^<]*>\n/', '', parent::__toString ())
: parent::__toString ();
}
}
// class DOMTemplateRepeaterArray : allow repetition over multiple nodes
//==============================================================================
// this is just a wrapper to handle that `repeat` might be executed on more
// than one element simultaneously; for example, if you are producing a list
// that occurs more than once on a page (e.g. page number links in a forum)
//
class DOMTemplateRepeaterArray {
private $nodes;
public function __construct (
\DOMNodeList $DOMNodeList,
array $namespaces=[]
) {
// convert the XPath query result into extended `DOMTemplateNode`s
// (`DOMTemplateRepeater`) so that you can modify the HTML with
// the same usual DOMTemplate API
foreach ($DOMNodeList as $DOMNode)
$this->nodes[] = new DOMTemplateRepeater ($DOMNode, $namespaces)
;
}
public function next () {
// cannot use `foreach` here because you shouldn't
// modify the nodes whilst iterating them
for ($i=0; $i<count ($this->nodes); $i++) $this->nodes[$i]->next ();
return $this;
}
// refer to `DOMTemplateNode->set`
public function set (
string $queries,
bool $asHTML = false
) {
foreach ($this->nodes as $node) $node->set ($queries, $asHTML);
return $this;
}
// refer to `DOMTemplateNode->setValue`
public function setValue (
string $query,
string $value,
bool $asHTML = false
) {
foreach ($this->nodes as $node)
$node->setValue ($query, $value, $asHTML)
;
return $this;
}
// refer to `DOMTemplateNode->addClass`
public function addClass (
string $query,
string $new_class
) {
foreach ($this->nodes as $node) $node->addClass ($query, $new_class);
return $this;
}
// refer to `DOMTemplateNode->remove`
public function remove (
string $query
) {
foreach ($this->nodes as $node) $node->remove ($query);
return $this;
}
}
// class DOMTemplateRepeater : the business-end of `DOMTemplateNode->repeat`!
//==============================================================================
class DOMTemplateRepeater extends DOMTemplateNode {
private $refNode; // the templated node will be added after this node
private $template; // a copy of the original node to work from each time
protected $type;
public function __construct (
\DOMNode $DOMNode,
array $namespaces=[]
) {
// we insert the templated item after the reference node,
// which will always be the last item that was templated
$this->refNode = $DOMNode;
// take a copy of the original node that we will use
// as a starting point each time we iterate
$this->template = $DOMNode->cloneNode (true);
// initialise the template with the current, original node
parent::__construct ($DOMNode, $namespaces);
}
public function next (): this {
// when we insert the newly templated item,
// use it as the reference node for the next item and so on
$this->refNode =
($this->refNode->parentNode->lastChild === $this->DOMNode)
? $this->refNode->parentNode->appendChild ($this->DOMNode)
// if there's some kind of HTML after the reference node, we can
// use that to insert our item inbetween. this means that the list
// you are templating doesn't have to be wrapped in an element!
: $this->refNode->parentNode->insertBefore (
$this->DOMNode, $this->refNode->nextSibling
)
;
// reset the template
$this->DOMNode = $this->template->cloneNode (true);
return $this;
}
}
?>