Engine.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. <?php
  2. namespace Michel\PurePlate;
  3. use ErrorException;
  4. use Exception;
  5. use InvalidArgumentException;
  6. use ParseError;
  7. use RuntimeException;
  8. use Throwable;
  9. final class Engine
  10. {
  11. private string $templateDir;
  12. private bool $devMode;
  13. private string $cacheDir;
  14. private PhpRenderer $renderer;
  15. private array $blocks = [];
  16. private array $globals = [];
  17. public function __construct(
  18. string $templateDir,
  19. bool $devMode = false,
  20. ?string $cacheDir = null,
  21. array $globals = []
  22. )
  23. {
  24. $this->templateDir = $templateDir;
  25. $this->devMode = $devMode;
  26. $this->cacheDir = $cacheDir ?? sys_get_temp_dir() . '/plate_cache';
  27. if (!is_dir($this->cacheDir)) {
  28. @mkdir($this->cacheDir, 0755, true);
  29. }
  30. foreach ($globals as $key => $value) {
  31. if (!is_string($key)) {
  32. throw new InvalidArgumentException('Global key must be a string.');
  33. }
  34. }
  35. $globals['_plate'] = $this;
  36. $this->globals = $globals;
  37. $this->renderer = new PhpRenderer($this->cacheDir, $this->globals);
  38. }
  39. /**
  40. * @throws ErrorException
  41. */
  42. public function render(string $filename, array $context = []): string
  43. {
  44. if (pathinfo($filename, PATHINFO_EXTENSION) === 'php') {
  45. $render = new PhpRenderer($this->templateDir, $this->globals);
  46. return $render->render($filename, $context);
  47. }
  48. $templatePath = $this->templateDir . '/' . ltrim($filename, '/');
  49. if (!file_exists($templatePath)) {
  50. throw new RuntimeException("Template not found: {$templatePath}");
  51. }
  52. $cacheFile = $this->getCacheFilename($filename);
  53. if ($this->devMode === true || !$this->isCacheValid($templatePath, $cacheFile)) {
  54. $compiledCode = $this->compile($templatePath);
  55. try {
  56. token_get_all($compiledCode, TOKEN_PARSE);
  57. $this->saveToCache($cacheFile, trim($compiledCode) . PHP_EOL);
  58. } catch (ParseError $e) {
  59. $this->handleError($e, $compiledCode, $templatePath);
  60. }
  61. }
  62. try {
  63. set_error_handler(function ($severity, $message, $file, $line) {
  64. if (!(error_reporting() & $severity)) {
  65. return;
  66. }
  67. throw new ErrorException($message, 0, $severity, $file, $line);
  68. });
  69. return $this->renderer->render(str_replace($this->cacheDir, '', realpath($cacheFile)), $context);
  70. } catch (Throwable $e) {
  71. $this->handleError($e, file_get_contents($cacheFile), $templatePath);
  72. } finally {
  73. restore_error_handler();
  74. }
  75. }
  76. /**
  77. * @throws ErrorException
  78. */
  79. public function extend(string $filename): void
  80. {
  81. $_ = $this->render($filename);
  82. $cacheFile = $this->getCacheFilename($filename);
  83. $this->renderer->extend(str_replace($this->cacheDir, '', realpath($cacheFile)));
  84. }
  85. private function compile(string $path): string
  86. {
  87. $html = file_get_contents($path);
  88. $this->blocks = [];
  89. $lines = explode(PHP_EOL, $html);
  90. $output = "";
  91. foreach ($lines as $i => $line) {
  92. $num = $i + 1;
  93. $line = preg_replace('/{#.*?#}/', '', $line);
  94. if (trim($line) === '') {
  95. $output .= $line .PHP_EOL;
  96. continue;
  97. }
  98. $line = preg_replace_callback('/{{(.*?)}}|{% (.*?) %}/', function ($m) use ($num, $path) {
  99. $isBlock = !empty($m[2]);
  100. $content = trim($isBlock ? $m[2] : $m[1]);
  101. if ($isBlock) {
  102. return $this->compileStructure($content, $num);
  103. }
  104. $phpExpr = $this->parseTokens($content);
  105. return "<?php /*L:$num;F:$path*/ echo htmlspecialchars((string)($phpExpr), ENT_QUOTES); ?>";
  106. }, $line);
  107. $output .= $line .PHP_EOL;
  108. }
  109. return $output;
  110. }
  111. private function compileStructure(string $rawContent, int $line): string
  112. {
  113. $parts = preg_split('/\s+/', $rawContent, 2);
  114. $cmd = strtolower(trim($parts[0]));
  115. $rawExpr = $parts[1] ?? '';
  116. if ($cmd === 'extends') {
  117. $phpExpr = $this->parseTokens($rawExpr);
  118. return "<?php \$_plate->extend($phpExpr); ?>";
  119. }
  120. if ($cmd === 'block') {
  121. $blockName = trim($rawExpr, "\"' ");
  122. return "<?php \$this->startBlock('$blockName'); ?>";
  123. }
  124. if ($cmd === 'endblock') {
  125. return "<?php \$this->endblock(); ?>";
  126. }
  127. if ($cmd === 'include') {
  128. $phpExpr = $this->parseTokens($rawExpr);
  129. return "<?php \$_plate->render($phpExpr); ?>";
  130. }
  131. if ($cmd === 'set') {
  132. $phpExpr = $this->parseTokens($rawExpr);
  133. return "<?php $phpExpr; ?>";
  134. }
  135. if (in_array($cmd, ['if', 'foreach', 'while', 'for', 'elseif'])) {
  136. if ($cmd !== 'elseif') {
  137. $this->blocks[] = ['type' => $cmd, 'line' => $line];
  138. }
  139. $phpExpr = $this->parseTokens($rawExpr);
  140. return "<?php $cmd ($phpExpr): ?>";
  141. }
  142. if ($cmd === 'else') {
  143. return "<?php else: ?>";
  144. }
  145. if (substr($cmd, 0, 3) === 'end') {
  146. if (empty($this->blocks)) {
  147. throw new \Exception(sprintf(
  148. "Unexpected '%s' at line %d (missing opening tag)",
  149. $cmd,
  150. $line
  151. ));
  152. }
  153. array_pop($this->blocks);
  154. return "<?php $cmd; ?>";
  155. }
  156. $phpExpr = $this->parseTokens($rawContent);
  157. return "<?php $phpExpr; ?>";
  158. }
  159. private function parseTokens(string $expr): string
  160. {
  161. if (trim($expr) === '') return '';
  162. $tokens = token_get_all("<?php " . trim($expr));
  163. $res = "";
  164. $len = count($tokens);
  165. for ($i = 0; $i < $len; $i++) {
  166. $t = $tokens[$i];
  167. if ($t === '|') {
  168. $filterName = '';
  169. $hasParens = false;
  170. // $filterIndex = -1;
  171. for ($j = $i + 1; $j < $len; $j++) {
  172. $nt = $tokens[$j];
  173. if (is_array($nt) && $nt[0] === T_WHITESPACE) {
  174. continue;
  175. }
  176. if (is_array($nt) && $nt[0] === T_STRING) {
  177. $filterName = $nt[1];
  178. $filterIndex = $j;
  179. for ($k = $j + 1; $k < $len; $k++) {
  180. $nnt = $tokens[$k];
  181. if (is_array($nnt) && $nnt[0] === T_WHITESPACE) {
  182. continue;
  183. }
  184. if ($nnt === '(') {
  185. $hasParens = true;
  186. $i = $k;
  187. } else {
  188. $i = $filterIndex;
  189. }
  190. break 2;
  191. }
  192. $i = $filterIndex;
  193. break;
  194. }
  195. }
  196. if ($hasParens) {
  197. $res = "$filterName(" . trim($res) . ", ";
  198. } else {
  199. $res = "$filterName(" . trim($res) . ")";
  200. }
  201. continue;
  202. }
  203. if (is_array($t)) {
  204. [$id, $text] = $t;
  205. if ($id === T_OPEN_TAG) {
  206. continue;
  207. }
  208. $word = strtolower($text);
  209. if ($id === T_STRING && $word === 'is') {
  210. $nextWords = [];
  211. $nextIndexes = [];
  212. for ($j = $i + 1; $j < $len && count($nextWords) < 2; $j++) {
  213. if (is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) {
  214. continue;
  215. }
  216. $nextWords[] = strtolower(is_array($tokens[$j]) ? $tokens[$j][1] : $tokens[$j]);
  217. $nextIndexes[] = $j;
  218. }
  219. if (isset($nextWords[0]) && $nextWords[0] === 'empty') {
  220. $res = "empty(" . trim($res) . ")";
  221. $i = $nextIndexes[0];
  222. continue;
  223. }
  224. if (isset($nextWords[1]) && $nextWords[0] === 'not' && $nextWords[1] === 'empty') {
  225. $res = "!empty(" . trim($res) . ")";
  226. $i = $nextIndexes[1];
  227. continue;
  228. }
  229. }
  230. if ($id === T_STRING || $id === T_EMPTY) {
  231. $isFunction = false;
  232. for ($j = $i + 1; $j < $len; $j++) {
  233. if (is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) continue;
  234. if ($tokens[$j] === '(') {
  235. $isFunction = true;
  236. }
  237. break;
  238. }
  239. if ($word === 'not') {
  240. $res .= '!';
  241. continue;
  242. }
  243. $trimmedRes = trim($res);
  244. $isProp = (substr($trimmedRes, -2) === '->');
  245. $isReserved = in_array($word, ['as', 'is', 'true', 'false', 'null', 'empty']);
  246. $res .= ($isProp || $isReserved || $isFunction) ? $text : '$' . $text;
  247. } else {
  248. $res .= $text;
  249. }
  250. } else {
  251. $res .= ($t === '.') ? '->' : $t;
  252. }
  253. }
  254. return $res;
  255. }
  256. private function handleError(\Throwable $e, string $compiled, string $path): void
  257. {
  258. $lines = explode("\n", $compiled);
  259. $errorLine = $e->getLine();
  260. $faultyCode = $lines[$errorLine - 1] ?? '';
  261. preg_match('/\/\*L:(\d+);F:(.*?)\*\//', $faultyCode, $m);
  262. $currentFile = $m[2] ?? $path;
  263. $currentLine = $m[1] ?? 'unknown';
  264. if ($e instanceof \ErrorException) {
  265. throw new \ErrorException(
  266. $e->getMessage() . " -> " . $currentFile . ":" . $currentLine,
  267. $e->getCode(),
  268. $e->getSeverity(),
  269. $e->getFile(),
  270. $e->getLine(),
  271. $e->getPrevious()
  272. );
  273. }
  274. throw new \ErrorException(
  275. "PurePlate Error: " . $e->getMessage() . " [At: " . $currentFile . ":" . $currentLine . "]",
  276. $e->getCode(),
  277. E_USER_ERROR,
  278. $e->getFile(),
  279. $e->getLine(),
  280. $e
  281. );
  282. }
  283. private function isCacheValid(string $templateFile, string $cacheFile): bool
  284. {
  285. if (!file_exists($cacheFile)) {
  286. return false;
  287. }
  288. return filemtime($cacheFile) >= filemtime($templateFile);
  289. }
  290. private function getCacheFilename(string $templateFile): string
  291. {
  292. $hash = md5(realpath($templateFile));
  293. $basename = pathinfo($templateFile, PATHINFO_FILENAME);
  294. return $this->cacheDir . DIRECTORY_SEPARATOR . $basename . '_' . $hash . '.cache.php';
  295. }
  296. private function saveToCache(string $cacheFile, string $compiledCode): void
  297. {
  298. $tempFile = $cacheFile . '.tmp';
  299. if (file_put_contents($tempFile, $compiledCode, LOCK_EX) !== false) {
  300. @rename($tempFile, $cacheFile);
  301. } else {
  302. throw new RuntimeException("Unable to write cache file: {$cacheFile}");
  303. }
  304. }
  305. }