mirror of
https://github.com/ad-aures/castopod.git
synced 2026-04-14 20:17:46 +02:00
feat(components): add custom view renderer with ComponentRenderer adapted from bonfire2
- update Component class structure and remove component helper function and ComponentLoader - update residual activitypub naming to fediverse
This commit is contained in:
parent
5083cd2fda
commit
a95de8bab0
106 changed files with 1482 additions and 2139 deletions
264
app/Libraries/ViewComponents/ComponentRenderer.php
Normal file
264
app/Libraries/ViewComponents/ComponentRenderer.php
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
<?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?
|
||||
$modulesToDiscover = [APPPATH];
|
||||
foreach (config('Autoload')->psr4 as $namespace => $path) {
|
||||
if (str_starts_with($this->currentView, $namespace)) {
|
||||
array_unshift($modulesToDiscover, $path);
|
||||
}
|
||||
}
|
||||
|
||||
$namePath = str_replace('.', '/', $name);
|
||||
|
||||
foreach ($modulesToDiscover as $basePath) {
|
||||
// Look for a class component first
|
||||
$filePath = $basePath . $this->config->classComponentsPath . '/' . $namePath . '.php';
|
||||
|
||||
if (is_file($filePath)) {
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
$camelCaseName = strtolower(preg_replace('~(?<!^)(?<!\/)[A-Z]~', '_$0', $namePath) ?? '');
|
||||
$filePath = $basePath . $this->config->viewFileComponentsPath . '/' . $camelCaseName . '.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, "\'\"");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue