castopod/app/Libraries/ViewComponents/ComponentRenderer.php

250 lines
7.7 KiB
PHP

<?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;
public function __construct()
{
$this->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. <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';
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('~(?<!^)(?<!\/)[A-Z]~', '_$0', $namePath) ?? '');
$fileKey = $basePath . $this->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<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';
$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<string, string> $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, "\'\"");
}
}