config = config('ViewComponents'); } 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. */ private function renderSelfClosingTags(string $output): string { // Pattern borrowed and adapted from Laravel's ComponentTagCompiler // Should match any Component tags $pattern = "/ < \s* (?[A-Z][A-Za-z0-9\.]*?) \s* (? (?: \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*(?[A-Z][A-Za-z0-9\.]*?)(?[\s\S\=\'\"]*)>(?.*)<\/\s*\1\s*>/uUsm'; ini_set('pcre.backtrack_limit', '-1'); /* $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_values($lookupPaths); $pathsToDiscover[] = $this->config->defaultLookupPath; $namePath = str_replace('.', '/', $name); foreach ($pathsToDiscover as $basePath) { // Look for a class component first $fileKey = $basePath . $this->config->componentsDirectory . '/' . $namePath . '.php'; if (is_file($fileKey)) { return $fileKey; } $snakeCaseName = strtolower(preg_replace('~(?config->componentsDirectory . '/' . $snakeCaseName . '.php'; if (is_file($fileKey)) { return $fileKey; } } throw new RuntimeException("View not found for component: {$name}"); } /** * Parses a string to grab any key/value pairs, HTML attributes. * * @return array */ private function parseAttributes(string $attributeString): array { // Pattern borrowed from Laravel's ComponentTagCompiler $pattern = '/ (?[\w\-:.@]+) ( = (? ( \"[^\"]+\" | \'[^\']+\' | \\\'[^\\\']+\\\' | [^\s>]+ ) ) )? /x'; if (! preg_match_all($pattern, $attributeString, $matches, PREG_SET_ORDER)) { return []; } $attributes = []; /** * @var array $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 Component.php * * @param array $attributes */ private function factory(string $name, string $view, array $attributes): ?Component { // Locate the class in the same folder as the view $class = $name . '.php'; $fileKey = str_replace($name . '.php', $class, $view); if ($fileKey === '') { return null; } if (! file_exists($fileKey)) { return null; } $className = service('locator') ->getClassname($fileKey); if (! class_exists($className)) { return null; } return new $className($attributes); } /** * Renders the view when no corresponding class has been found. * * @param array $data */ private function renderView(string $view, array $data): string { return (static function (string $view, $data): string { 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, "\'\""); } }