Background
So, last time I inquired about PHP templates, I got a lot of responses like:
- it isn't needed; PHP is a good enough templating language on its own.
- it's hard to develop a templating language that is both powerful and easy for designers to work with (or around).
- it's already been done, use templating framework X.
- you're stupid.
All of these points have some amount of validity to them. Keeping them in mind, I went ahead with the templating thing, and now I'm back with more questions. :)
Overview
Goals
Here are the goals for this templating engine:
- minimal syntax.
- produce clean php code.
- don't break html syntax highlighting.
- no need for php developers to learn anything new (well, not much).
- support most of php flow control (everything but do..while).
- support inline php.
Hopefully that sounds pretty good. Notice that not among the goals are things like "prevent template authors from doing X" or "templates will be supplied by anonymous users." Security is not a major concern here, any more than it would be on a normal non-templated php file.
Rules
- default escape sequence is
{{...}}
.*- if no other rules match, echo or evaluate the sequence
- if sequence ends with a semicolon, evaluate entire sequence
- otherwise, echo the first expression and evaluate the rest
- if no other rules match, echo or evaluate the sequence
{{for|foreach|if|switch|while (...):}}
begins a block.- parentheses in condition may be omitted
- colon may be omitted
- outer right bracket may be omitted for bracket matching.**
{{else|elseif|break|continue|case|default}}
do what you'd expect.- parentheses in condition may be omitted
- outer right bracket may be omitted on
{{case}}
for bracket matching. - outer left bracket may be omitted on
{{break|continue}}
for bracket matching.
{{end}}
ends a block.- word characters may be appended to 'end', e.g. 'end_if'
- outer left bracket may be omitted for bracket matching.
* custom brackets can be used.
** bracket matching syntax can be disabled.
Templating
So far we've really just come up with a replacement syntax for <?php...?>
and <?=...?>
. For this to really be useful, we need some templating-specific operations.
Another templating framework I worked on uses a simple container/content paradigm that should work well here. That templating system was xml-based, so the code would look something like this...
<!-- in a template -->
<html>
<head>
<tt:Container name="script" />
</head>
<body>
<tt:Container name="main" />
</body>
</html>
<!-- in a page -->
<tt:Content name="script">
<script src="foo.js"></script>
</tt:Content>
<tt:Content name="main">
<div>...</div>
</tt:Content>
Multiple declarations of a content area with the same name will replace the previous content, but the previous content will be available within the Content tag via Container, so:
<tt:Content name="script">
<script src="foo.js"></script>
</tt:Content>
...
<tt:Content name="script">
<script src="bar.js"></script>
<tt:Container name="script" />
</tt:Content>
...
<tt:Container name="script" />
Should output:
<script src="bar.js"></script>
<script src="foo.js"></script>
I've tried to recreate Content
and Container
via set
and get
tags in this new templating system. They're intended to work exactly the same way, except, of course, they're not xml tags.
Code
Without further ado:
<?php
class Detemplate {
public $container_prefix='_tpl_';
public $brackets='{}';
public $bracket_matching=true;
public $use_cache=false;
private $block_keywords=array('for','foreach','if','switch','while');
private $set_count;
private $get_count;
public function parse_file ($file, $vars=array()) {
$sha1=sha1($file);
$cache = dirname(__FILE__)."/cache/".basename($file).".$sha1.php";
$f = "{$this->container_prefix}page_{$sha1}_";
if (!$this->use_cache || !file_exists($cache) || filemtime($cache)<filemtime($file)) {
$php = "<?php function $f {$this->t_vars()} ?>".
$this->parse_markup(file_get_contents($file)).
"<?php } ?>";
file_put_contents($cache, $php);
}
include $cache;
$f($vars);
}
public function parse_markup ($markup) {
$blocks=implode('|', $this->block_keywords);
$arglist= '\s*[\s(](.*?)\)?\s*'; // capture an argument list
$word= '\s*(\w+)\s*'; // capture a single word
$l='\\'.$this->brackets{0}; // left bracket
$r='\\'.$this->brackets{1}; // right bracket
$dl="#$l$l";
$sl=$this->bracket_matching ? "#$l?$l" : $dl;
$dr="$r$r(?!:$r)#";
$sr=$this->bracket_matching ? "$r$r?(?!:$r)#" : $dr;
$markup=preg_replace_callback(
array (
$sl.'(end)[_\w]*\s*;?\s*'.$dr,
$dl.'(el)se\s*if'.$arglist.':?\s*'.$dr,
$dl.'(else)\s*:?\s*'.$dr,
$dl.'(case)'.$word.':?\s*'.$sr,
$dl.'(default)()\s*:?\s*'.$sr,
$sl.'(break|continue)\s*;?\s*'.$dr,
$dl.'(set)'.$word.':?\s*'.$sr,
$dl.'(get)'.$word.':?\s*'.$dr,
$dl.'(parse)'.$word.':?\s*'.$dr,
$dl.'(function|fn)'.$word.$arglist.':?\s*'.$sr,
$dl.'('.$blocks.')'.$arglist.':?\s*'.$sr,
'#('.$l.$l.')(.+?)(;?)\s*'.$dr,
'#\s*(\?)>[\s\n]*<\?php\s*#',
),
array($this, 'preg_callback'),
$markup);
return $markup;
}
private function preg_callback ($m) {
switch ($m[1]) {
// end of block
case "end":
return "<?php } } ?>";
// keywords with special handling
case "el": // elseif
return "<?php } elseif ({$m[2]}) { ?>";
case "else":
return "<?php } else { ?>";
case "case": case "default":
return "<?php {$m[1]} {$m[2]}: ?>";
case "break": case "continue":
return "<?php {$m[1]}; ?>";
// parse an external template document
case "parse":
return $this->parse_markup(file_get_contents($m[2]));
// save / load content sections
case "set":
$i=++$this->set_count[$m[2]];
$f=$this->t_fn($m[2], $i);
$p=$this->t_fn($m[2], $i-1);
$v=$this->t_fn_alias($m[2]);
return "<?php if (!function_exists('$f')) { $v='$f'; ".
"function $f {$this->t_vars()} unset ($v); $v='$p'; ?>";
case "get":
$i=++$this->get_count[$m[2]];
$c=$this->t_fn_ctx($m[2], $i);
$v=$this->t_tmp();
$a=$this->t_fn_alias($m[2]);
return "<?php if (!$c) { ".
"foreach (array_keys(get_defined_vars()) as $v) $c".
"[$v]=&\$$v; unset($v); } $a(&$c); ?>";
case "function": case "fn":
return "<?php if (!function_exists('{$m[2]}')) { ".
"function {$m[2]} ({$m[3]}) { ?>";
// echo / interpret
case "{{":
return "<?php".($m[3]?"":" echo")." {$m[2]}; ?>";
// merge adjacent php tags
case "?":
return " ";
}
// block keywords
if (in_array($m[1], $this->block_keywords)) {
return "<?php { {$m[1]} ({$m[2]}) { ?>";
}
}
private function t_fn ($name, $index) {
if ($index<1) return "is_null";
return "{$this->container_prefix}{$name}_$index";
}
private function t_fn_alias ($name) {
return "\${$this->container_prefix}['fn_$name']";
}
private function t_fn_ctx ($name, $index) {
return "\${$this->container_prefix}['ctx_{$name}_$index']";
}
private function t_vars () {
$v=$this->t_tmp();
return "($v) { extract($v); unset($v);";
}
private function t_tmp () {
return '$'.$this->container_prefix.'v';
}
}
?>
Example templated html:
<script>var _lang = {{json_encode($lang)}};</script>
<script src='/cartel/static/inventory.js'></script>
<link href='/cartel/static/inventory.css' type='text/css' rel='stylesheet' />
<form class="inquiry" method="post" action="process.php" onsubmit="return validate(this)">
<div class="filter">
<h2>{{$lang['T_FILTER_TITLE']}}</h2>
<a href='#{{urlencode($lang['T_FILTER_ALL'])}}' onclick='applyFilter();'>{{$lang['T_FILTER_ALL']}}</a>
{{foreach ($filters as $f)}}
<a href='#{{urlencode($f)}}' onclick='applyFilter("c_{{urlencode($f)}}");'>{{$f}}</a>
{{end}}
</div>
<table class="inventory" id="inventory_table">
{{foreach $row_array as $row_num=>$r}
{{if $row_num==0}
<tr class='static'>
{{foreach $r as $col}
<th>{{$col}}</th>
{end}}
<th class='ordercol'>{{$lang['T_ORDER']}}</th>
</tr>
{{else}}
{{function spin_button $id, $dir, $max}
<a href='#' class='spinbutton'
onclick="return spin('{{$id}}', {{$dir}}, {{$max}})">
{{$dir==-1 ? '◀' : '▶'}}
</a>
{end}}
<tr class="{{'c_'.urlencode($r[$man_col])}}">
{{foreach $r as $i=>$col}
<td class='{{$i?"col":"firstcol"}}'>{{$col}}</td>
{end}}
<td class='ordercol'>
{{$id="part_{$r[$part_col]}"; $max=$r[$qty_col];}}
{{spin_button($id, -1, $max)}}
<input onchange="spin(this.id, 0, '{{$max}}')"
id='{{$id}}' name='{{$id}}'type='text' value='0' />
{{spin_button($id, +1, $max)}}
</td>
</tr>
{end}}
{end}}
<tr class="static"><th colspan="{{$cols+1}}">{{$lang['T_FORM_HELP']}}</th></tr>
{{foreach $fields as $f}
<tr class="static">
<td class="fields" colspan="2">
<label for="{{$f[0]}}">{{$f[1]}}</label>
</td>
<td class="fields" colspan="{{$cols-1}}">
<input name="{{$f[0]}}" id="{{$f[0]}}" type="text" />
</td>
</tr>
{end}}
<tr class="static">
<td id="validation" class="send" colspan="{{$cols}}"> </td>
<td colspan="1" class="send"><input type="submit" value="{{$lang['T_SEND']}}" /></td>
</tr>
</table>
</form>
Questions
I've got a few questions about how to proceed with this thing. Some have definite answers, some may be more CW material...
set/get produces messy code. Can it be improved? I'm looking for some kind of sensible middle ground between set/get and
{{function}}
(see code and example).What's missing that's provided in popular templating languages?
Is the syntax ok? Should the lines that echo things, the lines that do things, and the flow control lines be more syntactically different? How about the optional outer brackets for matching... silly?
Looking forward to hearing everyone's input on this.