I haven’t yet quite found an XML/XSLT processing framework to really handle the extensibility and versatility I needed for my framework, I had to do some research on what already existed to see how I could use the ideas I’ve found to put together something that really worked for me.
My requirements were as follows:
- Be able to dynamically assign variables to <xsl:variable /> tags.
- Use a layered approach to templates (and by this, I mean have layers like ‘html_above’ and ‘html_below’ which could contain the opening <html> and <head> tags and also the closing </body> and </html> tags, respectively).
- Be able to load multiple xsl stylesheets using <xsl:include />.
- Set a primary ‘sub template’ which the layers would then surround.
- Be able to flow in with the existing framework that I’ve already coded.
- Easily convert/add data to an XML tree for processing by XSLTProcessor.
- Be able to localize language strings.
These requirements would allow the most flexibility in generating dynamic content for a wide range of different applications, and would allow the greatest easy in templating for me.
Before I really dive into what I came up with, let me say that I am completely new to XSLT processing, so I very well may have gone above and beyond what was needed for my requirements, however it works quite well so far and was a great learning experience for me.
The first step was create a shell class with some variables I would need to track my information. The ‘Object’ class that this extends is a base class in my framework that handles most of PHP’s magic methods for objects (get, set, etc). One variable not listed below is $properties, which exists in the ‘Object’ class and is accessed via get/set magic methods. This part will become essential for setting <xsl:param /> tags.
class Dom extends Object
{
/**
* @var array Array of stylesheet files to include
*/
private $templates = array();
/**
* @var array Array of layers to apply around the main template
*/
private $layers = array();
/**
* @var string Main xslt template to call
*/
private $subTemplate;
/**
* @var string suffix to use when array has invalid keys
*/
private $listSuffix = '-list';
/**
* @var XSLTProcessor the xslt object used for the transformation
*/
private $xslt;
/**
* @var DOMDocument DOM object used to hold XML data
*/
private $dom;
/**
* @var DOMNode Root node of the tree
*/
private $rootNode;
/**
* @var string Encoding to use for document (defaults to utf-8
*/
private $encoding = 'UTF-8';
/**
* @var bool Determine wheter or not to output raw xml or not
*/
public $xml = FALSE;
/**
* @var int The id in the registry of the shutdown function call in case we want to not use DOM
*/
private $shutdownId;
CONST TYPE_ARRAY_LIST = 1;
CONST TYPE_ARRAY = 2;
CONST TYPE_DOM = 3;
CONST TYPE_DOMDOCUMENT = 4;
CONST TYPE_DOMNODE = 5;
CONST TYPE_OBJECT = 6;
CONST TYPE_BOOL = 7;
CONST TYPE_STRING = 8;
public function __construct()
{
parent::__construct();
$this->shutdownId = $this->registry->register_shutdown_function(array($this, 'renderDom'));
$this->encoding = 'UTF-8';
$this->dom = new DOMDocument('1.0', 'UTF-8');
$this->xslt = new XSLTProcessor();
$this->rootNode = $this->createElement('root');
$this->appendChild($this->rootNode);
$this->load->helper('file');
}
}
The constructor calls two functions that isn’t really important to this and that is to my registry to register the renderDom function as a shutdown function and to a file helper (used for caching compiled templates). I use the registry to track these instead of just adding to PHP’s because if I want to disable using this class to output, I can call a simple ‘destroy’ method to prevent it from ever running when php execution ends.
I knocked out the easy things first, some methods to add layers and stylesheets to the object.
/**
* Adds stylesheets to the output array
* You may pass as many arguments as templates you want to load
*
* @return void
*/
public function addXslt($dummy)
{
foreach (func_get_args() as $sheet)
$this->transformations[] = str_replace('/', DS, $sheet);
}
/**
* Adds layers to the output layer array
* You may pass as many arguments as layers you want to load
*
* @return void
*/
public function addLayer($dummy)
{
foreach(func_get_args() as $sheet)
$this->layers[] = str_replace('/', DS, $sheet);
}
/**
* Sets the sub template to use
*
* @param string $template Template name to use for output
* @return void
*/
public function setTemplate($template)
{
$this->subTemplate = $template;
}
/**
* If using language strings, set the language locale
*
* @param string $locale Locale to use
* @return void
*/
public function setLocale($locale)
{
// Make sure the locale exists first...
if (is_readable(APP . DS . 'languages' . DS . $locale . '.xml'))
$this->locale = $locale;
}
I also wanted to have the ability to indirectly call DOMDocument and XSLTProcessor functions without having to call their objects first. I used this block of code to accomplish this. I’ve also passed the call to the Object class to handle this as needed if the method or parameter doesn’t exist in either of these classes (which handle the appropriate error checking/throwing).
/**
* Magic function to transparently call dom and xslt functions
*
* @param string $func Function name to call
* @param array $args array of arguments to pass to the function
* @return mixed Will return the value of the function being called (or trigger an error)
*/
public function __call($func, $args)
{
if (method_exists($this->dom, $func))
return call_user_func_array(array($this->dom, $func), $args);
elseif (method_exists($this->xslt, $func))
return call_user_func_array(array($this->xslt, $func), $args);
else
parent::__call($func, $args);
}
/**
* Magic function to transparently get dom and xslt properties
*
* @param string $property Property to retrieve
* @return mixed Will return the value of the property being retrieved
*/
public function __get($property)
{
if (property_exists($this->dom, $property))
return $this->dom->$property;
elseif (property_exists($this->xslt, $property))
return $this->xslt->$property;
else
return parent::__get($property);
}
My next step was to create some methods that would add XML data to the object’s XML tree that the class would use for output processing. I first needed a small helper method to convert objects to arrays. I decided against type casting primarily because I didn’t need the private and protected properties of an object exported, only public ones. You can make adjustments as needed. This function is contained in my Common.php file for my framework in case I ever need it elsewhere.
/**
* Converts an object into an array using it's public properties
*
* @param mixed $data Object to be converted
* @return array Object converted to an array
*/
function objectToArray($data)
{
$data = is_object($data) ? get_object_vars($data) : $data;
return is_array($data) ? array_map(__FUNCTION__, $data) : $data;
}
I needed a couple more helper methods inside the class itself to help determine some information as the information was being added to the XML tree.
/**
* Determines if an array key is valid for a node name
*
* @param string $nodeName string to validate
* @return bool True if the name is valid
*/
private function isValidName($nodeName)
{
return preg_match('~^[a-z][-a-z0-9_.]*$~i', $nodeName) == 1;
}
/**
* Determines if an array will be list data based on valid names or not
*
* @param array $data Array to validated
* @return bool True if the array has all valid names
*/
private function isListData($data)
{
return is_array($data) && count($data) != count(array_filter(array_keys($data), array($this, 'isValidName')));
}
/**
* Determines the php type of an object
*
* @param mixed $data Data to get type of
* @return int Data type
*/
private function getDataType($data)
{
if (is_array($data) && $this->isListData($data))
return self::TYPE_ARRAY_LIST;
elseif (is_array($data))
return self::TYPE_ARRAY;
elseif ($data instanceof DOMDocument)
return self::TYPE_DOMDOCUMENT;
elseif ($data instanceof DOMNODE)
return self::TYPE_DOMNODE;
elseif (is_object($data))
return self::TYPE_OBJECT;
elseif (is_bool($data))
return self::TYPE_BOOL;
else
return self::TYPE_STRING;
}
Now I’m ready to add elements to my DOMDocument object and create the XML structure that will be used in translation.
/**
* Adds elements to the xml tree
*
* @param string $nodeName The name of the node to be inserted
* @param mixed $data Data to be inserted. Can be an Array, Dom, DOMDocument, DOMNode, Object, Bool or String/Int
* @param object $parentNode Optional parent node to insert the data under
* @return object DOMNode object of the inserted node
*/
public function addToDom($nodeName, $data, &$parentNode = NULL)
{
if (!isset($parentNode))
$parentNode = $this->rootNode;
$node = $this->convertToXml($nodeName, $data, $parentNode);
if (!($node instanceof DOMNode))
trigger_error($nodeName . ' could not be inserted into the xml tree', E_USER_WARNING);
return $node;
}
/**
* Recursively converts an element to an XML representation of itself
*
* @param string $nodeName The name of the node to be inserted
* @param mixed $data Data to be inserted. Can be an Array, Dom, DOMDocument, DOMNode, Object, Bool or String/Int
* @param DOMNode $parentNode The node that these items should be attached to
* @return object DOMNode object of the inserted node
*/
private function convertToXml($nodeName, $data, $parentNode)
{
if (!$this->isValidName($nodeName))
return FALSE;
// Determine the type of element we're dealing with
$dataType = $this->getDataType($data);
if ($dataType == self::TYPE_ARRAY_LIST)
{
if (!empty($data['nodeAttributes']))
foreach ($data['nodeAttributes'] as $name => $value)
$parentNode->setAttribute($name, $value);
// We don't want to include these in the elements for this node
unset($data['nodeAttributes']);
if (isset($data['nodeValue']) && !$node->hasChildNodes())
$parentNode->nodeValue = $data['nodeValue'];
// Recursively add each item in the array to the node
else
foreach ($data as $name => $value)
$parentNode->appendChild($this->convertToXml($this->isValidName($name) ? $name : $nodeName, $value, $parentNode));
return $parentNode;
}
elseif ($dataType == self::TYPE_ARRAY)
{
$node = $this->createElement($nodeName);
if (!empty($data['nodeAttributes']))
foreach ($data['nodeAttributes'] as $name => $value)
$node->setAttribute($name, $value);
// We don't want to include these in the elements for this node
unset($data['nodeAttributes']);
if (isset($data['nodeValue']) && !$node->hasChildNodes())
$node->nodeValue = $data['nodeValue'];
// Recursively add each item in the array to the node
else
foreach ($data as $name => $value)
$node->appendChild($this->convertToXml($this->isValidName($name) ? $name : $nodeName, $value, $node));
}
elseif ($dataType == self::TYPE_DOMDOCUMENT || $dataType == self::TYPE_DOMNODE)
{
// If this is a domdocument, let's get the root node
if ($dataType == self::TYPE_DOMDOCUMENT)
$data = $data->documentElement;
// Import the node into this document and return a copy of it
$domNode = $this->importNode($data, TRUE);
// If the name of the imported node is what we're trying to insert, use it as the node
if ($nodeName == $domNode->nodeName)
$node = $domNode;
// Otherwise create a new node with that name and append our imported node
else
{
$node = $this->createElement($nodeName);
$node->appendChild($domNode);
}
}
elseif ($dataType == self::TYPE_OBJECT)
{
// Objects get converted to arrays first, then imported recursively
$node = $this->convertToXml($nodeName, objectToArray($data));
}
// Fall through all other types (string and bool)
else
{
if ($dataType == self::TYPE_BOOL)
$data = $data ? 'TRUE' : 'FALSE';
// Strings get inserted into a cdata section
$node = $this->createElement($nodeName);
$cdata = $this->createCDATASection((string) $data);
$node->appendChild($cdata);
}
$parentNode->appendChild($node);
return $node;
}
Now that all of the important code for adding information to the XML tree is complete, we can concentrate on assembling all of our params, stylesheets, layers and template into something that the XSLTProcessor will use to translate the XML.
This was my biggest hurdle. From everything that I could find on XSLT, I could only have one match=”/” in a <xsl:template /> throughout. What I found I needed to do was use DOMDocument to dynamically generate a xsl stylesheet which then included everything I needed and called the templates that I specified. See the createXsl method below that I came up with. Keep in mind that I settled on a reflection type design, wherein my templates define the actual HTML layout and I use a custom namespace to call XSL functions. Think of it this way: I have a main stylesheet which includes rules on how to process the HTML. The HTML then contains references in a custom namespace which the stylesheet then processes to pull data from either the language file, or the data. The stylesheets don’t contain the actual presentation data, only the logic on how it’s retrieved. (XSLT is afterall just another XML document, so you can use XSLT to translate another XSLT document)
public function createXsl()
{
$xslNamespace = 'http://www.w3.org/1999/XSL/Transform';
// Create our dynamic XSL stylesheet
$dom = new DOMDocument('1.0', $this->encoding);
$stylesheetNode = $dom->createElementNS($xslNamespace, 'xsl:stylesheet');
$stylesheetNode->setAttribute('xmlns:xsl', $xslNamespace);
$stylesheetNode->setAttribute('version', '1.0');
$dom->appendChild($stylesheetNode);
// Import the base logic
// Custom namespace
if (!empty($this->xmlns))
$stylesheetNode->setAttribute('xmlns:' . $this->xmlns, 'urn:' . $this->xmlns);
$importNode = $dom->createElementNS($xslNamespace, 'xsl:import');
$importNode->setAttribute('href', '../app/templates/xslt/logic.xsl');
$stylesheetNode->appendChild($importNode);
if (!empty($this->locale))
{
$localeNode = $dom->createElementNS($xslNamespace, 'xsl:variable');
$localeNode->setAttribute('name', 'locale');
$localeNode->setAttribute('select', 'document(\'' . '../app/languages/' . $this->locale . '.xml\')/l');
$stylesheetNode->appendChild($localeNode);
array_unshift($this->transformations, 'locale');
}
// Add the extra transformations
foreach ($this->transformations as $xslt)
{
$importNode = $dom->createElementNS($xslNamespace, 'xsl:include');
$importNode->setAttribute('href', '../app/templates/xslt/' . $xslt . '.xsl');
$stylesheetNode->appendChild($importNode);
}
// Set the output type of the document
$outputNode = $dom->createElementNS($xslNamespace, 'xsl:output');
$outputNode->setAttribute('method', 'html');
$outputNode->setAttribute('doctype-system', 'http://www.w3.org/TR/xhtml1/DTD/transitional.dtd');
$outputNode->setAttribute('doctype-public', '-//W3C//DTD XHTML 1.0 Transitional//EN');
$stylesheetNode->appendChild($outputNode);
// Allows us to set params in the XSL stylesheet
foreach ($this->properties as $param => $value)
{
$variableNode = $dom->createElementNS($xslNamespace, 'xsl:variable', $value);
$variableNode->setAttribute('name', $param);
$stylesheetNode->appendChild($variableNode);
}
// Add some extra
$variableNode = $dom->createElementNS($xslNamespace, 'xsl:param');
$variableNode->setAttribute('name', 'template-uri');
$stylesheetNode->appendChild($variableNode);
$variableNode = $dom->createElementNS($xslNamespace, 'xsl:variable');
$variableNode->setAttribute('name', 'template');
$variableNode->setAttribute('select', 'document($template-uri)');
$stylesheetNode->appendChild($variableNode);
$variableNode = $dom->createElementNS($xslNamespace, 'xsl:variable');
$variableNode->setAttribute('name', 'source');
$variableNode->setAttribute('select', '/');
$stylesheetNode->appendChild($variableNode);
return $dom;
}
That’s a mouthful, so let’s quickly run through it. First we create our root stylesheet node and give it the normal xsl namespace. Then we see if a custom namespace has been defined, and if so, add it to the stylesheet node. We then import the base logic file, which, without any other rules defined, will output the template exactly how it’s read in, without any transformations.
If a locale has been defined, we add a variable to the stylesheet to define where the language file is located and add the locale.xslt transformation file onto the include (this file includes the instructions on how to process language tags in the html templates.
Then we go through and add any xslt stylesheets to the document that is needed and define our output type. Then any variables that have been set by $this->dom->myvariable type calls using magic functions are added (dynamic variables). A ‘template-uri’ param is then setup that will eventually be filled with the location of the complete HTML template. A variable is setup to point to a document representation of what will be filled into the param. Lastly, we define our source data variable, so we can call on it when needed in the transformations.
Now there is only two final functions I needed to complete the output to XHTML code from the XML and XSL. A very simple function that will either output straight XML, or transform it with the stylesheets and output pretty XHTML. The output code is nicely formatted and XHTML Transitional compliant (maybe even strict) and a function to create/cache my final template compilation
/**
* Renders the output from the XML and XSLT
*
* @return void
*/
public function renderDom()
{
if ($this->xml)
{
header('Content-type: text/xml');
$dom = new DOMDocument('1.0', $this->encoding);
$dom->loadXML($this->saveXML());
}
else
{
$this->importStylesheet($this->createXsl());
$this->setParameter('', 'template-uri', $this->cacheTemplate());
$dom = $this->transformToDoc($this->dom);
}
$dom->preserveWhiteSpace = FALSE;
$dom->formatOutput = TRUE;
$dom->save('php://output');
}
public function cacheTemplate($ttl = 300)
{
// Create a unique cache name based on the layers and sub template
$cacheName = 'cache_' . md5(serialize(array_merge($this->layers, array($this->subTemplate)))) . '.xml';
$cachePath = CACHE . DS . $cacheName;
// If the cache file exists and hasn't expired, return it.
if (is_readable($cachePath) && filemtime($cachePath) >= time() - $ttl)
return $cachePath;
// First load the sub template to use later
$subTemplate = getFile(APP . DS . 'templates' . DS . $this->subTemplate . '.xml');
if ($subTemplate === FALSE)
trigger_error($this->subTemplate . ' does not exist or cannot be read', E_USER_ERROR);
$finalTemplate = '<insert-sub-template/>';
// For each layer we want to add, read it in and replace it into the final template
foreach ($this->layers as $layer)
{
$contents = getFile(APP . DS . 'templates' . DS . 'layers' . DS . $layer . '.xml');
if ($contents === FALSE)
trigger_error($layer . ' does not exist or cannot be read', E_USER_ERROR);
elseif (strpos($contents, '<insert-sub-template/>') === FALSE)
trigger_error($layer . ' is not a valid template layer');
$finalTemplate = str_replace('<insert-sub-template/>', $contents, $finalTemplate);
}
// Finally add the sub template right into the middle of it all
$finalTemplate = str_replace('<insert-sub-template/>', $subTemplate, $finalTemplate);
// Write the cache file to disk
if (writeFile($cachePath, $finalTemplate) === FALSE)
trigger_error('Failed to write template to disk', E_USER_ERROR);
return '../cache/' . $cacheName;
}
A layer file would look like so:
<html><body><insert-sub-template/></body></html>
And a subtemplate would look like:
<h1>My Template!</h1>
So there it is… the class I have meets all of my requirements and offers plenty of reusability and extensibility. Now lets take a look at how it would be used, and what it would generate. Below is some sample code and then the xsl stylesheet that createXsl generates to go with it, as well as the final template, and the final output.
$this->dom->setTemplate('MyTest');
$this->dom->addXslt('album');
$this->dom->myparam = 'test param';
$collection = array(
'owner' => array(
'name' => array(
'given' => 'Jason',
'family' => 'Diamond',
),
'email' => 'me@you.com',
),
array(
'artist' => 'Radiohead',
'title' => 'OK Computer',
),
array(
'artist' => 'Mogwai',
'title' => 'Kicking a dead pig',
),
);
$this->dom->addToDom('album', $collection);
$this->dom->setLocale('en-US');
$this->dom->addLayer('html');
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
<xsl:import href="../app/templates/xslt/logic.xsl"/>
<xsl:variable name="locale" select="document('../app/languages/en-US.xml')/l"/>
<xsl:include href="../app/templates/xslt/locale.xsl"/>
<xsl:include href="../app/templates/xslt/album.xsl"/>
<xsl:output method="html" doctype-system="http://www.w3.org/TR/xhtml1/DTD/transitional.dtd" doctype-public="-//W3C//DTD XHTML 1.0 Transitional//EN"/>
<xsl:variable name="myparam">test param</xsl:variable>
<xsl:param name="template-uri"/>
<xsl:variable name="template" select="document($template-uri)"/>
<xsl:variable name="source" select="/"/>
</xsl:stylesheet>
logic.xslt looks like so:
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/">
<xsl:apply-templates select="$template/node()" />
</xsl:template>
<xsl:template match="*">
<xsl:element name="{name()}" namespace="{namespace-uri()}">
<xsl:apply-templates select="@* | node()" />
</xsl:element>
</xsl:template>
<xsl:template match="@*">
<xsl:attribute name="{name()}">
<xsl:value-of select="." />
</xsl:attribute>
</xsl:template>
<xsl:template match="text()">
<xsl:if test="normalize-space()">
<xsl:value-of select="." />
</xsl:if>
</xsl:template>
</xsl:stylesheet>
The final template
<html xmlns:txt="text" xmlns:my="my"> <head> <title>test</title> </head> <body> <h1 style="color:red;"><my:owner-name />'s Collection</h1> <h2>Albums</h2> <ul> <my:for-each-album sort-by='title'> <li><my:album-artist /> / <my:album-title /></li> </my:for-each-album> </ul> </body> </html>
And the output:
<?xml version="1.0" standalone="yes"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>test</title>
</head>
<body>
<h1 style="color:red;">JasonDiamond's Collection</h1>
<h2>Albums</h2>
<ul>
<li>Mogwai / Kicking a dead pig</li>
<li>Radiohead / OK Computer</li>
</ul>
</body>
</html>
The result returned from createXsl() will import the logic.xslt and other transformation files and will then process the final template. When the rules in the xslt files encounter data from the source, it will pull from the source and insert it into the template. This is almost a small MVC within itself. You have a controller (main XSLT created by createXsl(), model (logic.xslt and other xslt files), and the view (final template)).
Well there was my adventure, I hope it enlightens or helps someone. The code brought forth here is BSD licensed.
Download: Dom PHP Class