<?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'
            ),
            ["&amp;", "&lt;", "&gt;"]
        ));

        // because everything is XML, HTML named entities like "&copy;" 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 "&copy;"
        // 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;
    }
}

?>