Renderer.php

PHP

Path: src/Template/Renderer.php

<?php

namespace mini\Template;

use mini\Mini;

/**
 * Default template renderer with inheritance support
 *
 * Supports multi-level template inheritance via $this->extend() and $this->block().
 * Uses the views path registry to locate template files.
 *
 * Template inheritance example:
 * ```php
 * // child.php
 * <?php $this->extend('layout.php'); ?>
 * <?php $this->block('title', 'My Page'); ?>
 * <?php $this->block('content'); ?><p>Content here</p><?php $this->end(); ?>
 *
 * // layout.php
 * <html><head><title><?php $this->show('title', 'Untitled'); ?></title></head>
 * <body><?php $this->show('content'); ?></body></html>
 * ```
 */
class Renderer implements RendererInterface
{
    /**
     * Find and include stacked _viewstart.php files for a template path
     *
     * Searches from root to the template's directory, including each _viewstart.php found.
     * Variables set in _viewstart.php files (like $layout) are captured and returned.
     * Later files override earlier ones (most specific wins).
     *
     * @param string $template Template path (e.g., 'admin/users/list.php')
     * @param array $vars Initial variables to make available
     * @return array{layout: ?string, vars: array} Captured $layout and merged vars
     */
    private function includeViewstarts(string $template, array $vars): array
    {
        $pathsRegistry = Mini::$mini->paths->views;
        $parts = explode('/', trim($template, '/'));
        array_pop($parts); // Remove the template filename

        // Build list of directories to check (root first, most specific last)
        $dirsToCheck = [''];
        $current = '';
        foreach ($parts as $part) {
            $current .= ($current ? '/' : '') . $part;
            $dirsToCheck[] = $current;
        }

        // Collect all _viewstart.php paths that exist
        $viewstartPaths = [];
        foreach ($dirsToCheck as $dir) {
            $viewstartPath = $dir ? "$dir/_viewstart.php" : '_viewstart.php';
            $fullPath = $pathsRegistry->findFirst($viewstartPath);
            if ($fullPath) {
                $viewstartPaths[] = $fullPath;
            }
        }

        // Include all in same scope (root first), allowing later files to override
        $include = function(array $__viewstartPaths, array $__vars): array {
            extract($__vars);
            $layout = null;
            foreach ($__viewstartPaths as $__path) {
                include $__path;
            }
            $defined = get_defined_vars();
            unset($defined['__viewstartPaths'], $defined['__vars'], $defined['__path']);
            return ['layout' => $layout ?? null, 'vars' => $defined];
        };

        return $include($viewstartPaths, $vars);
    }

    public function render(string $template, array $vars = []): string
    {
        // Use path registry to find template
        $templatePath = Mini::$mini->paths->views->findFirst($template);

        if (!$templatePath) {
            $searchedPaths = implode(', ', Mini::$mini->paths->views->getPaths());
            throw new \Exception("Template not found: $template (searched in: $searchedPaths)");
        }

        $ctx = new TemplateContext();

        // Find and include _viewstart.php files (stacked, root first)
        // Only for initial render, not when extending parent layouts
        if (!isset($vars['__blocks'])) {
            $viewstartVars = $this->includeViewstarts($template, $vars);
            $ctx->setDefaultLayout($viewstartVars['layout'] ?? null);
            $vars = $viewstartVars['vars'];
        }

        // Merge blocks from lower-level (child) into current context BEFORE rendering
        // This ensures $this->show() calls in parent templates can access child blocks
        if (isset($vars['__blocks'])) {
            $ctx->blocks = $vars['__blocks'] + $ctx->blocks;
        }

        // Render template with $this bound to context
        ob_start();
        try {
            $ctx->include($templatePath, $vars);
            $ctx->assertNoUnclosedBlocks();
        } catch (\Throwable $e) {
            ob_end_clean();
            throw $e;
        }
        $output = ob_get_clean();

        // Capture any raw output as "content" ONLY if no child provided content
        // and we didn't receive blocks from a child (meaning we're not a parent template)
        if ($output !== '' && !isset($vars['__blocks'])) {
            if (isset($ctx->blocks['content'])) {
                throw new \LogicException('Template has output outside of blocks which will be discarded. Wrap all output in $this->block()/$this->end().');
            }
            $ctx->blocks['content'] = $output;
        }

        // If this template extends another, recurse upward
        if ($ctx->layout) {
            $newVars = $vars;
            $newVars['__blocks'] = $ctx->blocks;
            return $this->render($ctx->layout, $newVars);
        }

        // Otherwise, this is the topmost template — render final output
        // If we have output (from a layout), return it; otherwise return content block
        return $output !== '' ? $output : ($ctx->blocks['content'] ?? '');
    }
}