| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360 |
- <?php
- namespace Michel\PurePlate;
- use ErrorException;
- use Exception;
- use InvalidArgumentException;
- use ParseError;
- use RuntimeException;
- use Throwable;
- final class Engine
- {
- private string $templateDir;
- private bool $devMode;
- private string $cacheDir;
- private PhpRenderer $renderer;
- private array $blocks = [];
- private array $globals = [];
- public function __construct(
- string $templateDir,
- bool $devMode = false,
- ?string $cacheDir = null,
- array $globals = []
- )
- {
- $this->templateDir = $templateDir;
- $this->devMode = $devMode;
- $this->cacheDir = $cacheDir ?? sys_get_temp_dir() . '/plate_cache';
- if (!is_dir($this->cacheDir)) {
- @mkdir($this->cacheDir, 0755, true);
- }
- foreach ($globals as $key => $value) {
- if (!is_string($key)) {
- throw new InvalidArgumentException('Global key must be a string.');
- }
- }
- $globals['_plate'] = $this;
- $this->globals = $globals;
- $this->renderer = new PhpRenderer($this->cacheDir, $this->globals);
- }
- /**
- * @throws ErrorException
- */
- public function render(string $filename, array $context = []): string
- {
- if (pathinfo($filename, PATHINFO_EXTENSION) === 'php') {
- $render = new PhpRenderer($this->templateDir, $this->globals);
- return $render->render($filename, $context);
- }
- $templatePath = $this->templateDir . '/' . ltrim($filename, '/');
- if (!file_exists($templatePath)) {
- throw new RuntimeException("Template not found: {$templatePath}");
- }
- $cacheFile = $this->getCacheFilename($filename);
- if ($this->devMode === true || !$this->isCacheValid($templatePath, $cacheFile)) {
- $compiledCode = $this->compile($templatePath);
- try {
- token_get_all($compiledCode, TOKEN_PARSE);
- $this->saveToCache($cacheFile, trim($compiledCode) . PHP_EOL);
- } catch (ParseError $e) {
- $this->handleError($e, $compiledCode, $templatePath);
- }
- }
- try {
- set_error_handler(function ($severity, $message, $file, $line) {
- if (!(error_reporting() & $severity)) {
- return;
- }
- throw new ErrorException($message, 0, $severity, $file, $line);
- });
- return $this->renderer->render(str_replace($this->cacheDir, '', realpath($cacheFile)), $context);
- } catch (Throwable $e) {
- $this->handleError($e, file_get_contents($cacheFile), $templatePath);
- } finally {
- restore_error_handler();
- }
- }
- /**
- * @throws ErrorException
- */
- public function extend(string $filename): void
- {
- $_ = $this->render($filename);
- $cacheFile = $this->getCacheFilename($filename);
- $this->renderer->extend(str_replace($this->cacheDir, '', realpath($cacheFile)));
- }
- private function compile(string $path): string
- {
- $html = file_get_contents($path);
- $this->blocks = [];
- $lines = explode(PHP_EOL, $html);
- $output = "";
- foreach ($lines as $i => $line) {
- $num = $i + 1;
- $line = preg_replace('/{#.*?#}/', '', $line);
- if (trim($line) === '') {
- $output .= $line .PHP_EOL;
- continue;
- }
- $line = preg_replace_callback('/{{(.*?)}}|{% (.*?) %}/', function ($m) use ($num, $path) {
- $isBlock = !empty($m[2]);
- $content = trim($isBlock ? $m[2] : $m[1]);
- if ($isBlock) {
- return $this->compileStructure($content, $num);
- }
- $phpExpr = $this->parseTokens($content);
- return "<?php /*L:$num;F:$path*/ echo htmlspecialchars((string)($phpExpr), ENT_QUOTES); ?>";
- }, $line);
- $output .= $line .PHP_EOL;
- }
- return $output;
- }
- private function compileStructure(string $rawContent, int $line): string
- {
- $parts = preg_split('/\s+/', $rawContent, 2);
- $cmd = strtolower(trim($parts[0]));
- $rawExpr = $parts[1] ?? '';
- if ($cmd === 'extends') {
- $phpExpr = $this->parseTokens($rawExpr);
- return "<?php \$_plate->extend($phpExpr); ?>";
- }
- if ($cmd === 'block') {
- $blockName = trim($rawExpr, "\"' ");
- return "<?php \$this->startBlock('$blockName'); ?>";
- }
- if ($cmd === 'endblock') {
- return "<?php \$this->endblock(); ?>";
- }
- if ($cmd === 'include') {
- $phpExpr = $this->parseTokens($rawExpr);
- return "<?php \$_plate->render($phpExpr); ?>";
- }
- if ($cmd === 'set') {
- $phpExpr = $this->parseTokens($rawExpr);
- return "<?php $phpExpr; ?>";
- }
- if (in_array($cmd, ['if', 'foreach', 'while', 'for', 'elseif'])) {
- if ($cmd !== 'elseif') {
- $this->blocks[] = ['type' => $cmd, 'line' => $line];
- }
- $phpExpr = $this->parseTokens($rawExpr);
- return "<?php $cmd ($phpExpr): ?>";
- }
- if ($cmd === 'else') {
- return "<?php else: ?>";
- }
- if (substr($cmd, 0, 3) === 'end') {
- if (empty($this->blocks)) {
- throw new \Exception(sprintf(
- "Unexpected '%s' at line %d (missing opening tag)",
- $cmd,
- $line
- ));
- }
- array_pop($this->blocks);
- return "<?php $cmd; ?>";
- }
- $phpExpr = $this->parseTokens($rawContent);
- return "<?php $phpExpr; ?>";
- }
- private function parseTokens(string $expr): string
- {
- if (trim($expr) === '') return '';
- $tokens = token_get_all("<?php " . trim($expr));
- $res = "";
- $len = count($tokens);
- for ($i = 0; $i < $len; $i++) {
- $t = $tokens[$i];
- if ($t === '|') {
- $filterName = '';
- $hasParens = false;
- // $filterIndex = -1;
- for ($j = $i + 1; $j < $len; $j++) {
- $nt = $tokens[$j];
- if (is_array($nt) && $nt[0] === T_WHITESPACE) {
- continue;
- }
- if (is_array($nt) && $nt[0] === T_STRING) {
- $filterName = $nt[1];
- $filterIndex = $j;
- for ($k = $j + 1; $k < $len; $k++) {
- $nnt = $tokens[$k];
- if (is_array($nnt) && $nnt[0] === T_WHITESPACE) {
- continue;
- }
- if ($nnt === '(') {
- $hasParens = true;
- $i = $k;
- } else {
- $i = $filterIndex;
- }
- break 2;
- }
- $i = $filterIndex;
- break;
- }
- }
- if ($hasParens) {
- $res = "$filterName(" . trim($res) . ", ";
- } else {
- $res = "$filterName(" . trim($res) . ")";
- }
- continue;
- }
- if (is_array($t)) {
- [$id, $text] = $t;
- if ($id === T_OPEN_TAG) {
- continue;
- }
- $word = strtolower($text);
- if ($id === T_STRING && $word === 'is') {
- $nextWords = [];
- $nextIndexes = [];
- for ($j = $i + 1; $j < $len && count($nextWords) < 2; $j++) {
- if (is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) {
- continue;
- }
- $nextWords[] = strtolower(is_array($tokens[$j]) ? $tokens[$j][1] : $tokens[$j]);
- $nextIndexes[] = $j;
- }
- if (isset($nextWords[0]) && $nextWords[0] === 'empty') {
- $res = "empty(" . trim($res) . ")";
- $i = $nextIndexes[0];
- continue;
- }
- if (isset($nextWords[1]) && $nextWords[0] === 'not' && $nextWords[1] === 'empty') {
- $res = "!empty(" . trim($res) . ")";
- $i = $nextIndexes[1];
- continue;
- }
- }
- if ($id === T_STRING || $id === T_EMPTY) {
- $isFunction = false;
- for ($j = $i + 1; $j < $len; $j++) {
- if (is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) continue;
- if ($tokens[$j] === '(') {
- $isFunction = true;
- }
- break;
- }
- if ($word === 'not') {
- $res .= '!';
- continue;
- }
- $trimmedRes = trim($res);
- $isProp = (substr($trimmedRes, -2) === '->');
- $isReserved = in_array($word, ['as', 'is', 'true', 'false', 'null', 'empty']);
- $res .= ($isProp || $isReserved || $isFunction) ? $text : '$' . $text;
- } else {
- $res .= $text;
- }
- } else {
- $res .= ($t === '.') ? '->' : $t;
- }
- }
- return $res;
- }
- private function handleError(\Throwable $e, string $compiled, string $path): void
- {
- $lines = explode("\n", $compiled);
- $errorLine = $e->getLine();
- $faultyCode = $lines[$errorLine - 1] ?? '';
- preg_match('/\/\*L:(\d+);F:(.*?)\*\//', $faultyCode, $m);
- $currentFile = $m[2] ?? $path;
- $currentLine = $m[1] ?? 'unknown';
- if ($e instanceof \ErrorException) {
- throw new \ErrorException(
- $e->getMessage() . " -> " . $currentFile . ":" . $currentLine,
- $e->getCode(),
- $e->getSeverity(),
- $e->getFile(),
- $e->getLine(),
- $e->getPrevious()
- );
- }
- throw new \ErrorException(
- "PurePlate Error: " . $e->getMessage() . " [At: " . $currentFile . ":" . $currentLine . "]",
- $e->getCode(),
- E_USER_ERROR,
- $e->getFile(),
- $e->getLine(),
- $e
- );
- }
- private function isCacheValid(string $templateFile, string $cacheFile): bool
- {
- if (!file_exists($cacheFile)) {
- return false;
- }
- return filemtime($cacheFile) >= filemtime($templateFile);
- }
- private function getCacheFilename(string $templateFile): string
- {
- $hash = md5(realpath($templateFile));
- $basename = pathinfo($templateFile, PATHINFO_FILENAME);
- return $this->cacheDir . DIRECTORY_SEPARATOR . $basename . '_' . $hash . '.cache.php';
- }
- private function saveToCache(string $cacheFile, string $compiledCode): void
- {
- $tempFile = $cacheFile . '.tmp';
- if (file_put_contents($tempFile, $compiledCode, LOCK_EX) !== false) {
- @rename($tempFile, $cacheFile);
- } else {
- throw new RuntimeException("Unable to write cache file: {$cacheFile}");
- }
- }
- }
|