Page 1 of 1

Simple templating class using HTML comments (POC)

Posted: Fri Mar 03, 2006 9:24 am
by Chris Corbyn
Don't take this as final, it's simply POC: Proof of Concept.

Code: Select all

<?php

/*
myTpl, A PHP Templating class using HTML comments

This unversioned release this class is not licensed.
It's a proof of concept only and will be developed further
-- to take away some of the logic required for handling loops
-- and other tricky areas.
A GPL license will be applied only when the author considers this
-- class ready for commercial use.

Author: Chris Corbyn (d11wtq, DevNetwork.net)
Date: 2006-03-03

*/

class myTpl
{
    protected
    
    $overallSource,
    $stylesheet,                    // _STYLESHEET_
    $javaScript,                    // _JAVASCRIPT_
    $extraHead,                    // _EXTRA HEAD_
    $caseSensitive = true,
    $removeExtras = false;

    public

    $tplVars = array(); //Holds the parsed template as an array of tokens
    
    /*
     $obj = new myTpl( string $path [, array $tpl_vars [, bool $remove_extras] ] )
     */
    function __construct($path, &$tpl_vars=false, $remove_extras=false)
    {
        if (file_exists($path))
        {
            $this->overallSource = $this->load($path);
            $tpl_vars = $this->parseTplVars($this->overallSource);
            $this->tplVars =& $tpl_vars;
            $this->removeExtras = $remove_extras;
        }
        else die ('No template file could be opened at '.$path);
    }

    // (string)
    private function load($path)
    {
        ob_start();
        require($path);
        return ob_get_clean();
    }

    //Recursive
    // (array)
    private function parseTplVars($source)
    {
        $ret = array();
        $full_re = '@(?:<!-- ::([\w ]*?) START:: -->(.*?)<!-- ::\\1 END:: -->)|(?:<!-- ::([\w ]*?):: -->)@s';
        if (!$this->caseSensitive) $full_re .= 'i';

        if (preg_match_all($full_re, $source, $matches, PREG_OFFSET_CAPTURE)) //This is a "block" of code, or just a marker, we don't know just yet
        {
            foreach ($matches[0] as $i => $arr)
            {
                if (!empty($matches[1][$i][0])) //Name for a "block"
                {
                    $ret[$matches[1][$i][0]] = array(); //We use the name as a key
                    
                    if (preg_match($full_re, $matches[2][$i][0])) //Has "blocks" inside itself
                    {
                        $ret[$matches[1][$i][0]] = $this->parseTplVars($matches[2][$i][0]); //Returns array of course 
                        $ret[$matches[1][$i][0]]['@content'] = $matches[2][$i][0]; //Keep a copy of the "block" to which these parts apply
                    }
                    else
                    {
                        $ret[$matches[1][$i][0]] = $matches[2][$i][0]; //No blocks inside this block so just store it
                    }
                }
                elseif (!empty($matches[3][$i][0])) //Not a "block", just a marker in the template
                {
                    $ret[$matches[3][$i][0]] = ''; //Make a var, but just leave it empty
                }
            }
        }
        $ret['@content'] = $this->overallSource;
        return $ret;
    }

    // (void)
    public function setCaseSensitive($bool)
    {
        $this->caseSensitive = $bool;
    }

    //Replace a defined segment of the template
    // (string)
    public function replace($part, $with, $source)
    {
        $re = '@(?:<!-- ::'.$part.' START:: -->(.*?)<!-- ::'.$part.' END:: -->)|(?:<!-- ::('.$part.'):: -->)@s';
        if (!$this->caseSensitive) $re .= 'i';
        
        return preg_replace($re, $with, $source);
    }

    // (array)
    public function getTplVars()
    {
        return $this->tplVars;
    }

    // (string)
    public function getSource()
    {
        return $this->overallSource;
    }

    // (string)
    public function getExtraHead()
    {
        return $this->extraHead;
    }

    // (string)
    public function getJavaScript()
    {
        return $this->javaScript;
    }

    // (string)
    public function getStylesheet()
    {
        return $this->stylesheet;
    }

    // (void)
    public function setRemoveExtras($bool)
    {
        $this->removeExtras = $bool;
    }
    
