PDA

View Full Version : Modular DOMDocument template engine



ralph l mayo
01-10-2006, 10:40 PM
This is for PHP 5 only (I think)

I won't go into the pros and cons of using a templating engine much except to say that it's a rather roundabout way of doing things, but that I think it pays dividends later on in maintainability. This one works with the XML standard, and doesn't need an appreciable modification to work with other XML compliant programs, like XHTML editors. If you have any questions or suggestions I'm open to both, but keep in mind this is pared down for simplicity's sake and isn't exactly indicative of what I'm really doing, just of the general idea.

Note if you do use a template engine it becomes very important to keep users from using your tags and/or to keep user input from being parsed. In this case, for example, if a user posts a message with <bd:if condition="maliciouscode"> and it makes it into the parser, you've got huge problems.

re: The "if", XML is a storage format and it's generally not really a good idea to impose logic structures on it, so caveat emptor

Also, this is a multi-parter, so hang on. It's not too tough.

First, an XML file to be read in and processed:

domtest.xml


<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:bd="http://example.com/dtd" xml:lang="en" lang="en">
<!-- the important part of this line is the xmlns:bd property. bd can be anything, it will be your prefix below
the URL defined here doesn't have to exist until you want to add validation to your template engine, but it
has to match the value in DOMTemplate.class.php -->
<head>
<title>Welcome to <bd:variable name="sitename" /></title>
</head>
<body>
<p>Last month was: <bd:date format="F" when="last month" /></p>
<bd:if condition="microtime(true)%2 == 0">
<p>Happy even microsecond!</p>
</bd:if>
<bd:else>
<p>Oh well, maybe next refresh.</p>
</bd:else>
</body>
</html>


Next, the interface used by the engine itself and its modules:

XMLTags.interface.php


<?php
interface XMLTags
{
/* returns an array containing all the tags the object is responsible for.
it may not duplicate or override any other object's tags */
public function describe();
/* returns a DOMNode that should replace the input DOMNode in the template */
public function doTag($DOMNode);
}
?>


Next, the actual templating engine.

DOMTemplate.class.php


