twig = self::twig_instance($templates_dir, $cache_dir, $config['is_dev']); $this->static_time = time(); } public static function twig_instance($templates_dir, $cache_dir, $auto_reload) { // must specify a second argument ($rootPath) here // otherwise it will be getcwd() and it's www-prod/htdocs/ for apache and www-prod/ for cli code // this is bad for templates rebuilding $twig_loader = new \Twig\Loader\FilesystemLoader($templates_dir, ROOT); $env_options = []; if (!is_null($cache_dir)) { $env_options += [ 'cache' => $cache_dir, 'auto_reload' => $auto_reload ]; } $twig = new \Twig\Environment($twig_loader, $env_options); $twig->addExtension(new Twig_MyExtension); return $twig; } public function render($template, array $vars = []) { $this->apply_globals(); return $this->do_render($template, array_merge($this->vars, $vars)); } protected function do_render($template, $vars) { global $config; $s = ''; try { $s = $this->twig->render($template, $vars); } catch (\Twig\Error\Error $e) { $error = get_class($e).": failed to render"; $source_ctx = $e->getSourceContext(); if ($source_ctx) { $path = $source_ctx->getPath(); if (startsWith($path, ROOT)) $path = substr($path, strlen(ROOT)+1); $error .= " ".$source_ctx->getName()." (".$path.") at line ".$e->getTemplateLine(); } $error .= ": "; $error .= $e->getMessage(); debugError($error); if ($config['is_dev']) $s = $error."\n"; } return $s; } public function set($arg1, $arg2 = null) { if (is_array($arg1)) { foreach ($arg1 as $key => $value) { $this->vars[$key] = $value; } } elseif ($arg2 !== null) { $this->vars[$arg1] = $arg2; } } public function is_set($key): bool { return isset($this->vars[$key]); } public function set_global($arg1, $arg2 = null) { if (is_array($arg1)) { foreach ($arg1 as $key => $value) { $this->global_vars[$key] = $value; } } elseif ($arg2 !== null) { $this->global_vars[$arg1] = $arg2; } } public function is_global_set($key): bool { return isset($this->global_vars[$key]); } public function get_global($key) { return $this->is_global_set($key) ? $this->global_vars[$key] : null; } public function apply_globals() { if (!empty($this->global_vars) && !$this->globals_applied) { foreach ($this->global_vars as $key => $value) $this->twig->addGlobal($key, $value); $this->globals_applied = true; } } /** * @param string $title */ public function set_title($title) { $this->title = $title; } /** * @return string */ public function get_title() { $title = $this->title != '' ? $this->title : 'Домашний сайт'; if (!empty($this->title_modifiers)) { foreach ($this->title_modifiers as $modifier) { $title = $modifier($title); } } return $title; } /** * @param callable $callable */ public function add_page_title_modifier(callable $callable) { if (!is_callable($callable)) { trigger_error(__METHOD__.': argument is not callable'); } else { $this->title_modifiers[] = $callable; } } /** * @param string $css_name * @param null $extra */ public function add_static(string $name, $extra = null) { global $config; // $is_css = endsWith($name, '.css'); $this->static[] = [$name, $extra]; } public function add_external_static($type, $url) { $this->external_static[] = ['type' => $type, 'url' => $url]; } public function add_js($js) { $this->js[] = $js; } public function add_lang_keys(array $keys) { $this->lang_keys = array_merge($this->lang_keys, $keys); } public function add_head($html) { $this->head[] = $html; } public function get_head_html() { global $config; $lines = []; $public_path = $config['static_public_path']; foreach ($this->static as $val) { list($name, $extra) = $val; if (endsWith($name, '.js')) $lines[] = self::js_link($public_path.'/'.$name, $config['static'][$name] ?? 1); else $lines[] = self::css_link($public_path.'/'.$name, $config['static'][$name] ?? 1, $extra); } if (!empty($this->external_static)) { foreach ($this->external_static as $ext) { if ($ext['type'] == 'js') $lines[] = self::js_link($ext['url']); else if ($ext['type'] == 'css') $lines[] = self::css_link($ext['url']); } } if (!empty($this->head)) { $lines = array_merge($lines, $this->head); } return implode("\n", $lines); } public static function js_link($name, $version = null): string { if ($version !== null) $name .= '?'.$version; return ''; } public static function css_link($name, $version = null, $extra = null) { if ($version !== null) $name .= '?'.$version; $s = 'lang_keys)) { foreach ($this->lang_keys as $key) $keys[$key] = $lang[$key]; } return $keys; } public function render_not_found() { http_response_code(404); if (!is_xhr_request()) { $this->render_page('404.twig'); } else { ajax_error(['code' => 404]); } } /** * @param null|string $reason */ public function render_forbidden($reason = null) { http_response_code(403); if (!is_xhr_request()) { $this->set(['reason' => $reason]); $this->render_page('403.twig'); } else { $data = ['code' => 403]; if (!is_null($reason)) $data['reason'] = $reason; ajax_error($data); } } public function must_revalidate() { header('Cache-Control: no-store, no-cache, must-revalidate'); } abstract public function render_page($template); } class web_tpl extends base_tpl { protected $alternate = false; public function __construct() { global $config; $templates = $config['templates']['web']; parent::__construct( ROOT.'/'. $templates['root'], $config['twig_cache'] ? ROOT.'/'.$templates['cache'] : null ); } public function set_alternate($alt) { $this->alternate = $alt; } public function render_page($template) { echo $this->_render_header(); echo $this->_render_body($template); echo $this->_render_footer(); exit; } public function _render_header() { global $config; $this->apply_globals(); $vars = [ 'title' => $this->get_title(), 'keywords' => $this->keywords, 'description' => $this->description, 'alternate' => $this->alternate, 'static' => $this->get_head_html(), ]; return $this->do_render('header.twig', $vars); } public function _render_body($template) { return $this->do_render($template, $this->vars); } public function _render_footer() { $exec_time = microtime(true) - START_TIME; $exec_time = round($exec_time, 4); $footer_vars = [ 'exec_time' => $exec_time, 'js' => !empty($this->js) ? implode("\n", $this->js) : '', ]; return $this->do_render('footer.twig', $footer_vars); } } class Twig_MyExtension extends \Twig\Extension\AbstractExtension { public function getFilters() { global $lang; return array( new \Twig\TwigFilter('lang', 'lang'), new \Twig\TwigFilter('lang', function($key, array $args = []) use (&$lang) { array_walk($args, function(&$item, $key) { $item = htmlescape($item); }); array_unshift($args, $key); return call_user_func_array([$lang, 'get'], $args); }, ['is_variadic' => true]), new \Twig\TwigFilter('plural', function($text, array $args = []) use (&$lang) { array_unshift($args, $text); return call_user_func_array([$lang, 'num'], $args); }, ['is_variadic' => true]), new \Twig\TwigFilter('format_number', function($number, array $args = []) { array_unshift($args, $number); return call_user_func_array('formatNumber', $args); }, ['is_variadic' => true]), new \Twig\TwigFilter('short_number', function($number, array $args = []) { array_unshift($args, $number); return call_user_func_array('shortNumber', $args); }, ['is_variadic']), new \Twig\TwigFilter('format_time', function($ts, array $args = []) { array_unshift($args, $ts); return call_user_func_array('formatTime', $args); }, ['is_variadic' => true]), new \Twig\TwigFilter('format_duration', function($seconds, array $args = []) { array_unshift($args, $seconds); return call_user_func_array('formatDuration', $args); }, ['is_variadic' => true]), ); } public function getTokenParsers() { return [new JsTagTokenParser()]; } public function getName() { return 'lang'; } } // Based on https://stackoverflow.com/questions/26170727/how-to-create-a-twig-custom-tag-that-executes-a-callback class JsTagTokenParser extends \Twig\TokenParser\AbstractTokenParser { public function parse(\Twig\Token $token) { $lineno = $token->getLine(); $stream = $this->parser->getStream(); // recovers all inline parameters close to your tag name $params = array_merge([], $this->getInlineParams($token)); $continue = true; while ($continue) { // create subtree until the decideJsTagFork() callback returns true $body = $this->parser->subparse(array ($this, 'decideJsTagFork')); // I like to put a switch here, in case you need to add middle tags, such // as: {% js %}, {% nextjs %}, {% endjs %}. $tag = $stream->next()->getValue(); switch ($tag) { case 'endjs': $continue = false; break; default: throw new \Twig\Error\SyntaxError(sprintf('Unexpected end of template. Twig was looking for the following tags "endjs" to close the "mytag" block started at line %d)', $lineno), -1); } // you want $body at the beginning of your arguments array_unshift($params, $body); // if your endjs can also contains params, you can uncomment this line: // $params = array_merge($params, $this->getInlineParams($token)); // and comment this one: $stream->expect(\Twig\Token::BLOCK_END_TYPE); } return new JsTagNode(new \Twig\Node\Node($params), $lineno, $this->getTag()); } /** * Recovers all tag parameters until we find a BLOCK_END_TYPE ( %} ) * * @param \Twig\Token $token * @return array */ protected function getInlineParams(\Twig\Token $token) { $stream = $this->parser->getStream(); $params = array (); while (!$stream->test(\Twig\Token::BLOCK_END_TYPE)) { $params[] = $this->parser->getExpressionParser()->parseExpression(); } $stream->expect(\Twig\Token::BLOCK_END_TYPE); return $params; } /** * Callback called at each tag name when subparsing, must return * true when the expected end tag is reached. * * @param \Twig\Token $token * @return bool */ public function decideJsTagFork(\Twig\Token $token) { return $token->test(['endjs']); } /** * Your tag name: if the parsed tag match the one you put here, your parse() * method will be called. * * @return string */ public function getTag() { return 'js'; } } class JsTagNode extends \Twig\Node\Node { public function __construct($params, $lineno = 0, $tag = null) { parent::__construct(['params' => $params], [], $lineno, $tag); } public function compile(\Twig\Compiler $compiler) { $count = count($this->getNode('params')); $compiler->addDebugInfo($this); $compiler ->write('global $__tpl;') ->raw(PHP_EOL); for ($i = 0; ($i < $count); $i++) { // argument is not an expression (such as, a \Twig\Node\Textbody) // we should trick with output buffering to get a valid argument to pass // to the functionToCall() function. if (!($this->getNode('params')->getNode($i) instanceof \Twig\Node\Expression\AbstractExpression)) { $compiler ->write('ob_start();') ->raw(PHP_EOL); $compiler ->subcompile($this->getNode('params')->getNode($i)); $compiler ->write('$js = ob_get_clean();') ->raw(PHP_EOL); } } $compiler ->write('$__tpl->add_js($js);') ->raw(PHP_EOL) ->write('unset($js);') ->raw(PHP_EOL); } } /** * @param $data */ function ajax_ok($data) { ajax_response(['response' => $data]); } /** * @param $error * @param int $code */ function ajax_error($error, $code = 200) { ajax_response(['error' => $error], $code); } /** * @param $data * @param int $code */ function ajax_response($data, $code = 200) { header('Cache-Control: no-cache, must-revalidate'); header('Pragma: no-cache'); header('Content-Type: application/json; charset=utf-8'); http_response_code($code); echo jsonEncode($data); exit; }