    //Recursive
    // (string)
    public function render($array=false)
    {    
        $ret = '';
        if (!$array) $array = $this->tplVars;
        $ret = $array['@content'];
        foreach ($array as $k => $v)
        {
            if (!is_array($v) && $k != '@content')
            {
                if ($this->removeExtras)
                {
                    $style_re = '/^_STYLESHEET_$/';
                    if (!$this->caseSensitive) $style_re .= 'i';
                    if (preg_match($style_re, $k))
                    {
                        $this->stylesheet = $v;
                        $v = '';
                    }
                    $js_re = '/^_JAVASCRIPT_$/';
                    if (!$this->caseSensitive) $js_re .= 'i';
                    if (preg_match($js_re, $k))
                    {
                        $this->javaScript = $v;
                        $v = '';
                    }
                    $head_re = '/^_EXTRA HEAD_$/';
                    if (!$this->caseSensitive) $head_re .= 'i';
                    if (preg_match($head_re, $k))
                    {
                        $this->extraHead = $v;
                        $v = '';
                    }
                }
                $ret = $this->replace($k, $v, $ret);
            }
            elseif ($k != '@content')
            {
                if ($this->removeExtras)
                {
                    $style_re = '/^_STYLESHEET_$/';
                    if (!$this->caseSensitive) $style_re .= 'i';
                    if (preg_match($style_re, $k))
                    {
                        $this->stylesheet = $this->render($v);
                        $v = array('@content' => '');
                    }
                    $js_re = '/^_JAVASCRIPT_$/';
                    if (!$this->caseSensitive) $js_re .= 'i';
                    if (preg_match($js_re, $k))
                    {
                        $this->javaScript = $this->render($v);
                        $v = array('@content' => '');
                    }
                    $head_re = '/^_EXTRA HEAD_$/';
                    if (!$this->caseSensitive) $head_re .= 'i';
                    if (preg_match($head_re, $k))
                    {
                        $this->extraHead = $this->render($v);
                        $v = array('@content' => '');
                    }
                }
                $ret = $this->replace($k, $this->render($v), $ret);
            }            
        }
        return $ret;
    }
    
}

?>
Theory behind the concept:

I wanted to create a template class that would tie in perfectly with a fully OOP/PHP5 environment working with MVC. I also wanted this class to work nicely for less-savvy OOP'ers and Procedural coders.

Templates are essentially HTML files... even the syntax used in the files is HTML. Comments are used to define blocks of markup and markers in the template files... this means that you can partially parse templates if you want to without breaking the output.

Syntax inside the templates:

Everything is done using comments. Some comments (3 so far) are special keywords that myTpl will use to take copies of CSS blocks, JS blocks and additional items that need to go in the <HEAD> of the page.

Defining a section of code:

Code: Select all

<html>
<head>
<title>Example</title>
</head>
<body>
<!-- ::EXAMPLE START:: -->
<div>
This is an example
</div>
<!-- ::EXAMPLE END:: -->
</body>
</html>
Here, we defined the entire DIV in the body as being a section called "EXAMPLE". We use the START and END keywords to define this.

Defining a single marker in the code is easier still. A marker is just a point in the code where something like text may be inserted.

Code: Select all

<html>
<head>
<title>Example</title>
</head>
<body>
<div>
This div says <!-- ::EXAMPLE:: -->
</div>
</body>
</html>
Now we have a marker called EXAMPLE which we can replace with some specific text as needed.

We can nest our syntax infinitely too:

Code: Select all

<html>
<head>
<title>Example</title>
</head>
<body>
<!-- ::BLOCK ONE START:: -->
<div>
This div says <!-- ::EXAMPLE TEXT:: -->
</div>
<!-- ::BLOCK ONE END:: -->
</body>
</html>
In the above we have a block of markup named BLOCK ONE, this block contains a marker called EXAMPLE TEXT.

The names of the sections/markers apply in the scope of the parent block only and absolutely.... this means that you don't have to worry about name clashes in large templates providing you only use a name once in each block you define (even nests).

For example, this will work fine, nothing clashes:

Code: Select all

<html>
<head>
<title>Example</title>
</head>
<body>
<!-- ::BLOCK START:: -->
<div>
This div says <!-- ::TEXT:: -->
</div>
<!-- ::BLOCK END:: -->

<!-- ::TEXT:: -->

</body>
</html>
But this will have clashes with names (the last defined one will overwrite the first):

Code: Select all

<html>
<head>
<title>Example</title>
</head>
<body>
<div>
This div says <!-- ::TEXT:: -->
</div>

<!-- ::TEXT:: -->

</body>
</html>
How the templates get parsed:

The template file is passed to the constructor of myTpl as a string path. When the object is instantiated myTpl runs through the template file recursively reading your defined blocks of code and markers whilst storing them in a multidimensional array. This multi-dimensional array also contains some extra information that myTpl will use to rebuild the array into a working markup (it contains values for '@content' in each array).

You can opt to pass a variable name into the second constrcutor paramater too... this forces a reference to be created both internally and externally between the array in myTpl and the variable you pass. This makes it easy to work with the array in procedural environment since changes made to the global or local variable will still reflect inside the object (quite deliberately).

The idea is that the values in the array are modified as desired before running the render() method in myTpl to fetch the resulting markup.

