mirror of
https://code.castopod.org/adaures/castopod.git
synced 2024-09-04 01:47:19 +02:00
- update CI4 to v4.1.9's stable production package - update php and js dependencies to latest
265 lines
8.1 KiB
PHP
265 lines
8.1 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;
|
|
|
|
/**
|
|
* 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';
|
|
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_filter($lookupPaths, function ($path): bool {
|
|
return str_starts_with($this->currentView, $path);
|
|
});
|
|
$pathsToDiscover = array_values($pathsToDiscover);
|
|
$pathsToDiscover[] = $this->config->defaultLookupPath;
|
|
|
|
$namePath = str_replace('.', '/', $name);
|
|
|
|
foreach ($pathsToDiscover as $basePath) {
|
|
// Look for a class component first
|
|
$filePath = $basePath . $this->config->componentsDirectory . '/' . $namePath . '.php';
|
|
|
|
if (is_file($filePath)) {
|
|
return $filePath;
|
|
}
|
|
|
|
$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);
|
|
|
|
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 {
|
|
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, "\'\"");
|
|
}
|
|
}
|