Twig.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. <?php
  2. /**
  3. * @package Grav\Common\Twig
  4. *
  5. * @copyright Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved.
  6. * @license MIT License; see LICENSE file for details.
  7. */
  8. namespace Grav\Common\Twig;
  9. use Grav\Common\Debugger;
  10. use Grav\Common\Grav;
  11. use Grav\Common\Config\Config;
  12. use Grav\Common\Language\Language;
  13. use Grav\Common\Language\LanguageCodes;
  14. use Grav\Common\Page\Interfaces\PageInterface;
  15. use Grav\Common\Page\Pages;
  16. use Grav\Common\Twig\Exception\TwigException;
  17. use Grav\Common\Twig\Extension\FilesystemExtension;
  18. use Grav\Common\Twig\Extension\GravExtension;
  19. use Grav\Common\Utils;
  20. use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
  21. use RocketTheme\Toolbox\Event\Event;
  22. use RuntimeException;
  23. use Twig\Cache\FilesystemCache;
  24. use Twig\DeferredExtension\DeferredExtension;
  25. use Twig\Environment;
  26. use Twig\Error\LoaderError;
  27. use Twig\Error\RuntimeError;
  28. use Twig\Extension\CoreExtension;
  29. use Twig\Extension\DebugExtension;
  30. use Twig\Extension\StringLoaderExtension;
  31. use Twig\Loader\ArrayLoader;
  32. use Twig\Loader\ChainLoader;
  33. use Twig\Loader\ExistsLoaderInterface;
  34. use Twig\Loader\FilesystemLoader;
  35. use Twig\Profiler\Profile;
  36. use Twig\TwigFilter;
  37. use Twig\TwigFunction;
  38. use function function_exists;
  39. use function in_array;
  40. use function is_array;
  41. /**
  42. * Class Twig
  43. * @package Grav\Common\Twig
  44. */
  45. class Twig
  46. {
  47. /** @var Environment */
  48. public $twig;
  49. /** @var array */
  50. public $twig_vars = [];
  51. /** @var array */
  52. public $twig_paths;
  53. /** @var string */
  54. public $template;
  55. /** @var array */
  56. public $plugins_hooked_nav = [];
  57. /** @var array */
  58. public $plugins_quick_tray = [];
  59. /** @var array */
  60. public $plugins_hooked_dashboard_widgets_top = [];
  61. /** @var array */
  62. public $plugins_hooked_dashboard_widgets_main = [];
  63. /** @var Grav */
  64. protected $grav;
  65. /** @var FilesystemLoader */
  66. protected $loader;
  67. /** @var ArrayLoader */
  68. protected $loaderArray;
  69. /** @var bool */
  70. protected $autoescape;
  71. /** @var Profile */
  72. protected $profile;
  73. /**
  74. * Constructor
  75. *
  76. * @param Grav $grav
  77. */
  78. public function __construct(Grav $grav)
  79. {
  80. $this->grav = $grav;
  81. $this->twig_paths = [];
  82. }
  83. /**
  84. * Twig initialization that sets the twig loader chain, then the environment, then extensions
  85. * and also the base set of twig vars
  86. *
  87. * @return $this
  88. */
  89. public function init()
  90. {
  91. if (null === $this->twig) {
  92. /** @var Config $config */
  93. $config = $this->grav['config'];
  94. /** @var UniformResourceLocator $locator */
  95. $locator = $this->grav['locator'];
  96. /** @var Language $language */
  97. $language = $this->grav['language'];
  98. $active_language = $language->getActive();
  99. // handle language templates if available
  100. if ($language->enabled()) {
  101. $lang_templates = $locator->findResource('theme://templates/' . ($active_language ?: $language->getDefault()));
  102. if ($lang_templates) {
  103. $this->twig_paths[] = $lang_templates;
  104. }
  105. }
  106. $this->twig_paths = array_merge($this->twig_paths, $locator->findResources('theme://templates'));
  107. $this->grav->fireEvent('onTwigTemplatePaths');
  108. // Add Grav core templates location
  109. $core_templates = array_merge($locator->findResources('system://templates'), $locator->findResources('system://templates/testing'));
  110. $this->twig_paths = array_merge($this->twig_paths, $core_templates);
  111. $this->loader = new FilesystemLoader($this->twig_paths);
  112. // Register all other prefixes as namespaces in twig
  113. foreach ($locator->getPaths('theme') as $prefix => $_) {
  114. if ($prefix === '') {
  115. continue;
  116. }
  117. $twig_paths = [];
  118. // handle language templates if available
  119. if ($language->enabled()) {
  120. $lang_templates = $locator->findResource('theme://'.$prefix.'templates/' . ($active_language ?: $language->getDefault()));
  121. if ($lang_templates) {
  122. $twig_paths[] = $lang_templates;
  123. }
  124. }
  125. $twig_paths = array_merge($twig_paths, $locator->findResources('theme://'.$prefix.'templates'));
  126. $namespace = trim($prefix, '/');
  127. $this->loader->setPaths($twig_paths, $namespace);
  128. }
  129. $this->grav->fireEvent('onTwigLoader');
  130. $this->loaderArray = new ArrayLoader([]);
  131. $loader_chain = new ChainLoader([$this->loaderArray, $this->loader]);
  132. $params = $config->get('system.twig');
  133. if (!empty($params['cache'])) {
  134. $cachePath = $locator->findResource('cache://twig', true, true);
  135. $params['cache'] = new FilesystemCache($cachePath, FilesystemCache::FORCE_BYTECODE_INVALIDATION);
  136. }
  137. if (!$config->get('system.strict_mode.twig_compat', false)) {
  138. // Force autoescape on for all files if in strict mode.
  139. $params['autoescape'] = 'html';
  140. } elseif (!empty($this->autoescape)) {
  141. $params['autoescape'] = $this->autoescape ? 'html' : false;
  142. }
  143. if (empty($params['autoescape'])) {
  144. user_error('Grav 2.0 will have Twig auto-escaping forced on (can be emulated by turning off \'system.strict_mode.twig_compat\' setting in your configuration)', E_USER_DEPRECATED);
  145. }
  146. $this->twig = new TwigEnvironment($loader_chain, $params);
  147. $this->twig->registerUndefinedFunctionCallback(function (string $name) use ($config) {
  148. $allowed = $config->get('system.twig.safe_functions');
  149. if (is_array($allowed) && in_array($name, $allowed, true) && function_exists($name)) {
  150. return new TwigFunction($name, $name);
  151. }
  152. if ($config->get('system.twig.undefined_functions')) {
  153. if (function_exists($name)) {
  154. if (!Utils::isDangerousFunction($name)) {
  155. user_error("PHP function {$name}() was used as Twig function. This is deprecated in Grav 1.7. Please add it to system configuration: `system.twig.safe_functions`", E_USER_DEPRECATED);
  156. return new TwigFunction($name, $name);
  157. }
  158. /** @var Debugger $debugger */
  159. $debugger = $this->grav['debugger'];
  160. $debugger->addException(new RuntimeException("Blocked potentially dangerous PHP function {$name}() being used as Twig function. If you really want to use it, please add it to system configuration: `system.twig.safe_functions`"));
  161. }
  162. return new TwigFunction($name, static function () {});
  163. }
  164. return false;
  165. });
  166. $this->twig->registerUndefinedFilterCallback(function (string $name) use ($config) {
  167. $allowed = $config->get('system.twig.safe_filters');
  168. if (is_array($allowed) && in_array($name, $allowed, true) && function_exists($name)) {
  169. return new TwigFilter($name, $name);
  170. }
  171. if ($config->get('system.twig.undefined_filters')) {
  172. if (function_exists($name)) {
  173. if (!Utils::isDangerousFunction($name)) {
  174. user_error("PHP function {$name}() used as Twig filter. This is deprecated in Grav 1.7. Please add it to system configuration: `system.twig.safe_filters`", E_USER_DEPRECATED);
  175. return new TwigFilter($name, $name);
  176. }
  177. /** @var Debugger $debugger */
  178. $debugger = $this->grav['debugger'];
  179. $debugger->addException(new RuntimeException("Blocked potentially dangerous PHP function {$name}() being used as Twig filter. If you really want to use it, please add it to system configuration: `system.twig.safe_filters`"));
  180. }
  181. return new TwigFilter($name, static function () {});
  182. }
  183. return false;
  184. });
  185. $this->grav->fireEvent('onTwigInitialized');
  186. // set default date format if set in config
  187. if ($config->get('system.pages.dateformat.long')) {
  188. /** @var CoreExtension $extension */
  189. $extension = $this->twig->getExtension(CoreExtension::class);
  190. $extension->setDateFormat($config->get('system.pages.dateformat.long'));
  191. }
  192. // enable the debug extension if required
  193. if ($config->get('system.twig.debug')) {
  194. $this->twig->addExtension(new DebugExtension());
  195. }
  196. $this->twig->addExtension(new GravExtension());
  197. $this->twig->addExtension(new FilesystemExtension());
  198. $this->twig->addExtension(new DeferredExtension());
  199. $this->twig->addExtension(new StringLoaderExtension());
  200. /** @var Debugger $debugger */
  201. $debugger = $this->grav['debugger'];
  202. $debugger->addTwigProfiler($this->twig);
  203. $this->grav->fireEvent('onTwigExtensions');
  204. /** @var Pages $pages */
  205. $pages = $this->grav['pages'];
  206. // Set some standard variables for twig
  207. $this->twig_vars += [
  208. 'config' => $config,
  209. 'system' => $config->get('system'),
  210. 'theme' => $config->get('theme'),
  211. 'site' => $config->get('site'),
  212. 'uri' => $this->grav['uri'],
  213. 'assets' => $this->grav['assets'],
  214. 'taxonomy' => $this->grav['taxonomy'],
  215. 'browser' => $this->grav['browser'],
  216. 'base_dir' => GRAV_ROOT,
  217. 'home_url' => $pages->homeUrl($active_language),
  218. 'base_url' => $pages->baseUrl($active_language),
  219. 'base_url_absolute' => $pages->baseUrl($active_language, true),
  220. 'base_url_relative' => $pages->baseUrl($active_language, false),
  221. 'base_url_simple' => $this->grav['base_url'],
  222. 'theme_dir' => $locator->findResource('theme://'),
  223. 'theme_url' => $this->grav['base_url'] . '/' . $locator->findResource('theme://', false),
  224. 'html_lang' => $this->grav['language']->getActive() ?: $config->get('site.default_lang', 'en'),
  225. 'language_codes' => new LanguageCodes,
  226. ];
  227. }
  228. return $this;
  229. }
  230. /**
  231. * @return Environment
  232. */
  233. public function twig()
  234. {
  235. return $this->twig;
  236. }
  237. /**
  238. * @return FilesystemLoader
  239. */
  240. public function loader()
  241. {
  242. return $this->loader;
  243. }
  244. /**
  245. * @return Profile
  246. */
  247. public function profile()
  248. {
  249. return $this->profile;
  250. }
  251. /**
  252. * Adds or overrides a template.
  253. *
  254. * @param string $name The template name
  255. * @param string $template The template source
  256. */
  257. public function setTemplate($name, $template)
  258. {
  259. $this->loaderArray->setTemplate($name, $template);
  260. }
  261. /**
  262. * Twig process that renders a page item. It supports two variations:
  263. * 1) Handles modular pages by rendering a specific page based on its modular twig template
  264. * 2) Renders individual page items for twig processing before the site rendering
  265. *
  266. * @param PageInterface $item The page item to render
  267. * @param string|null $content Optional content override
  268. *
  269. * @return string The rendered output
  270. */
  271. public function processPage(PageInterface $item, $content = null)
  272. {
  273. $content = $content ?? $item->content();
  274. // override the twig header vars for local resolution
  275. $this->grav->fireEvent('onTwigPageVariables', new Event(['page' => $item]));
  276. $twig_vars = $this->twig_vars;
  277. $twig_vars['page'] = $item;
  278. $twig_vars['media'] = $item->media();
  279. $twig_vars['header'] = $item->header();
  280. $local_twig = clone $this->twig;
  281. $output = '';
  282. try {
  283. if ($item->isModule()) {
  284. $twig_vars['content'] = $content;
  285. $template = $this->getPageTwigTemplate($item);
  286. $output = $content = $local_twig->render($template, $twig_vars);
  287. }
  288. // Process in-page Twig
  289. if ($item->shouldProcess('twig')) {
  290. $name = '@Page:' . $item->path();
  291. $this->setTemplate($name, $content);
  292. $output = $local_twig->render($name, $twig_vars);
  293. }
  294. } catch (LoaderError $e) {
  295. throw new RuntimeException($e->getRawMessage(), 400, $e);
  296. }
  297. return $output;
  298. }
  299. /**
  300. * Process a Twig template directly by using a template name
  301. * and optional array of variables
  302. *
  303. * @param string $template template to render with
  304. * @param array $vars Optional variables
  305. *
  306. * @return string
  307. */
  308. public function processTemplate($template, $vars = [])
  309. {
  310. // override the twig header vars for local resolution
  311. $this->grav->fireEvent('onTwigTemplateVariables');
  312. $vars += $this->twig_vars;
  313. try {
  314. $output = $this->twig->render($template, $vars);
  315. } catch (LoaderError $e) {
  316. throw new RuntimeException($e->getRawMessage(), 404, $e);
  317. }
  318. return $output;
  319. }
  320. /**
  321. * Process a Twig template directly by using a Twig string
  322. * and optional array of variables
  323. *
  324. * @param string $string string to render.
  325. * @param array $vars Optional variables
  326. *
  327. * @return string
  328. */
  329. public function processString($string, array $vars = [])
  330. {
  331. // override the twig header vars for local resolution
  332. $this->grav->fireEvent('onTwigStringVariables');
  333. $vars += $this->twig_vars;
  334. $name = '@Var:' . $string;
  335. $this->setTemplate($name, $string);
  336. try {
  337. $output = $this->twig->render($name, $vars);
  338. } catch (LoaderError $e) {
  339. throw new RuntimeException($e->getRawMessage(), 404, $e);
  340. }
  341. return $output;
  342. }
  343. /**
  344. * Twig process that renders the site layout. This is the main twig process that renders the overall
  345. * page and handles all the layout for the site display.
  346. *
  347. * @param string|null $format Output format (defaults to HTML).
  348. * @param array $vars
  349. * @return string the rendered output
  350. * @throws RuntimeException
  351. */
  352. public function processSite($format = null, array $vars = [])
  353. {
  354. try {
  355. $grav = $this->grav;
  356. // set the page now its been processed
  357. $grav->fireEvent('onTwigSiteVariables');
  358. /** @var Pages $pages */
  359. $pages = $grav['pages'];
  360. /** @var PageInterface $page */
  361. $page = $grav['page'];
  362. $twig_vars = $this->twig_vars;
  363. $twig_vars['theme'] = $grav['config']->get('theme');
  364. $twig_vars['pages'] = $pages->root();
  365. $twig_vars['page'] = $page;
  366. $twig_vars['header'] = $page->header();
  367. $twig_vars['media'] = $page->media();
  368. $twig_vars['content'] = $page->content();
  369. // determine if params are set, if so disable twig cache
  370. $params = $grav['uri']->params(null, true);
  371. if (!empty($params)) {
  372. $this->twig->setCache(false);
  373. }
  374. // Get Twig template layout
  375. $template = $this->getPageTwigTemplate($page, $format);
  376. $page->templateFormat($format);
  377. $output = $this->twig->render($template, $vars + $twig_vars);
  378. } catch (LoaderError $e) {
  379. throw new RuntimeException($e->getMessage(), 400, $e);
  380. } catch (RuntimeError $e) {
  381. $prev = $e->getPrevious();
  382. if ($prev instanceof TwigException) {
  383. $code = $prev->getCode() ?: 500;
  384. // Fire onPageNotFound event.
  385. $event = new Event([
  386. 'page' => $page,
  387. 'code' => $code,
  388. 'message' => $prev->getMessage(),
  389. 'exception' => $prev,
  390. 'route' => $grav['route'],
  391. 'request' => $grav['request']
  392. ]);
  393. $event = $grav->fireEvent("onDisplayErrorPage.{$code}", $event);
  394. $newPage = $event['page'];
  395. if ($newPage && $newPage !== $page) {
  396. unset($grav['page']);
  397. $grav['page'] = $newPage;
  398. return $this->processSite($newPage->templateFormat(), $vars);
  399. }
  400. }
  401. throw $e;
  402. }
  403. return $output;
  404. }
  405. /**
  406. * Wraps the FilesystemLoader addPath method (should be used only in `onTwigLoader()` event
  407. * @param string $template_path
  408. * @param string $namespace
  409. * @throws LoaderError
  410. */
  411. public function addPath($template_path, $namespace = '__main__')
  412. {
  413. $this->loader->addPath($template_path, $namespace);
  414. }
  415. /**
  416. * Wraps the FilesystemLoader prependPath method (should be used only in `onTwigLoader()` event
  417. * @param string $template_path
  418. * @param string $namespace
  419. * @throws LoaderError
  420. */
  421. public function prependPath($template_path, $namespace = '__main__')
  422. {
  423. $this->loader->prependPath($template_path, $namespace);
  424. }
  425. /**
  426. * Simple helper method to get the twig template if it has already been set, else return
  427. * the one being passed in
  428. * NOTE: Modular pages that are injected should not use this pre-set template as it's usually set at the page level
  429. *
  430. * @param string $template the template name
  431. * @return string the template name
  432. */
  433. public function template(string $template): string
  434. {
  435. if (isset($this->template)) {
  436. $template = $this->template;
  437. unset($this->template);
  438. }
  439. return $template;
  440. }
  441. /**
  442. * @param PageInterface $page
  443. * @param string|null $format
  444. * @return string
  445. */
  446. public function getPageTwigTemplate($page, &$format = null)
  447. {
  448. $template = $page->template();
  449. $default = $page->isModule() ? 'modular/default' : 'default';
  450. $extension = $format ?: $page->templateFormat();
  451. $twig_extension = $extension ? '.'. $extension .TWIG_EXT : TEMPLATE_EXT;
  452. $template_file = $this->template($template . $twig_extension);
  453. // TODO: no longer needed in Twig 3.
  454. /** @var ExistsLoaderInterface $loader */
  455. $loader = $this->twig->getLoader();
  456. if ($loader->exists($template_file)) {
  457. // template.xxx.twig
  458. $page_template = $template_file;
  459. } elseif ($twig_extension !== TEMPLATE_EXT && $loader->exists($template . TEMPLATE_EXT)) {
  460. // template.html.twig
  461. $page_template = $template . TEMPLATE_EXT;
  462. $format = 'html';
  463. } elseif ($loader->exists($default . $twig_extension)) {
  464. // default.xxx.twig
  465. $page_template = $default . $twig_extension;
  466. } else {
  467. // default.html.twig
  468. $page_template = $default . TEMPLATE_EXT;
  469. $format = 'html';
  470. }
  471. return $page_template;
  472. }
  473. /**
  474. * Overrides the autoescape setting
  475. *
  476. * @param bool $state
  477. * @return void
  478. * @deprecated 1.5 Auto-escape should always be turned on to protect against XSS issues (can be disabled per template file).
  479. */
  480. public function setAutoescape($state)
  481. {
  482. if (!$state) {
  483. user_error(__CLASS__ . '::' . __FUNCTION__ . '(false) is deprecated since Grav 1.5', E_USER_DEPRECATED);
  484. }
  485. $this->autoescape = (bool) $state;
  486. }
  487. }