Syntax rules:

There aren't really many rules... the only ones I created were to avoid issues with non-standard compliant code in partially parsed templates.

The names for the tags can only contain spaces, numbers (0-9), letters (a-zA-Z) and underscores.

Magic keywords:
The magic keywords so far are "_JAVASCRIPT_", "_STYLESHEET_" and "_EXTRA HEAD_".

This work like any other tag names unless you set the $removeExtras option to TRUE, either in the 3rd constructor paramater, or by calling the method $mytpl->setRemoveExtras(true).

If $removeExtras as set TRUE these blocks of code will not be send back in the resulting markup when render() is run. Instead they will be assigned to properties of the myTpl object and access using the accessor methods:

$mytpl->getJavaScript();
$mytpl->getStylesheet();
$mytpl->getExtraHead();

The idea behind those methods is so that templates which are *not* full HTML pages can defined style sto be applied to that overall page. It's up to the developer at present to make use of the return values from these methods to apply style to a page if required.

Basic example:

Let's take a look at this template:

Code: Select all

<html>
<head>
<title>Example</title>
</head>
<body>
<!-- ::BLOCK START:: -->
<div>
This div says <!-- ::TEXT:: -->
</div>
<!-- ::BLOCK END:: -->

<!-- ::TEXT:: -->

</body>
</html>
We'll save it is 'test.tpl' (you can call it test.html or test.php is you really want to..)

Now we fire up myTpl.

Code: Select all

//Note that $foo doesn't have to be defined, it will be created in the constructor
// Nor does $foo even need to be passed if you're using pure OOP
$tpl = new myTpl('test.tpl', $foo);
If we have a look at the contents of $foo we see the following:

Code: Select all

Array
(
    [BLOCK] => Array
        (
            [TEXT] => 
            [@content] => 
<div>
This div says <!-- ::TEXT:: -->
</div>

        )

    [TEXT] => 
    [@content] => <html>
<head>
<title>Example</title>
</head>
<body>
<!-- ::BLOCK START:: -->
<div>
This div says <!-- ::TEXT:: -->

</div>
<!-- ::BLOCK END:: -->

<!-- ::TEXT:: -->

</body>
</html>


)
Note that $foo as reference to $tpl->tplVars so any changes made to $foo or $tpl->tplVars will be mirrored.

OK, forget about the '@content' parts.... these are for myTpl to use.

So, we see we have [BLOCK] which contains [TEXT], we also have [TEXT] in the first level of the array. These are the values we can tickle :P

Code: Select all

$foo['BLOCK']['TEXT'] = 'Text inside out block';
$foo['TEXT'] = 'Text in the main body';
If we render this now we'll see what we did to our template.

Code: Select all

echo $tpl->render();
The output looks like this:

Code: Select all

<html>
<head>
<title>Example</title>
</head>
<body>

<div>
This div says Text inside out block
</div>


Text in the main body

</body>
</html>
Easy ;)

Obviosuly we can do fancy things like building dynamic tables... that's where you need to use some clever logic but I'm racking my brain for clever way to ask myTpl to do the hard work for you :)

Like I say, don't use this thinking it's gonna be a perfect solution (Smarty).... but it's certainly a stroll down another road :)

Final considerations:

myTpl uses a bit of output buffering while it loads a file. This means that you can actually include small amounts of PHP in your files if you really really need to (defeats the object but you can do it). myTpl reads the parsed PHP only... it will not display PHP code to a browser.

You can also view the source and example here: http://w3style.co.uk/~d11wtq/mytpl/source.php

Posted: Thu Mar 09, 2006 5:14 pm
by Chris Corbyn
Full documentation: http://w3style.co.uk/~d11wtq/mytpl/doc.html
Download: http://w3style.co.uk/~d11wtq/mytpl-0.1.tar.gz

Quite enjoying playing around with this, got some nice ideas on ways to really make it easy as a, b, c to do some fairly advanced things with :D

PHP4 version is just about ready too.

Posted: Thu Mar 09, 2006 5:36 pm
by Christopher
Very nice. I didn't look at the code too closely, but a couple of ideas from glancing at it.

1. Make the embedded tags (i.e. "<!--:: ", " ::-->", " START ::-->", " END ::-->") defined a properties so they can be customized. I have a similar class but use "<!--{TAG}-->" for example.

2. Make the thing compile to PHP code and have a cache. That's where you'll get speed.

3. The "_JAVASCRIPT_", "_STYLESHEET_" and "_EXTRA HEAD_" functionality really belongs in a separate Response class that can render a heirarchy of these templates into an HTML page.

Posted: Thu Mar 09, 2006 5:44 pm
by Chris Corbyn
arborint wrote:Very nice. I didn't look at the code too closely, but a couple of ideas from glancing at it.