<?php
if (!defined('INCLUDED_DOMTEMPLATE'))
{
define('INCLUDED_DOMTEMPLATE', 1);

class DOMTemplate implements XMLTags
{
/*private*/ var $dom;
/*private*/ var $processed;
/*private*/ var $handledtags;
/*private*/ var $showelse;
/*public*/ var $registeredvars;
/*public*/ var $cdatare;


public function __construct($fromfile = false)
{
$this->cdatare = array(); // inserted nodes in the form <![CDATA[key]]> will be replaced with value
$this->processed = false; // true only when the template has been parsed of the relevant tags
$this->showelse = false; // since the condition isn't included with the else statement, keep track of whether it should appear or not
$this->registeredvars = array(); // add variables to this array to make them accessible from the template
$this->handledtags = array(); // populated by the addTagHandler() function

/* register the built in tags */
$this->addHandler($this);

/* XML 1.0, UTF-8 encoding. Doesn't appear to do anything except change the
values of the XML declaration */
$this->dom = new DOMDocument('1.0', 'utf-8');

if ($fromfile)
{
$this->loadTemplate($fromfile);
}
}

/**
read a file into the DOMDocument object
*/
public function loadTemplate($filename)
{
/* change this to loadHTMLFile() to allow malformed documents. I use XHTML,
which should be well formed anyway, and I'd rather it catch some errors here
that will show up in validation anyway */
$this->dom->load($filename);
}

/**
step through the tags in our namespace and call their associated methods to get
replacements for them
*/
public function process()
{
/* this URL must match that in your XML document's namespace declaration (xmlns)
but as it is the URL doesn't necessarily have to exist. '*' is a wildcard. */
$elements = $this->dom->getElementsByTagNameNS('http://example.com/dtd', '*');

/* don't be tempted to foreach if you're modifying the document in the loop, or
things will get messy */
while ($elements->length > 0)
{
/* get the first occurance of a tag in our namespace */
$element = $elements->item(0);
/* if it's handled ... */
if (array_key_exists(strtolower($element->localName), $this->handledtags))
{
/* ... get a replacement node from the responsible object ... */
$rnode = $this->handledtags[strtolower($element->localName)]->doTag($element);
/* ... import it into our document ... */
$newnode = $this->dom->importNode($rnode, true);
/* ... and make the switch. */
$element->parentNode->replaceChild($newnode, $element);
}
else
{
/* You can handle this however you want. It should only really happen during
development, so as long as you and any others helping with the project
know what's going on. */
throw new Exception('DOMTemplate::process : tag "' . strtolower($element->localName) . '" is unhandled.');
}
}
$this->processed = true;
}

/**
as per interface, return an array of the handled tags. not so necessary here, but needed in modules
*/
public function describe()
{
return array('if', 'variable', 'else', 'include');
}

/**
returns a DOMNode object suitable to replace the node passed to it as $element
*/
public function doTag($element)
{
/* get a reference to the whole document that can be used to create new nodes */
$parent = $element->ownerDocument;

switch (strtolower($element->localName))
{
case 'variable':
/* TextNodes are just what they sound like. XML sensitive chars like < > are
automatically escaped to &lt; etc */
$replacement = (isset($this->registeredvars[$element->getAttribute('name')])) ? $this->registeredvars[$element->getAttribute('name')] : '';
return $parent->createTextNode($replacement);
case 'if':
eval('$bool = ' . $element->getAttribute('condition') . ';');
/* save the opposite of the result in case there's an else block coming up */
$this->showelse = !$bool;
if ($bool)
{
/* copy the contents of this block into the DOMDocumentFragment */
return $this->fillDocFrag($parent, $element->childNodes);
}
else
{
/* rather than deleting the node and breaking doTag's expected behavior,
just return a blank text node to replace it with */
return $parent->createTextNode('');
}
case 'else':
if ($this->showelse)
{
/* reset our bool */
$this->showelse = false;

return $this->fillDocFrag($parent, $element->childNodes);
}
else
{
return $parent->createTextNode('');
}
case 'include':
if (!is_readable($src = $element->getAttribute('src')))
{
throw new Exception ('DOMTemplate::doTag : "' . $element->getAttribute('src') . '" could not be opened for inclusion.');
}
if (strtolower($element->getAttribute('parse')) != 'true')
{
$fh = @fopen($src, 'r');
$inc = @fread($fh, @filesize($src));
@fclose($inc);
/* anything added to $this->cdatare['id'] is replaced with the value if a
matching CDATA section is found. this allows you to include badly formed
HTML chunks */
$this->cdatare[($key = md5(uniqid(rand(), true)))] = $inc;
return $parent->createCDATASection($key);
}
else
{
$inc = new DOMDocument();
$inc->load($src);
$newnodes = $inc->getElementsByTagName('*');
return $this->fillDocFrag($parent, $newnodes);
}
default:
/* this isn't normally reachable unless the handledtags array in the constructor
wrongly indicates something that isn't done here, so it's something else
you'll see in development */
throw new Exception('DOMTemplate::doTag : No action is defined for ostensibly handled tag "' . strtolower($element->localName) . '".');
}
}

/**
a utility function to get the nodes from $src into a DocumentFragment in $parent
*/
private function fillDocFrag($parent, $src)
{
$docfrag = $parent->createDocumentFragment();

foreach ($src as $cn)
{
/* clone each node because copying isn't implied by appendChild */
$copy = $cn->cloneNode(true);
$docfrag->appendChild($copy);
}

return $docfrag;
}

/**
populate $handltags with the responsibilities claimed by the $obj, which should implement XMLTags
*/
public function addHandler($obj)
{
if (!is_array($tags = $obj->describe()))
{
throw new Exception('DOMTemplate::addHandler - interface error: couldn\'t get responsibilities description.');
}
foreach ($tags as $tag)
{
if (isset($this->handledtags[$tag]))
{
throw new Exception('DOMTemplate::addHandler - duplicate responsibilities described for tag ' . $tag . '.');
}
$this->handledtags[$tag] = $obj;
}
}

/**
Kind of a built in plaintext template engine, useful to add chunks of plaintext without the automatic entity
conversion
*/
function plainTextReplace($doctext)
{
foreach ($this->cdatare as $id=>$text)
{
$doctext = str_replace('<![CDATA[' . $id . ']]>', $text, $doctext);
}
return $doctext;
}

/**
returns the processed template body
*/
public function render()
{
/* if process() wasn't called manually, do it now */
if (!$this->processed)
{
$this->process();
}
/* remove the extraneous namespace declarations DOMDocument slathers all over everything */
$doctext = preg_replace('/(?<!html) xmlns=".*?"/', '', $this->dom->saveXML());
/* remove your namespace or it will cause a validation error (why? I don't know) */
$doctext = str_replace('xmlns:bd="http://example.com/dtd" ', '', $doctext);
return $this->plainTextReplace($doctext); // replace any matching CDATA tags
}
}
}
?>


