<?php
/**
* @class template
*
* @package Clearbricks
* @subpackage Template
*
* @copyright Olivier Meunier & Association Dotclear
* @copyright GPL-2.0-only
*/
class template
{
private $self_name;
public $use_cache = true;
protected $blocks = [];
protected $values = [];
protected $remove_php = true;
protected $unknown_value_handler = null;
protected $unknown_block_handler = null;
protected $tpl_path = [];
protected $cache_dir;
protected $parent_file;
protected $compile_stack = [];
protected $parent_stack = [];
# Inclusion variables
protected static $superglobals = ['GLOBALS', '_SERVER', '_GET', '_POST', '_COOKIE', '_FILES', '_ENV', '_REQUEST', '_SESSION'];
protected static $_k;
protected static $_n;
protected static $_r;
public function __construct(string $cache_dir, string $self_name)
{
$this->setCacheDir($cache_dir);
$this->self_name = $self_name;
$this->addValue('include', [$this, 'includeFile']);
$this->addBlock('Block', [$this, 'blockSection']);
}
public function includeFile($attr)
{
if (!isset($attr['src'])) {
return;
}
$src = path::clean($attr['src']);
$tpl_file = $this->getFilePath($src);
if (!$tpl_file) {
return;
}
if (in_array($tpl_file, $this->compile_stack)) {
return;
}
return
'<?php try { ' .
'echo ' . $this->self_name . "->getData('" . str_replace("'", "\'", $src) . "'); " .
'} catch (Exception $e) {} ?>' . "\n";
}
public function blockSection($attr, string $content)
{
return $content;
}
public function setPath()
{
$path = [];
foreach (func_get_args() as $v) {
if (is_array($v)) {
$path = array_merge($path, array_values($v));
} else {
$path[] = $v;
}
}
foreach ($path as $k => $v) {
if (($v = path::real($v)) === false) {
unset($path[$k]);
}
}
$this->tpl_path = array_unique($path);
}
public function getPath(): array
{
return $this->tpl_path;
}
public function setCacheDir(string $dir)
{
if (!is_dir($dir)) {
throw new Exception($dir . ' is not a valid directory.');
}
if (!is_writable($dir)) {
throw new Exception($dir . ' is not writable.');
}
$this->cache_dir = path::real($dir) . '/';
}
public function addBlock(string $name, $callback)
{
if (!is_callable($callback)) {
throw new Exception('No valid callback for ' . $name);
}
$this->blocks[$name] = $callback;
}
public function addValue(string $name, $callback)
{
if (!is_callable($callback)) {
throw new Exception('No valid callback for ' . $name);
}
$this->values[$name] = $callback;
}
public function blockExists(string $name): bool
{
return isset($this->blocks[$name]);
}
public function valueExists(string $name): bool
{
return isset($this->values[$name]);
}
public function tagExists(string $name): bool
{
return $this->blockExists($name) || $this->valueExists($name);
}
public function getValueCallback(string $name)
{
if ($this->valueExists($name)) {
return $this->values[$name];
}
return false;
}
public function getBlockCallback(string $name)
{
if ($this->blockExists($name)) {
return $this->blocks[$name];
}
return false;
}
public function getBlocksList(): array
{
return array_keys($this->blocks);
}
public function getValuesList(): array
{
return array_keys($this->values);
}
public function getFile(string $file)
{
$tpl_file = $this->getFilePath($file);
if (!$tpl_file) {
throw new Exception('No template found for ' . $file);
}
$file_md5 = md5($tpl_file);
$dest_file = sprintf(
'%s/%s/%s/%s/%s.php',
$this->cache_dir,
'cbtpl',
substr($file_md5, 0, 2),
substr($file_md5, 2, 2),
$file_md5
);
clearstatcache();
$stat_f = $stat_d = false;
if (file_exists($dest_file)) {
$stat_f = stat($tpl_file);
$stat_d = stat($dest_file);
}
# We create template if:
# - dest_file doest not exists
# - we don't want cache
# - dest_file size == 0
# - tpl_file is more recent thant dest_file
if (!$stat_d || !$this->use_cache || $stat_d['size'] == 0 || $stat_f['mtime'] > $stat_d['mtime']) {
files::makeDir(dirname($dest_file), true);
if (($fp = @fopen($dest_file, 'wb')) === false) {
throw new Exception('Unable to create cache file');
}
$fc = $this->compileFile($tpl_file);
fwrite($fp, $fc);
fclose($fp);
files::inheritChmod($dest_file);
}
return $dest_file;
}
public function getFilePath(string $file)
{
foreach ($this->tpl_path as $p) {
if (file_exists($p . '/' . $file)) {
return $p . '/' . $file;
}
}
return false;
}
public function getParentFilePath(string $previous_path, string $file)
{
$check_file = false;
foreach ($this->tpl_path as $p) {
if ($check_file && file_exists($p . '/' . $file)) {
return $p . '/' . $file;
}
if ($p == $previous_path) {
$check_file = true;
}
}
return false;
}
public function getData(string $________): string
{
self::$_k = array_keys($GLOBALS);
foreach (self::$_k as self::$_n) {
if (!in_array(self::$_n, self::$superglobals)) {
global ${self::$_n};
}
}
$dest_file = $this->getFile($________);
ob_start();
if (ini_get('display_errors') == true) {
include $dest_file;
} else {
@include $dest_file;
}
self::$_r = ob_get_contents();
ob_end_clean();
return self::$_r;
}
protected function getCompiledTree(string $file, &$err)
{
$fc = file_get_contents($file);
$this->compile_stack[] = $file;
# Remove every PHP tags
if ($this->remove_php) {
$fc = preg_replace('/<\?(?=php|=|\s).*?\?>/ms', '', $fc);
}
# Transform what could be considered as PHP short tags
$fc = preg_replace(
'/(<\?(?!php|=|\s))(.*?)(\?>)/ms',
'<?php echo "$1"; ?>$2<?php echo "$3"; ?>',
$fc
);
# Remove template comments <!-- #... -->
$fc = preg_replace('/(^\s*)?<!-- #(.*?)-->/ms', '', $fc);
# Lexer part : split file into small pieces
# each array entry will be either a tag or plain text
$blocks = preg_split(
'#(<tpl:\w+[^>]*>)|(</tpl:\w+>)|({{tpl:\w+[^}]*}})#msu',
$fc,
-1,
PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
);
# Next : build semantic tree from tokens.
$rootNode = new tplNode();
$node = $rootNode;
$errors = [];
$this->parent_file = '';
foreach ($blocks as $id => $block) {
$isblock = preg_match('#<tpl:(\w+)(?:(\s+.*?)>|>)|</tpl:(\w+)>|{{tpl:(\w+)(\s(.*?))?}}#ms', $block, $match);
if ($isblock == 1) {
if (substr($match[0], 1, 1) == '/') {
// Closing tag, check if it matches current opened node
$tag = $match[3];
if (($node instanceof tplNodeBlock) && $node->getTag() == $tag) {
$node->setClosing();
$node = $node->getParent();
} else {
// Closing tag does not match opening tag
// Search if it closes a parent tag
$search = $node;
while ($search->getTag() != 'ROOT' && $search->getTag() != $tag) {
$search = $search->getParent();
}
if ($search->getTag() == $tag) {
$errors[] = sprintf(
__('Did not find closing tag for block <tpl:%s>. Content has been ignored.'),
html::escapeHTML($node->getTag())
);
$search->setClosing();
$node = $search->getParent();
} else {
$errors[] = sprintf(
__('Unexpected closing tag </tpl:%s> found.'),
$tag
);
}
}
} elseif (substr($match[0], 0, 1) == '{') {
// Value tag
$tag = $match[4];
$str_attr = '';
$attr = [];
if (isset($match[6])) {
$str_attr = $match[6];
$attr = $this->getAttrs($match[6]);
}
if (strtolower($tag) == 'extends') {
if (isset($attr['parent']) && $this->parent_file == '') {
$this->parent_file = $attr['parent'];
}
} elseif (strtolower($tag) == 'parent') {
$node->addChild(new tplNodeValueParent($tag, $attr, $str_attr));
} else {
$node->addChild(new tplNodeValue($tag, $attr, $str_attr));
}
} else {
// Opening tag, create new node and dive into it
$tag = $match[1];
if ($tag == 'Block') {
$newnode = new tplNodeBlockDefinition($tag, isset($match[2]) ? $this->getAttrs($match[2]) : []);
} else {
$newnode = new tplNodeBlock($tag, isset($match[2]) ? $this->getAttrs($match[2]) : []);
}
$node->addChild($newnode);
$node = $newnode;
}
} else {
// Simple text
$node->addChild(new tplNodeText($block));
}
}
if (($node instanceof tplNodeBlock) && !$node->isClosed()) {
$errors[] = sprintf(
__('Did not find closing tag for block <tpl:%s>. Content has been ignored.'),
html::escapeHTML($node->getTag())
);
}
$err = '';
if (count($errors) > 0) {
$err = "\n\n<!-- \n" .
__('WARNING: the following errors have been found while parsing template file :') .
"\n * " .
join("\n * ", $errors) .
"\n -->\n";
}
return $rootNode;
}
protected function compileFile(string $file)
{
$tree = null;
$err = '';
while (true) {
if ($file && !in_array($file, $this->parent_stack)) {
$tree = $this->getCompiledTree($file, $err);
if ($this->parent_file == '__parent__') {
$this->parent_stack[] = $file;
$newfile = $this->getParentFilePath(dirname($file), basename($file));
if (!$newfile) {
throw new Exception('No template found for ' . basename($file));
}
$file = $newfile;
} elseif ($this->parent_file != '') {
$this->parent_stack[] = $file;
$file = $this->getFilePath($this->parent_file);
if (!$file) {
throw new Exception('No template found for ' . $this->parent_file);
}
} else {
return $tree->compile($this) . $err;
}
} else {
if ($tree != null) {
return $tree->compile($this) . $err;
}
return '';
}
}
}
public function compileBlockNode(string $tag, $attr, string $content)
{
$res = '';
if (isset($this->blocks[$tag])) {
$res .= call_user_func($this->blocks[$tag], $attr, $content);
} elseif ($this->unknown_block_handler != null) {
$res .= call_user_func($this->unknown_block_handler, $tag, $attr, $content);
}
return $res;
}
public function compileValueNode(string $tag, $attr, string $str_attr)
{
$res = '';
if (isset($this->values[$tag])) {
$res .= call_user_func($this->values[$tag], $attr, ltrim((string) $str_attr));
} elseif ($this->unknown_value_handler != null) {
$res .= call_user_func($this->unknown_value_handler, $tag, $attr, $str_attr);
}
return $res;
}
protected function compileValue(array $match)
{
$v = $match[1];
$attr = isset($match[2]) ? $this->getAttrs($match[2]) : [];
$str_attr = $match[2] ?? null;
return call_user_func($this->values[$v], $attr, ltrim((string) $str_attr));
}
public function setUnknownValueHandler($callback)
{
if (is_callable($callback)) {
$this->unknown_value_handler = $callback;
}
}
public function setUnknownBlockHandler($callback)
{
if (is_callable($callback)) {
$this->unknown_block_handler = $callback;
}
}
protected function getAttrs(string $str): array
{
$res = [];
if (preg_match_all('|([a-zA-Z0-9_:-]+)="([^"]*)"|ms', $str, $m) > 0) {
foreach ($m[1] as $i => $v) {
$res[$v] = $m[2][$i];
}
}
return $res;
}
}