1. Make the embedded tags (i.e. "<!--:: ", " ::-->", " START ::-->", " END ::-->") defined a properties so they can be customized. I have a similar class but use "<!--{TAG}-->" for example.

2. Make the thing compile to PHP code and have a cache. That's where you'll get speed.

3. The "_JAVASCRIPT_", "_STYLESHEET_" and "_EXTRA HEAD_" functionality really belongs in a separate Response class that can render a heirarchy of these templates into an HTML page.
1. Brilliant :) Why didn't I think of that? :P

2. ?? I've never done anything like that, I'll have to do some research but it sure sounds good.

3. Dead right, I'd actually already thought about this... I might as well do that before I do much else or I'll kick myself in the teeth further down the line otherwise.

Thanks for the comments :)

EDIT | Just read what you mentioned about caching. It sounds like this is something that would need to be offered but not enforced by default otherwise some request may be getting expired content :)

Posted: Thu Mar 09, 2006 6:33 pm
by Christopher
I agree that the cache should be off by default.

Now you just add the ability to have custom Tags that can call registered code. But you may need a lexer rather than regexp for that.

Here are two classes that can be used for a hierarchical Response. You would need to add a set($tag, $value) method to your class to use it. The attach() method is used to add children to a node in the tree. It works like a template system in a way but at a higher level. As you can also see it handles headers and redirects as well. The first is the root node that can be used alone for simple cases:

Code: Select all

class Response extends ResponseChild {

    function Response ($name='') {
    	$this->ResponseChild($name);
    }
    
    function out() {
        if ($this->redirect) {
            header('Location: ' . $this->redirect);
        } else {
	        foreach ($this->headers as $field => $params) {
	            if (! is_null($params)) {
	                header($field . ': ' . implode(', ', $params));
	            }
	        }
	        echo $this->content;
        }
    }
    	
} // end class Response
Here is the child that can be attached to build a tree:

Code: Select all

<?php

class ResponseChild {
    var $name = '';
    var $children = array();
    var $renderer = null;
    var $headers = array();
    var $redirect = null;
    var $content = '';

    function ResponseChild ($name='') {
    	$this->name = $name;
    }
    
    function setHeader($field, $param=null) {
        if (is_array($param)) {
        	foreach ($param as $prm) {
				$this->headers[$field][] = $prm;
        	}
        } else {
			$this->headers[$field][] = $param;
        }
    }

    function getHeaders() {
        return $this->headers;
    }

    function setRedirect($url) {
        $this->redirect = $url;
    }

    function getRedirect() {
        return $this->redirect;
    }

    function setContent($content) {
        $this->content = $content;
    }

    function getContent() {
        return $this->content;
    }

    function setRenderer(&$renderer) {
        $this->renderer =& $renderer;
   }

    function set($name, $var) {
        $this->children[$name] = $var;
    }

    function attach(&$child) {
        $this->children[$child->name] =& $child;
    }

	function execute(&$locator) {
		if ($this->children) {
			$names = array_keys($this->children);
//get headers and redirect from children if set
			foreach ($names as $name) {
				if (is_a($this->children[$name], 'ResponseChild')) {
					if ($this->children[$name]->headers) {
						foreach ($this->children[$name]->headers as $field => $value) {
							$this->setHeaders($field, $value);
						}
					}
					if ($this->children[$name]->redirect) {
						$this->redirect = $this->children[$name]->redirect;
					}
				}
			}
// only render if no content and has a renderer
			if (! $this->content && $this->renderer) {
				foreach ($names as $name) {
					if (is_a($this->children[$name], 'ResponseChild')) {
						$this->renderer->set($name, $this->children[$name]->execute($locator));
					} else {
						$this->renderer->set($name, $this->children[$name]);
					}
				}
				$this->content = $this->renderer->render();
			}
		} elseif (method_exists($this->renderer, 'render')) {
			$this->content = $this->renderer->render();
		}
		return $this->content;
	}
	
} // end class ResponseChild
You use it like:

Code: Select all

$response = new Response()
$response->setRenderer(new myTpl('page_layout.html'));

$menu = new ResponseChild('menu');
$menu->setRenderer(new MyMenu());  // any class that supports render() can be used -- not just templates

$content = new ResponseChild('menu');
$content ->setRenderer(new myTpl('page_content.html'));

$response->attach($menu);
$response->attach($content);
echo $response->out();
The idea is to pass it through controllers and have each attach its part.

Posted: Thu Mar 09, 2006 7:03 pm
by Chris Corbyn
Yeah I was actually doing this in testing to add headers and footers so it should really be integrated in some way. Thanks for your example :)

Posted: Thu Mar 09, 2006 7:51 pm
by neophyte
I like the concept d11...