Here's the modular part, or a quick example thereof. You can implement XMLTags to do new things without messing around with the engine code (although in this case you might want to mess with the core a bit since it's so minimal). This module creates a path from the XML tag prefix:date to PHP's date() and strtotime() functions:

dateTimeTags.class.php


<?php
if (!defined('INCLUDED_DATETIMETAGS'))
{
define('INCLUDED_DATETIMETAGS', 1);

class dateTimeTags implements XMLTags
{
public function describe()
{
/* this must be an array even if there's only one tag */
return(array('date'));
}

public function doTag($element)
{
$parent = $element->ownerDocument;

switch (strtolower($element->localName))
{
case 'date':
/* use the specified formatting if it's available, else a generic string */
$format = ($element->hasAttribute('format')) ? $element->getAttribute('format') : 'l, F j g:iA';
/* if the when attribute is available and it's a number, pass it, if it's
a string, run it through strtotime(), if it's not set, assume now */
$when = ($element->hasAttribute('when')) ? (is_numeric($element->getAttribute('when')) ? $element->getAttribute('when') : strtotime($element->getAttribute('when'))) : time();
return $parent->createTextNode(date($format, $when));
default:
throw new Exception('dateTimeTags::doTag : No action is defined for ostensibly handled tag "' . strtolower($element->localName) . '".');
}
}
}
}
?>


And finally, a file to pull it all together and make the required connections to set things rolling:

index.php


<?php
include('XMLTags.interface.php');
include('DOMTemplate.class.php');
include('dateTimeTags.class.php');

/* instantiations */
$template = new DOMTemplate('domtest.xml');
$dt = new dateTimeTags();

/* setup */
$template->registeredvars['sitename'] = 'Web 1.0 SP2';
$template->addHandler($dt);

/* delayed gratification */
echo $template->render();
?>


Example output:



<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<!-- the important part of this line is the xmlns:bd property. bd can be anything, it will be your prefix below
the URL defined here doesn't have to exist until you want to add validation to your template engine, but it
has to match the value in DOMTemplate.class.php -->
<head><meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Welcome to Web 1.0 SP2</title>
</head>
<body>
<p>Last month was: December</p>

<p>Happy even microsecond!</p>



</body>
</html>


Worth every penny!

missing-score
01-12-2006, 10:00 PM
Thats quite nice :D. It might sound weird, but the worst thing about it is that you would have to use valid XML in order for it to work, so you couldn't really have partial templates unless they all were a full valid XML section.

Also, where you use your ternary ($x->getAttribute('attr')) ?, you could also use (and would be semantically better to use):



($x->hasAttribute('attr'))

ralph l mayo
01-13-2006, 12:41 AM
Thanks for the hasAttribute() thing, that makes more sense.

I ran into some problesm with the only valid XML restriction too, so I added a tag that allows importing arbitrary fragments. Now only the main template has to be valid XML, and you can import snippets of any description.

I didn't include an example because I'd have to add yet another file, but the usage is:


<!-- parse="true" requires valid XML and is necessary if there are further template tags in the file-->
<bd:include src="valid.xml" parse="true" />
<!-- simple replace no matter what the file contents -->
<bd:include src="foo.html" />

The Reverend
11-30-2006, 08:09 AM
I'm playing around with this so I can use it on my website. What I'm trying to do is add in another tag that allows for the use of foreach if I use some arrays. I'm fairly unfamiliar with a lot of these XML functions so I'm struggling to get this code right.

I want to be able to take code like this in the template:


<bd:foreach select="content">
<p><bd:variable name="content" /></p>
</bd:foreach>

This is assuming content has been passed in as an array. I can't figure out how to do this. I'm also trying to figure out how to do it in a way where I could use multiple arrays to create a table:


<bd:foreach select="variable">
<tr><td><bd:variable name="variable" /></td><td><bd:variable name="variable2" /></td></tr>
</bd:foreach>

I'm struggling to create the right code to handle this. I've gotten to the point where I could get it to just print out the array, but I don't know how to get it to access the code between the foreach tags and evaluate it properly.


case 'foreach':
if(isset($this->registeredvars[$element->getAttribute('select')]))
{
foreach($this->registeredvars[$element->getAttribute('select')] as $value)
{
//the code between the tags evaluated as an array
}
}

Sorry to bump such an old thread for this, but this is giving me headaches like you wouldn't believe.:thumbsup:

ralph l mayo
11-30-2006, 08:51 PM
I really wouldn't recommend using this. Try out Smarty or one of the other zillion templating engines that are maintained & supported.