vendor/contao/core-bundle/src/Twig/Loader/ContaoFilesystemLoader.php line 310

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. /*
  4. * This file is part of Contao.
  5. *
  6. * (c) Leo Feyer
  7. *
  8. * @license LGPL-3.0-or-later
  9. */
  10. namespace Contao\CoreBundle\Twig\Loader;
  11. use Contao\CoreBundle\Exception\InvalidThemePathException;
  12. use Contao\CoreBundle\Twig\ContaoTwigUtil;
  13. use Psr\Cache\CacheItemPoolInterface;
  14. use Symfony\Component\Filesystem\Path;
  15. use Symfony\Contracts\Service\ResetInterface;
  16. use Twig\Loader\LoaderInterface;
  17. use Twig\Source;
  18. /**
  19. * The ContaoFilesystemLoader loads templates from the Contao-specific template
  20. * directories inside of bundles (<bundle>/contao/templates), the app's global
  21. * template directory (<root>/templates) and registered theme directories
  22. * (<root>/templates/<theme>).
  23. *
  24. * Contrary to Twig's default loader, we keep track of template files instead
  25. * of directories. This allows us to group multiple representations of the same
  26. * template (identifier) from different namespaces in a single data structure:
  27. * the Contao template hierarchy.
  28. *
  29. * @experimental
  30. */
  31. class ContaoFilesystemLoader implements LoaderInterface, ResetInterface
  32. {
  33. private const CACHE_KEY_HIERARCHY = 'contao.twig.template_hierarchy';
  34. private CacheItemPoolInterface $cachePool;
  35. private TemplateLocator $templateLocator;
  36. private ThemeNamespace $themeNamespace;
  37. private string $projectDir;
  38. /**
  39. * @var string|false|null
  40. */
  41. private $currentThemeSlug;
  42. /**
  43. * @var array<string, array<string, string>>|null
  44. */
  45. private $inheritanceChains;
  46. /**
  47. * @var array<string, string>
  48. */
  49. private array $lookupCache = [];
  50. public function __construct(CacheItemPoolInterface $cachePool, TemplateLocator $templateLocator, ThemeNamespace $themeNamespace, string $projectDir)
  51. {
  52. $this->projectDir = $projectDir;
  53. $this->themeNamespace = $themeNamespace;
  54. $this->templateLocator = $templateLocator;
  55. $this->cachePool = $cachePool;
  56. }
  57. /**
  58. * Gets the cache key to use for the environment's template cache for a
  59. * given template name.
  60. *
  61. * If we are currently in a theme context and a theme specific variant of
  62. * the template exists, its cache key will be returned instead.
  63. *
  64. * @param string $name The name of the template to load
  65. *
  66. * @return string The cache key
  67. */
  68. public function getCacheKey(string $name): string
  69. {
  70. $templateName = $this->getThemeTemplateName($name) ?? $name;
  71. if (null === $path = $this->findTemplate($templateName)) {
  72. return '';
  73. }
  74. // We prefix the cache key to make sure templates from the default Symfony loader
  75. // won't be reused. Otherwise, we cannot reliably differentiate when to apply our
  76. // input encoding tolerant escaper filters (see #4623).
  77. return 'c:'.Path::makeRelative($path, $this->projectDir);
  78. }
  79. /**
  80. * Returns the source context for a given template logical name.
  81. *
  82. * If we're currently in a theme context and a theme specific variant of
  83. * the template exists, its source context will be returned instead.
  84. *
  85. * @param string $name The template logical name
  86. */
  87. public function getSourceContext(string $name): Source
  88. {
  89. $templateName = $this->getThemeTemplateName($name) ?? $name;
  90. if (null === $path = $this->findTemplate($templateName)) {
  91. return new Source('', $templateName, '');
  92. }
  93. // The Contao PHP templates will still be rendered by the Contao framework via a
  94. // PhpTemplateProxyNode. We're removing the source to not confuse Twig's lexer
  95. // and parser and just keep the block names. At some point we may transpile the
  96. // source to valid Twig instead and drop the proxy.
  97. if ('html5' !== Path::getExtension($path, true)) {
  98. return new Source(file_get_contents($path), $templateName, $path);
  99. }
  100. // Look up the blocks of the parent template if present
  101. if (
  102. 1 === preg_match(
  103. '/\$this\s*->\s*extend\s*\(\s*[\'"]([a-z0-9_-]+)[\'"]\s*\)/i',
  104. (string) file_get_contents($path),
  105. $match,
  106. )
  107. && '@Contao/'.$match[1].'.html5' !== $name
  108. ) {
  109. return new Source($this->getSourceContext('@Contao/'.$match[1].'.html5')->getCode(), $templateName, $path);
  110. }
  111. preg_match_all(
  112. '/\$this\s*->\s*block\s*\(\s*[\'"]([a-z0-9_-]+)[\'"]\s*\)/i',
  113. (string) file_get_contents($path),
  114. $matches,
  115. );
  116. return new Source(implode("\n", $matches[1]), $templateName, $path);
  117. }
  118. /**
  119. * Check if we have the source code of a template, given its name.
  120. *
  121. * If we are currently in a theme context and a theme specific variant of
  122. * the template exists, its availability will be checked as well.
  123. *
  124. * @param string $name The name of the template to check if we can load
  125. *
  126. * @return bool If the template source code is handled by this loader or not
  127. */
  128. public function exists(string $name): bool
  129. {
  130. if (null !== $this->findTemplate($name)) {
  131. return true;
  132. }
  133. if (null !== ($themeTemplate = $this->getThemeTemplateName($name))) {
  134. return null !== $this->findTemplate($themeTemplate);
  135. }
  136. return false;
  137. }
  138. /**
  139. * Returns true if the template or any variant of it in the hierarchy is
  140. * still fresh.
  141. *
  142. * If we are currently in a theme context and a theme specific variant of
  143. * the template exists, its state will be checked as well.
  144. *
  145. * @param string $name The template name
  146. * @param int $time Timestamp of the last modification time of the
  147. * cached template
  148. *
  149. * @return bool true if the template is fresh, false otherwise
  150. */
  151. public function isFresh(string $name, int $time): bool
  152. {
  153. $isExpired = static function (string $path, int $time): bool {
  154. $mTime = @filemtime($path);
  155. // A cache record is considered expired if the actual file has a newer mtime or
  156. // reading the filemtime failed.
  157. return false === $mTime || $mTime > $time;
  158. };
  159. // Check theme template
  160. if ((null !== ($themeTemplate = $this->getThemeTemplateName($name))) && $isExpired($this->findTemplate($themeTemplate), $time)) {
  161. return false;
  162. }
  163. // Check hierarchy
  164. $chain = $this->getInheritanceChains()[ContaoTwigUtil::getIdentifier($name)] ?? [];
  165. foreach (array_keys($chain) as $path) {
  166. if ($isExpired($path, $time)) {
  167. return false;
  168. }
  169. }
  170. return true;
  171. }
  172. /**
  173. * Resets the cached theme context.
  174. *
  175. * @internal
  176. */
  177. public function reset(): void
  178. {
  179. $this->currentThemeSlug = null;
  180. $this->lookupCache = [];
  181. }
  182. /**
  183. * Finds the next template in the hierarchy and returns the logical name.
  184. */
  185. public function getDynamicParent(string $shortNameOrIdentifier, string $sourcePath, ?string $themeSlug = null): string
  186. {
  187. $hierarchy = $this->getInheritanceChains($themeSlug);
  188. $identifier = ContaoTwigUtil::getIdentifier($shortNameOrIdentifier);
  189. if (null === ($chain = $hierarchy[$identifier] ?? null)) {
  190. throw new \LogicException(sprintf('The template "%s" could not be found in the template hierarchy.', $identifier));
  191. }
  192. // Find the next element in the hierarchy or use the first if it cannot be found
  193. $index = array_search(Path::canonicalize($sourcePath), array_keys($chain), true);
  194. $next = array_values($chain)[false !== $index ? $index + 1 : 0] ?? null;
  195. if (null === $next) {
  196. throw new \LogicException(sprintf('The template "%s" does not have a parent "%s" it can extend from.', $sourcePath, $identifier));
  197. }
  198. return $next;
  199. }
  200. /**
  201. * Finds the first template in the hierarchy and returns the logical name.
  202. */
  203. public function getFirst(string $shortNameOrIdentifier, ?string $themeSlug = null): string
  204. {
  205. $identifier = ContaoTwigUtil::getIdentifier($shortNameOrIdentifier);
  206. $hierarchy = $this->getInheritanceChains($themeSlug);
  207. if (null === ($chain = $hierarchy[$identifier] ?? null)) {
  208. throw new \LogicException(sprintf('The template "%s" could not be found in the template hierarchy.', $identifier));
  209. }
  210. return $chain[array_key_first($chain)];
  211. }
  212. /**
  213. * Returns an array [<template identifier> => <path mappings>] where path
  214. * mappings are arrays [<absolute path> => <template logical name>] in the
  215. * order they should appear in the inheritance chain for the respective
  216. * template identifier.
  217. *
  218. * If a $themeSlug is given the result will additionally include templates
  219. * of that theme if there are any.
  220. *
  221. * For example:
  222. * [
  223. * 'foo' => [
  224. * '/path/to/foo.html.twig' => '@Some/foo.html.twig',
  225. * '/other/path/to/foo.html5' => '@Other/foo.html5',
  226. * ],
  227. * ]
  228. *
  229. * @return array<string, array<string, string>>
  230. */
  231. public function getInheritanceChains(?string $themeSlug = null): array
  232. {
  233. $this->ensureHierarchyIsBuilt();
  234. $chains = $this->inheritanceChains;
  235. foreach ($chains as $identifier => $chain) {
  236. foreach ($chain as $path => $name) {
  237. // Filter out theme paths that do not match the given slug.
  238. if (null !== ($namespace = $this->themeNamespace->match($name)) && $namespace !== $themeSlug) {
  239. unset($chains[$identifier][$path]);
  240. }
  241. }
  242. if (empty($chains[$identifier])) {
  243. unset($chains[$identifier]);
  244. }
  245. }
  246. return $chains;
  247. }
  248. /**
  249. * Warm up the template hierarchy cache.
  250. *
  251. * If $forceRefresh is enabled, any current state and cache state will get
  252. * rebuilt. This will always induce filesystem operations.
  253. */
  254. public function warmUp(bool $forceRefresh = false): void
  255. {
  256. if (!$forceRefresh) {
  257. $this->ensureHierarchyIsBuilt();
  258. return;
  259. }
  260. $this->inheritanceChains = null;
  261. $this->lookupCache = [];
  262. $this->ensureHierarchyIsBuilt(false);
  263. }
  264. private function ensureHierarchyIsBuilt(bool $useCacheForLookup = true): void
  265. {
  266. if (null !== $this->inheritanceChains) {
  267. return;
  268. }
  269. $hierarchyItem = $this->cachePool->getItem(self::CACHE_KEY_HIERARCHY);
  270. // Restore hierarchy from cache
  271. if ($useCacheForLookup && $hierarchyItem->isHit() && null !== ($hierarchy = $hierarchyItem->get())) {
  272. $this->inheritanceChains = $hierarchy;
  273. return;
  274. }
  275. // Find templates and build the hierarchy
  276. $this->inheritanceChains = $this->buildInheritanceChains();
  277. // Persist
  278. $hierarchyItem->set($this->inheritanceChains);
  279. $this->cachePool->save($hierarchyItem);
  280. }
  281. /**
  282. * @return array<string, array<string, string>>
  283. */
  284. private function buildInheritanceChains(): array
  285. {
  286. /** @var list<array{string, string}> $sources */
  287. $sources = [];
  288. foreach ($this->templateLocator->findThemeDirectories() as $slug => $path) {
  289. $sources[] = [$path, "Contao_Theme_$slug"];
  290. }
  291. $sources[] = [Path::join($this->projectDir, 'templates'), 'Contao_Global'];
  292. foreach ($this->templateLocator->findResourcesPaths() as $name => $resourcesPaths) {
  293. foreach ($resourcesPaths as $path) {
  294. $sources[] = [$path, "Contao_$name"];
  295. }
  296. }
  297. // Lookup templates and build hierarchy
  298. $templatesByNamespace = [];
  299. foreach ($sources as [$searchPath, $namespace]) {
  300. $templates = $this->templateLocator->findTemplates($searchPath);
  301. foreach ($templates as $shortName => $templatePath) {
  302. if (null !== ($existingPath = $templatesByNamespace[$namespace][$shortName] ?? null)) {
  303. $basePath = Path::getLongestCommonBasePath($templatePath, $existingPath);
  304. throw new \OutOfBoundsException(sprintf('There cannot be more than one "%s" template in "%s".', $shortName, $basePath));
  305. }
  306. $templatesByNamespace[$namespace][$shortName] = $templatePath;
  307. }
  308. }
  309. $typeByIdentifier = [];
  310. $hierarchy = [];
  311. foreach ($templatesByNamespace as $namespace => $templates) {
  312. foreach ($templates as $shortName => $path) {
  313. $identifier = ContaoTwigUtil::getIdentifier($shortName);
  314. $type = \in_array($extension = ContaoTwigUtil::getExtension($path), ['html.twig', 'html5'], true)
  315. ? 'html.twig/html5'
  316. : $extension;
  317. // Make sure all files grouped under a certain identifier share the same type
  318. if (null === ($existingType = $typeByIdentifier[$identifier] ?? null)) {
  319. $typeByIdentifier[$identifier] = $type;
  320. } elseif ($type !== $existingType) {
  321. throw new \OutOfBoundsException(sprintf('The "%s" template has incompatible types, got "%s" in "%s" and "%s" in "%s".', $identifier, $existingType, array_key_last($hierarchy[$identifier]), $type, $path));
  322. }
  323. $hierarchy[$identifier][$path] = "@$namespace/$shortName";
  324. }
  325. }
  326. return $hierarchy;
  327. }
  328. /**
  329. * Resolves the path of a given template name from the hierarchy or returns
  330. * null if no matching element was found.
  331. */
  332. private function findTemplate(string $name): ?string
  333. {
  334. $findTemplate = function (string $name): ?string {
  335. if (null === ($parsed = ContaoTwigUtil::parseContaoName($name))) {
  336. return null;
  337. }
  338. [$namespace, $shortname] = $parsed;
  339. $identifier = ContaoTwigUtil::getIdentifier($shortname);
  340. $this->ensureHierarchyIsBuilt();
  341. if (empty($candidates = $this->inheritanceChains[$identifier] ?? [])) {
  342. return null;
  343. }
  344. $extension = ContaoTwigUtil::getExtension($shortname);
  345. foreach ($candidates as $candidatePath => $candidateTemplateName) {
  346. // The extension needs to match.
  347. if (ContaoTwigUtil::getExtension($candidatePath) !== $extension) {
  348. continue;
  349. }
  350. // Either the namespace must match, or - in case of the default namespace
  351. // ("@Contao") - the first non-theme element is used.
  352. if (('Contao' === $namespace && !$this->themeNamespace->match($candidateTemplateName)) || str_starts_with($candidateTemplateName, "@$namespace/")) {
  353. return $candidatePath;
  354. }
  355. }
  356. return null;
  357. };
  358. // Cache the result in a lookup table
  359. return $this->lookupCache[$name] ??= $findTemplate($name);
  360. }
  361. /**
  362. * Returns the template name of a theme specific variant of the given name
  363. * or null if not applicable.
  364. */
  365. private function getThemeTemplateName(string $name): ?string
  366. {
  367. $parts = ContaoTwigUtil::parseContaoName($name);
  368. if ('Contao' !== ($parts[0] ?? null)) {
  369. return null;
  370. }
  371. if (false === ($themeSlug = $this->currentThemeSlug ?? $this->getThemeSlug())) {
  372. return null;
  373. }
  374. $namespace = $this->themeNamespace->getFromSlug($themeSlug);
  375. $template = "$namespace/$parts[1]";
  376. return $this->exists($template) ? $template : null;
  377. }
  378. /**
  379. * Returns and stores the current theme slug or false if not applicable.
  380. *
  381. * @return string|false
  382. */
  383. private function getThemeSlug()
  384. {
  385. if (null === ($page = $GLOBALS['objPage'] ?? null) || null === ($path = $page->templateGroup)) {
  386. return $this->currentThemeSlug = false;
  387. }
  388. // TODO: remove try/catch block in Contao 5.0
  389. try {
  390. $slug = $this->themeNamespace->generateSlug(Path::makeRelative($path, 'templates'));
  391. } catch (InvalidThemePathException $e) {
  392. $slug = false;
  393. }
  394. return $this->currentThemeSlug = $slug;
  395. }
  396. }