Skip to content
Snippets Groups Projects
ComponentRenderer.php 8.11 KiB
Newer Older
  • Learn to ignore specific revisions
  • <?php
    
    declare(strict_types=1);
    
    namespace ViewComponents;
    
    use RuntimeException;
    use ViewComponents\Config\ViewComponents;
    
    /**
     * Borrowed and adapted from https://github.com/lonnieezell/Bonfire2/
     */
    class ComponentRenderer
    {
        protected ViewComponents $config;
    
        /**
         * File name of the view source
         */
        protected string $currentView;
    
        public function __construct()
        {
            $this->config = config('ViewComponents');
        }
    
        public function setCurrentView(string $view): self
        {
            $this->currentView = $view;
    
            return $this;
        }
    
        public function render(string $output): string
        {
            // Try to locate any custom tags, with PascalCase names like: Button, Label, etc.
            service('timer')
                ->start('self-closing');
            $output = $this->renderSelfClosingTags($output);
            service('timer')
                ->stop('self-closing');
    
            service('timer')
                ->start('paired-tags');
            $output = $this->renderPairedTags($output);
            service('timer')
                ->stop('paired-tags');
    
            return $output;
        }
    
        /**
         * Finds and renders self-closing tags, i.e. <Foo />
         */
        private function renderSelfClosingTags(string $output): string
        {
            // Pattern borrowed and adapted from Laravel's ComponentTagCompiler
            // Should match any Component tags <Component />
            $pattern = "/
                <
                    \s*
                    (?<name>[A-Z][A-Za-z0-9\.]*?)
                    \s*
                    (?<attributes>
                        (?:
                            \s+
                            (?:
                                (?:
                                    \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\}
                                )
                                |
                                (?:
                                    [\w\-:.@]+
                                    (
                                        =
                                        (?:
                                            \\\"[^\\\"]*\\\"
                                            |
                                            \'[^\']*\'
                                            |
                                            [^\'\\\"=<>]+
                                        )
                                    )?
                                )
                            )
                        )*
                        \s*
                    )
                \/>
            /x";
    
            /*
                $matches[0] = full tags matched
                $matches[name] = tag name
                $matches[attributes] = array of attribute string (class="foo")
             */
            return preg_replace_callback($pattern, function ($match): string {
                $view = $this->locateView($match['name']);
                $attributes = $this->parseAttributes($match['attributes']);
    
                $component = $this->factory($match['name'], $view, $attributes);
    
                return $component instanceof Component
                    ? $component->render()
                    : $this->renderView($view, $attributes);
            }, $output) ?? '';
        }
    
        private function renderPairedTags(string $output): string
        {
            $pattern = '/<\s*(?<name>[A-Z][A-Za-z0-9\.]*?)(?<attributes>[\s\S\=\'\"]*)>(?<slot>.*)<\/\s*\1\s*>/uUsm';
    
            /*
                $matches[0] = full tags matched and all of its content
                $matches[name] = pascal cased tag name
                $matches[attributes] = string of tag attributes (class="foo")
                $matches[slot] = the content inside the tags
             */
            return preg_replace_callback($pattern, function ($match): string {
                $view = $this->locateView($match['name']);
                $attributes = $this->parseAttributes($match['attributes']);
                $attributes['slot'] = $match['slot'];
    
                $component = $this->factory($match['name'], $view, $attributes);
    
                return $component instanceof Component
                    ? $component->render()
                    : $this->renderView($view, $attributes);
            }, $output) ?? (string) preg_last_error();
        }
    
        /**
         * Locate the view file used to render the component. The file's name must match the name of the component.
         *
         * Looks for class and view file components in the current module before checking the default app module
         */
        private function locateView(string $name): string
        {
            // TODO: Is there a better way to locate components local to current module?
    
            $pathsToDiscover = [];
            $lookupPaths = $this->config->lookupPaths;
            $pathsToDiscover = array_filter($lookupPaths, function ($path): bool {
                return str_starts_with($this->currentView, $path);
            });
            $pathsToDiscover = array_values($pathsToDiscover);
            $pathsToDiscover[] = $this->config->defaultLookupPath;
    
            foreach ($pathsToDiscover as $basePath) {
    
                $filePath = $basePath . $this->config->componentsDirectory . '/' . $namePath . '.php';
    
                $snakeCaseName = strtolower(preg_replace('~(?<!^)(?<!\/)[A-Z]~', '_$0', $namePath) ?? '');
                $filePath = $basePath . $this->config->componentsDirectory . '/' . $snakeCaseName . '.php';
    
    
                if (is_file($filePath)) {
                    return $filePath;
                }
            }
    
            throw new RuntimeException("View not found for component: {$name}");
        }
    
        /**
         * Parses a string to grab any key/value pairs, HTML attributes.
         *
         * @return array<string, string>
         */
        private function parseAttributes(string $attributeString): array
        {
            // Pattern borrowed from Laravel's ComponentTagCompiler
            $pattern = '/
                (?<attribute>[\w\-:.@]+)
                (
                    =
                    (?<value>
                        (
                            \"[^\"]+\"
                            |
                            \'[^\']+\'
                            |
                            \\\'[^\\\']+\\\'
                            |
                            [^\s>]+
                        )
                    )
                )?
            /x';
    
            if (! preg_match_all($pattern, $attributeString, $matches, PREG_SET_ORDER)) {
                return [];
            }
    
            $attributes = [];
            /**
             * @var array<string, string> $match
             */
            foreach ($matches as $match) {
                $attributes[$match['attribute']] = $this->stripQuotes($match['value']);
            }
    
            return $attributes;
        }
    
        /**
         * Attempts to locate the view and/or class that will be used to render this component. By default, the only thing
         * that is needed is a view, but a Component class can also be found if more power is needed.
         *
         * If a class is used, the name is expected to be <viewName>Component.php
         *
         * @param array<string, mixed> $attributes
         */
        private function factory(string $name, string $view, array $attributes): ?Component
        {
            // Locate the class in the same folder as the view
            $class = $name . '.php';
            $filePath = str_replace($name . '.php', $class, $view);
    
            if ($filePath === '') {
                return null;
            }
    
            if (! file_exists($filePath)) {
                return null;
            }
            $className = service('locator')
                ->getClassname($filePath);
    
            /** @phpstan-ignore-next-line */
            if (! class_exists($className)) {
                return null;
            }
    
            return new $className($attributes);
        }
    
        /**
         * Renders the view when no corresponding class has been found.
         *
         * @param array<string, string> $data
         */
        private function renderView(string $view, array $data): string
        {
            return (function (string $view, $data): string {
                /** @phpstan-ignore-next-line */
                extract($data);
                ob_start();
                eval('?>' . file_get_contents($view));
                return ob_get_clean() ?: '';
            })($view, $data);
        }
    
        /**
         * Removes surrounding quotes from a string.
         */
        private function stripQuotes(string $string): string
        {
            return trim($string, "\'\"");
        }
    }