Subida del módulo y tema de PrestaShop

This commit is contained in:
Kaloyan
2026-04-09 18:31:51 +02:00
parent 12c253296f
commit 16b3ff9424
39262 changed files with 7418797 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\DependencyInjection\Loader\Configurator;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $container): void {
$container->services()
->set('cache.ux.twig_component')
->parent('cache.system')
->private()
->tag('cache.pool')
;
};

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\DependencyInjection\Loader\Configurator;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\UX\TwigComponent\DataCollector\TwigComponentDataCollector;
use Symfony\UX\TwigComponent\EventListener\TwigComponentLoggerListener;
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
return static function (ContainerConfigurator $container) {
$container->services()
->set('ux.twig_component.component_logger_listener', TwigComponentLoggerListener::class)
->tag('kernel.event_subscriber')
->set('ux.twig_component.data_collector', TwigComponentDataCollector::class)
->args([
service('ux.twig_component.component_logger_listener'),
service('twig'),
])
->tag('data_collector', [
'template' => '@TwigComponent/Collector/twig_component.html.twig',
'id' => 'twig_component',
'priority' => 256,
]);
};

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
/**
* @author Matheo Daninos <matheo.daninos@gmail.com>
*
* @internal
*/
final class AnonymousComponent
{
private array $props;
public function mount($props = []): void
{
$this->props = $props;
}
#[ExposeInTemplate(destruct: true)]
public function getProps(): array
{
return $this->props;
}
}

View File

@@ -0,0 +1,110 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Attribute;
/**
* An attribute to register a TwigComponent.
*
* @see https://symfony.com/bundles/ux-twig-component
*
* @author Kevin Bond <kevinbond@gmail.com>
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class AsTwigComponent
{
public function __construct(
/**
* The component name (ie: Button).
*
* With the default configuration, the template path is resolved using
* the component's class name.
*
* App\Twig\Components\Alert -> <twig:Alert />
* App\Twig\Components\Foo\Bar -> <twig:Foo:Bar />
*
* @see https://symfony.com/bundles/ux-twig-component#naming-your-component
*/
private ?string $name = null,
/**
* The template path of the component (ie: components/Button.html.twig).
*
* With the default configuration, the template path is resolved using
* the component's name.
*
* Button -> templates/components/Button.html.twig
* Foo:Bar -> templates/components/Foo/Bar.html.twig
*
* @see https://symfony.com/bundles/ux-twig-component#component-template-path
*/
private ?string $template = null,
/**
* Whether to expose every public property as a Twig variable.
*
* @see https://symfony.com/bundles/ux-twig-component#passing-data-props-into-your-component
*/
private bool $exposePublicProps = true,
/**
* The name of the special "attributes" variable in the template.
*/
private string $attributesVar = 'attributes',
) {
}
/**
* @internal
*/
public function serviceConfig(): array
{
return [
'key' => $this->name,
'template' => $this->template,
'expose_public_props' => $this->exposePublicProps,
'attributes_var' => $this->attributesVar,
];
}
/**
* @param object|class-string $component
* @param class-string $attributeClass
*
* @return \ReflectionMethod[]
*
* @internal
*/
protected static function attributeMethodsByPriorityFor(object|string $component, string $attributeClass): array
{
$methods = iterator_to_array(self::attributeMethodsFor($attributeClass, $component));
usort($methods, static function (\ReflectionMethod $a, \ReflectionMethod $b) use ($attributeClass) {
return $a->getAttributes($attributeClass)[0]->newInstance()->priority <=> $b->getAttributes($attributeClass)[0]->newInstance()->priority;
});
return array_reverse($methods);
}
/**
* @return \Traversable<\ReflectionMethod>
*
* @internal
*/
protected static function attributeMethodsFor(string $attribute, object|string $component): \Traversable
{
foreach ((new \ReflectionClass($component))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
if ($method->getAttributes($attribute, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null) {
yield $method;
}
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Attribute;
/**
* Use to expose private/protected properties as variables directly
* in a component template (`someProp` vs `this.someProp`). These
* properties must be "accessible" (have a getter).
*
* @see https://symfony.com/bundles/ux-twig-component#exposeintemplate-attribute
*
* @author Kevin Bond <kevinbond@gmail.com>
*/
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD)]
final class ExposeInTemplate
{
/**
* @param string|null $name The variable name to expose. Leave as null
* to default to property name.
* @param string|null $getter The getter method to use. Leave as null
* to default to PropertyAccessor logic.
* @param bool $destruct The content should be used as array of variable
* names
*/
public function __construct(public ?string $name = null, public ?string $getter = null, public bool $destruct = false)
{
}
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Attribute;
/**
* An attribute to register a PostMount hook.
*
* @see https://symfony.com/bundles/ux-twig-component#postmount-hook
*
* @author Kevin Bond <kevinbond@gmail.com>
*/
#[\Attribute(\Attribute::TARGET_METHOD)]
final class PostMount
{
/**
* @param int $priority If multiple hooks are registered in a component, use to configure
* the order in which they are called (higher called earlier)
*/
public function __construct(public int $priority = 0)
{
}
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Attribute;
/**
* An attribute to register a PreMount hook.
*
* @see https://symfony.com/bundles/ux-twig-component#premount-hook
*
* @author Kevin Bond <kevinbond@gmail.com>
*/
#[\Attribute(\Attribute::TARGET_METHOD)]
final class PreMount
{
/**
* @param int $priority If multiple hooks are registered in a component, use to configure
* the order in which they are called (higher called earlier)
*/
public function __construct(public int $priority = 0)
{
}
}

View File

@@ -0,0 +1,150 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent;
use Twig\Template;
/**
* @author Bart Vanderstukken <bart.vanderstukken@gmail.com>
*
* @internal
*/
final class BlockStack
{
private const OUTER_BLOCK_PREFIX = 'outer__';
public const OUTER_BLOCK_FALLBACK_NAME = self::OUTER_BLOCK_PREFIX.'block_fallback';
/**
* @var array<string, array<int, array<int, string>>>
*/
private array $stack;
/**
* @var array<class-string, int>
*/
private static array $templateIndexStack = [];
public function convert(array $blocks, int $targetEmbeddedTemplateIndex): array
{
$newBlocks = [];
$hostEmbeddedTemplateIndex = null;
foreach ($blocks as $blockName => $block) {
// Keep already converted outer blocks untouched
if (str_starts_with($blockName, self::OUTER_BLOCK_PREFIX)) {
$newBlocks[$blockName] = $block;
continue;
}
// Determine the location of the block where it is defined in the host Template.
// Each component has its own embedded template. That template's index uniquely
// identifies the block definition.
$hostEmbeddedTemplateIndex ??= $this->findHostEmbeddedTemplateIndex();
// Change the name of outer blocks to something unique so blocks of nested components aren't overridden,
// which otherwise might cause a recursion loop when nesting components.
$newName = self::OUTER_BLOCK_PREFIX.$blockName.'_'.mt_rand();
$newBlocks[$newName] = $block;
// The host index combined with the index of the embedded template where the block can be used (target)
// allows us to remember the link between the original name and the new randomized name.
// That way we can map a call like `block(outerBlocks.block_name)` to the randomized name.
$this->stack[$blockName][$targetEmbeddedTemplateIndex][$hostEmbeddedTemplateIndex] = $newName;
}
return $newBlocks;
}
public function __call(string $name, array $arguments)
{
$callingEmbeddedTemplateIndex = $this->findCallingEmbeddedTemplateIndex();
$hostEmbeddedTemplateIndex = $this->findHostEmbeddedTemplateIndexFromCaller();
return $this->stack[$name][$callingEmbeddedTemplateIndex][$hostEmbeddedTemplateIndex] ?? self::OUTER_BLOCK_FALLBACK_NAME;
}
private function findHostEmbeddedTemplateIndex(): int
{
$backtrace = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT);
foreach ($backtrace as $trace) {
if (isset($trace['object']) && $trace['object'] instanceof Template) {
$classname = $trace['object']::class;
$templateIndex = self::getTemplateIndexFromTemplateClassname($classname);
if ($templateIndex) {
// If there's no template index, then we're in a component template
// and we need to go up until we find the embedded template
// (which will have the block definitions).
return $templateIndex;
}
}
}
return 0;
}
private function findCallingEmbeddedTemplateIndex(): int
{
$backtrace = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT);
foreach ($backtrace as $trace) {
if (isset($trace['object']) && $trace['object'] instanceof Template) {
return self::getTemplateIndexFromTemplateClassname($trace['object']::class);
}
}
}
private function findHostEmbeddedTemplateIndexFromCaller(): int
{
$backtrace = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT);
$blockCallerStack = [];
$renderer = null;
foreach ($backtrace as $trace) {
if (isset($trace['object']) && $trace['object'] instanceof Template) {
$classname = $trace['object']::class;
$templateIndex = self::getTemplateIndexFromTemplateClassname($classname);
if (null === $renderer) {
if ($templateIndex) {
// This class is an embedded template.
// Next class is either the renderer or a previous template that's passing blocks through.
$blockCallerStack[$classname] = $classname;
continue;
}
// If it's not an embedded template anymore, we've reached the renderer.
// From now on we'll travel back up the hierarchy.
$renderer = $classname;
continue;
}
if ($classname === $renderer || isset($blockCallerStack[$classname])) {
continue;
}
if (!$templateIndex) {
continue;
}
// This is the first template that's not part of the callstack,
// so it's the template that has the outer block definition.
return $templateIndex;
}
}
// If the component is not an embedded one, just return 0, so the fallback content (aka nothing) is used.
return 0;
}
private static function getTemplateIndexFromTemplateClassname(string $classname): int
{
return self::$templateIndexStack[$classname] ??= (int) substr($classname, strrpos($classname, '___') + 3);
}
}

View File

@@ -0,0 +1,140 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent;
/**
* Class Variant Authority (CVA) resolver.
*
* The CVA concept is used to render multiple variations of components, applying
* a set of conditions and recipes to dynamically compose CSS class strings.
*
* @see https://cva.style/docs
*
* @doc https://symfony.com/bundles/ux-twig-component/current/index.html
*
* @author Mathéo Daninos <matheo.daninos@gmail.com>
*
* @experimental
*
* @deprecated since Symfony UX 2.20, use CVA from the "twig/html-extra:^3.12.0" package instead.
*/
final class CVA
{
/**
* @var list<string|null>
*/
private readonly array $base;
/**
* @param string|list<string|null> $base The base classes to apply to the component
*/
public function __construct(
string|array $base = [],
/**
* The variants to apply based on recipes.
*
* Format: [variantCategory => [variantName => classes]]
*
* Example:
* 'colors' => [
* 'primary' => 'bleu-8000',
* 'danger' => 'red-800 text-bold',
* ],
* 'size' => [...],
*
* @var array<string, array<string, string|list<string>>>
*/
private readonly array $variants = [],
/**
* The compound variants to apply based on recipes.
*
* Format: [variantsCategory => ['variantName', 'variantName'], class: classes]
*
* Example:
* [
* 'colors' => ['primary'],
* 'size' => ['small'],
* 'class' => 'text-red-500',
* ],
* [
* 'size' => ['large'],
* 'class' => 'font-weight-500',
* ]
*
* @var array<array<string, string|array<string>>>
*/
private readonly array $compoundVariants = [],
/**
* The default variants to apply if specific recipes aren't provided.
*
* Format: [variantCategory => variantName]
*
* Example:
* 'colors' => 'primary',
*
* @var array<string, string>
*/
private readonly array $defaultVariants = [],
) {
$this->base = (array) $base;
}
public function apply(array $recipes, ?string ...$additionalClasses): string
{
$classes = [...$this->base];
// Resolve recipes against variants
foreach ($recipes as $recipeName => $recipeValue) {
if (\is_bool($recipeValue)) {
$recipeValue = $recipeValue ? 'true' : 'false';
}
$recipeClasses = $this->variants[$recipeName][$recipeValue] ?? [];
$classes = [...$classes, ...(array) $recipeClasses];
}
// Resolve compound variants
foreach ($this->compoundVariants as $compound) {
$compoundClasses = $this->resolveCompoundVariant($compound, $recipes) ?? [];
$classes = [...$classes, ...$compoundClasses];
}
// Apply default variants if specific recipes aren't provided
foreach ($this->defaultVariants as $defaultVariantName => $defaultVariantValue) {
if (!isset($recipes[$defaultVariantName])) {
$variantClasses = $this->variants[$defaultVariantName][$defaultVariantValue] ?? [];
$classes = [...$classes, ...(array) $variantClasses];
}
}
$classes = [...$classes, ...array_values($additionalClasses)];
$classes = implode(' ', array_filter($classes, is_string(...)));
$classes = preg_split('#\s+#', $classes, -1, \PREG_SPLIT_NO_EMPTY) ?: [];
return implode(' ', array_unique($classes));
}
private function resolveCompoundVariant(array $compound, array $recipes): array
{
foreach ($compound as $compoundName => $compoundValues) {
if ('class' === $compoundName) {
continue;
}
if (!isset($recipes[$compoundName]) || !\in_array($recipes[$compoundName], (array) $compoundValues)) {
return [];
}
}
return (array) ($compound['class'] ?? []);
}
}

View File

@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\CacheWarmer;
use Psr\Container\ContainerInterface;
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Symfony\UX\TwigComponent\ComponentProperties;
/**
* Warm the TwigComponent metadata caches.
*
* @author Simon André <smn.andre@gmail.com>
*
* @internal
*/
final class TwigComponentCacheWarmer implements CacheWarmerInterface, ServiceSubscriberInterface
{
/**
* As this cache warmer is optional, dependencies should be lazy-loaded, that's why a container should be injected.
*/
public function __construct(
private readonly ContainerInterface $container,
) {
}
public static function getSubscribedServices(): array
{
return [
'ux.twig_component.component_properties' => ComponentProperties::class,
];
}
public function warmUp(string $cacheDir, ?string $buildDir = null): array
{
$properties = $this->container->get('ux.twig_component.component_properties');
$properties->warmup();
return [];
}
public function isOptional(): bool
{
return true;
}
}

View File

@@ -0,0 +1,359 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Helper\TableSeparator;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Finder\Finder;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
use Symfony\UX\TwigComponent\ComponentFactory;
use Symfony\UX\TwigComponent\ComponentMetadata;
use Symfony\UX\TwigComponent\Twig\PropsNode;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
#[AsCommand(name: 'debug:twig-component', description: 'Display components and them usages for an application')]
class TwigComponentDebugCommand extends Command
{
private readonly string $anonymousDirectory;
public function __construct(
private string $twigTemplatesPath,
private ComponentFactory $componentFactory,
private Environment $twig,
private readonly array $componentClassMap,
?string $anonymousDirectory = null,
) {
parent::__construct();
$this->anonymousDirectory = $anonymousDirectory ?? 'components';
}
protected function configure(): void
{
$this
->setDefinition([
new InputArgument('name', InputArgument::OPTIONAL, 'A component name or part of the component name'),
])
->setHelp(
<<<'EOF'
The <info>%command.name%</info> display all the Twig components in your application.
To list all components:
<info>php %command.full_name%</info>
To get specific information about a component, specify its name (or a part of it):
<info>php %command.full_name% Alert</info>
EOF
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$name = $input->getArgument('name');
if (\is_string($name)) {
$component = $this->findComponentName($io, $name, $input->isInteractive());
if (null === $component) {
$io->error(\sprintf('Unknown component "%s".', $name));
return Command::FAILURE;
}
$this->displayComponentDetails($io, $component);
return Command::SUCCESS;
}
$components = $this->findComponents();
$this->displayComponentsTable($io, $components);
return Command::SUCCESS;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('name')) {
$suggestions->suggestValues(array_keys($this->findComponents()));
}
}
private function findComponentName(SymfonyStyle $io, string $name, bool $interactive): ?string
{
$components = [];
foreach ($this->componentClassMap as $componentName) {
if ($name === $componentName) {
return $name;
}
if (str_contains($componentName, $name)) {
$components[$componentName] = $componentName;
}
}
foreach ($this->findAnonymousComponents() as $componentName) {
if (isset($components[$componentName])) {
continue;
}
if ($name === $componentName) {
return $name;
}
if (str_contains($componentName, $name)) {
$components[$componentName] = $componentName;
}
}
if ($interactive && \count($components)) {
return $io->choice('Select one of the following component to display its information', array_values($components), 0);
}
return null;
}
/**
* @return array<string, ComponentMetadata>
*/
private function findComponents(): array
{
$components = [];
foreach ($this->componentClassMap as $class => $name) {
$components[$name] ??= $this->componentFactory->metadataFor($name);
}
foreach ($this->findAnonymousComponents() as $name => $template) {
$components[$name] ??= $this->componentFactory->metadataFor($name);
}
return $components;
}
/**
* Return a map of component name => template.
*
* @return array<string, string>
*/
private function findAnonymousComponents(): array
{
$componentsDir = $this->twigTemplatesPath.'/'.$this->anonymousDirectory;
$dirs = [$componentsDir => FilesystemLoader::MAIN_NAMESPACE];
$twigLoader = $this->twig->getLoader();
if ($twigLoader instanceof FilesystemLoader) {
foreach ($twigLoader->getNamespaces() as $namespace) {
if (str_starts_with($namespace, '!')) {
continue; // ignore parent convention namespaces
}
foreach ($twigLoader->getPaths($namespace) as $path) {
if (FilesystemLoader::MAIN_NAMESPACE === $namespace) {
$componentsDir = $path.'/'.$this->anonymousDirectory;
} else {
$componentsDir = $path.'/components';
}
if (!is_dir($componentsDir)) {
continue;
}
$dirs[$componentsDir] = $namespace;
}
}
}
$components = [];
$finderTemplates = new Finder();
$finderTemplates->files()
->in(array_keys($dirs))
->notPath('/_')
->name('*.html.twig')
;
foreach ($finderTemplates as $template) {
$component = str_replace(\DIRECTORY_SEPARATOR, ':', $template->getRelativePathname());
$component = substr($component, 0, -10); // remove file extension ".html.twig"
$path = $template->getPath();
if ($template->getRelativePath()) {
$path = rtrim(substr($template->getPath(), 0, -1 * \strlen($template->getRelativePath())), \DIRECTORY_SEPARATOR);
}
if (isset($dirs[$path]) && FilesystemLoader::MAIN_NAMESPACE !== $dirs[$path]) {
$component = $dirs[$path].':'.$component;
}
$components[$component] = $component;
}
return $components;
}
private function displayComponentDetails(SymfonyStyle $io, string $name): void
{
$metadata = $this->componentFactory->metadataFor($name);
$table = $io->createTable();
$table->setHeaderTitle('Component');
$table->setHeaders(['Property', 'Value']);
$table->addRows([
['Name', $metadata->getName()],
['Class', $metadata->get('class') ?? ''],
['Template', $metadata->getTemplate()],
]);
// Anonymous Component
if ($metadata->isAnonymous()) {
$table->addRows([
['Type', '<comment>Anonymous</comment>'],
new TableSeparator(),
['Properties', implode("\n", $this->getAnonymousComponentProperties($metadata))],
]);
$table->render();
return;
}
$table->addRows([
['Type', $metadata->get('live') ? '<info>Live</info>' : ''],
new TableSeparator(),
// ['Attributes Var', $metadata->get('attributes_var')],
['Public Props', $metadata->isPublicPropsExposed() ? 'Yes' : 'No'],
['Properties', implode("\n", $this->getComponentProperties($metadata))],
]);
$logMethod = function (\ReflectionMethod $m) {
$params = array_map(
fn (\ReflectionParameter $p) => '$'.$p->getName(),
$m->getParameters(),
);
return \sprintf('%s(%s)', $m->getName(), implode(', ', $params));
};
$hooks = [];
$reflector = new \ReflectionClass($metadata->getClass());
foreach ($metadata->getPreMounts() as $method) {
$hooks[] = ['PreMount', $logMethod($reflector->getMethod($method))];
}
foreach ($metadata->getMounts() as $method) {
$hooks[] = ['Mount', $logMethod($reflector->getMethod($method))];
}
foreach ($metadata->getPostMounts() as $method) {
$hooks[] = ['PostMount', $logMethod($reflector->getMethod($method))];
}
if ($hooks) {
$table->addRows([
new TableSeparator(),
...$hooks,
]);
}
$table->render();
}
/**
* @param array<ComponentMetadata> $components
*/
private function displayComponentsTable(SymfonyStyle $io, array $components): void
{
$table = $io->createTable();
$table->setStyle('default');
$table->setHeaderTitle('Components');
$table->setHeaders(['Name', 'Class', 'Template', 'Type']);
foreach ($components as $metadata) {
$table->addRow([
$metadata->getName(),
$metadata->get('class') ? $metadata->getClass() : '',
$metadata->getTemplate(),
$metadata->get('live') ? '<info>Live</info>' : ($metadata->get('class') ? '' : '<comment>Anon</comment>'),
]);
}
$table->render();
}
/**
* @return array<string, string>
*/
private function getComponentProperties(ComponentMetadata $metadata): array
{
$properties = [];
$reflectionClass = new \ReflectionClass($metadata->getClass());
foreach ($reflectionClass->getProperties() as $property) {
$propertyName = $property->getName();
if ($metadata->isPublicPropsExposed() && $property->isPublic()) {
$type = $property->getType();
if ($type instanceof \ReflectionNamedType) {
$typeName = $type->getName();
} else {
$typeName = (string) $type;
}
$value = $property->hasDefaultValue() ? $property->getDefaultValue() : null;
$propertyDisplay = $typeName.' $'.$propertyName.(null !== $value ? ' = '.json_encode($value) : '');
$properties[$property->name] = $propertyDisplay;
}
foreach ($property->getAttributes(ExposeInTemplate::class) as $exposeAttribute) {
/** @var ExposeInTemplate $attribute */
$attribute = $exposeAttribute->newInstance();
$properties[$property->name] = $attribute->name ?? $property->name;
}
}
return $properties;
}
/**
* Extract properties from {% props %} tag in anonymous template.
*
* @return array<string, string>
*/
private function getAnonymousComponentProperties(ComponentMetadata $metadata): array
{
$source = $this->twig->load($metadata->getTemplate())->getSourceContext();
$tokenStream = $this->twig->tokenize($source);
$moduleNode = $this->twig->parse($tokenStream);
$propsNode = null;
foreach ($moduleNode->getNode('body') as $bodyNode) {
foreach ($bodyNode as $node) {
if (PropsNode::class === $node::class) {
$propsNode = $node;
break 2;
}
}
}
if (!$propsNode instanceof PropsNode) {
return [];
}
$propertyNames = $propsNode->getAttribute('names');
$properties = array_combine($propertyNames, $propertyNames);
foreach ($propertyNames as $propName) {
if ($propsNode->hasNode($propName)
&& ($valueNode = $propsNode->getNode($propName))
&& $valueNode->hasAttribute('value')
) {
$value = $valueNode->getAttribute('value');
if (\is_bool($value)) {
$value = $value ? 'true' : 'false';
} else {
$value = json_encode($value);
}
$properties[$propName] = $propName.' = '.$value;
}
}
return $properties;
}
}

View File

@@ -0,0 +1,267 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent;
use Symfony\UX\StimulusBundle\Dto\StimulusAttributes;
use Symfony\WebpackEncoreBundle\Dto\AbstractStimulusDto;
use Twig\Runtime\EscaperRuntime;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*
* @immutable
*/
final class ComponentAttributes implements \Stringable, \IteratorAggregate, \Countable
{
private const NESTED_REGEX = '#^([\w-]+):(.+)$#';
private const ALPINE_REGEX = '#^x-([a-z]+):[^:]+$#';
private const VUE_REGEX = '#^v-([a-z]+):[^:]+$#';
/** @var array<string,true> */
private array $rendered = [];
/**
* @param array<string, string|bool> $attributes
*/
public function __construct(
private array $attributes,
private readonly EscaperRuntime $escaper,
) {
}
public function __toString(): string
{
$attributes = '';
foreach ($this->attributes as $key => $value) {
if (isset($this->rendered[$key])) {
continue;
}
if (false === $value) {
continue;
}
if (
str_contains($key, ':')
&& preg_match(self::NESTED_REGEX, $key)
&& !preg_match(self::ALPINE_REGEX, $key)
&& !preg_match(self::VUE_REGEX, $key)
) {
continue;
}
if (null === $value) {
trigger_deprecation('symfony/ux-twig-component', '2.8.0', 'Passing "null" as an attribute value is deprecated and will throw an exception in 3.0.');
$value = true;
}
if (!\is_scalar($value) && !($value instanceof \Stringable)) {
throw new \LogicException(\sprintf('A "%s" prop was passed when creating the component. No matching "%s" property or mount() argument was found, so we attempted to use this as an HTML attribute. But, the value is not a scalar (it\'s a "%s"). Did you mean to pass this to your component or is there a typo on its name?', $key, $key, get_debug_type($value)));
}
if (true === $value && str_starts_with($key, 'aria-')) {
$value = 'true';
}
// Allowed characters in attribute names:
// - common attribute names (HTML 5):
// id, class, style, title, lang, dir, role,...
// data-*, aria-*,
// xml:*, xmlns:*,
// - special syntax names (Vue.js, Svelte, Alpine.js, ...)
// v-*, x-*, @*, :*
if (!ctype_alnum(str_replace(['-', '_', ':', '@', '.'], '', $key))) {
$key = (string) $this->escaper->escape($key, 'html_attr');
}
if (true === $value) {
$attributes .= ' '.$key;
} else {
if (!ctype_alnum(str_replace(['-', '_'], '', $value))) {
$value = $this->escaper->escape($value, 'html');
}
$attributes .= ' '.\sprintf('%s="%s"', $key, $value);
}
}
return $attributes;
}
public function __clone(): void
{
$this->rendered = [];
}
public function render(string $attribute): ?string
{
if (null === $value = $this->attributes[$attribute] ?? null) {
return null;
}
if ($value instanceof \Stringable) {
$value = (string) $value;
}
if (true === $value && str_starts_with($attribute, 'aria-')) {
$value = 'true';
}
if (!\is_string($value)) {
throw new \LogicException(\sprintf('Can only get string attributes (%s is a "%s").', $attribute, get_debug_type($value)));
}
$this->rendered[$attribute] = true;
return $value;
}
/**
* @return array<string, string|bool>
*/
public function all(): array
{
return $this->attributes;
}
/**
* Set default attributes. These are used if they are not already
* defined.
*
* "class" and "data-controller" are special, these defaults are prepended to
* the existing attribute (if available).
*/
public function defaults(iterable $attributes): self
{
if ($attributes instanceof StimulusAttributes) {
$attributes = $attributes->toArray();
}
if ($attributes instanceof \Traversable) {
$attributes = iterator_to_array($attributes);
}
foreach ($this->attributes as $key => $value) {
if (\in_array($key, ['class', 'data-controller', 'data-action'], true) && isset($attributes[$key])) {
$attributes[$key] = "{$attributes[$key]} {$value}";
continue;
}
$attributes[$key] = $value;
}
foreach (array_keys($this->rendered) as $attribute) {
unset($attributes[$attribute]);
}
return new self($attributes, $this->escaper);
}
/**
* Extract only these attributes.
*/
public function only(string ...$keys): self
{
$attributes = [];
foreach ($this->attributes as $key => $value) {
if (\in_array($key, $keys, true)) {
$attributes[$key] = $value;
}
}
return new self($attributes, $this->escaper);
}
/**
* Extract all but these attributes.
*/
public function without(string ...$keys): self
{
$clone = clone $this;
foreach ($keys as $key) {
unset($clone->attributes[$key]);
}
return $clone;
}
public function add($stimulusDto): self
{
if ($stimulusDto instanceof AbstractStimulusDto) {
trigger_deprecation('symfony/ux-twig-component', '2.9.0', 'Passing a StimulusDto to ComponentAttributes::add() is deprecated. Run "composer require symfony/stimulus-bundle" then use "attributes.defaults(stimulus_controller(\'...\'))".');
} elseif ($stimulusDto instanceof StimulusAttributes) {
trigger_deprecation('symfony/ux-twig-component', '2.9.0', 'Calling ComponentAttributes::add() is deprecated. Instead use "attributes.defaults(stimulus_controller(\'...\'))".');
return $this->defaults($stimulusDto);
} else {
throw new \InvalidArgumentException(\sprintf('Argument 1 passed to "%s()" must be an instance of "%s" or "%s", "%s" given.', __METHOD__, AbstractStimulusDto::class, StimulusAttributes::class, get_debug_type($stimulusDto)));
}
$controllersAttributes = $stimulusDto->toArray();
$attributes = $this->attributes;
$attributes['data-controller'] = trim(implode(' ', array_merge(
explode(' ', $attributes['data-controller'] ?? ''),
explode(' ', $controllersAttributes['data-controller'] ?? [])
)));
unset($controllersAttributes['data-controller']);
$clone = new self($attributes, $this->escaper);
// add the remaining attributes for values/classes
return $clone->defaults($controllersAttributes);
}
public function remove($key): self
{
$attributes = $this->attributes;
unset($attributes[$key]);
return new self($attributes, $this->escaper);
}
public function nested(string $namespace): self
{
$attributes = [];
foreach ($this->attributes as $key => $value) {
if (
str_contains($key, ':')
&& preg_match(self::NESTED_REGEX, $key, $matches) && $namespace === $matches[1]
) {
$attributes[$matches[2]] = $value;
}
}
return new self($attributes, $this->escaper);
}
public function getIterator(): \Traversable
{
return new \ArrayIterator($this->attributes);
}
public function has(string $attribute): bool
{
return \array_key_exists($attribute, $this->attributes);
}
public function count(): int
{
return \count($this->attributes);
}
}

View File

@@ -0,0 +1,245 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Contracts\Service\ResetInterface;
use Symfony\UX\TwigComponent\Event\PostMountEvent;
use Symfony\UX\TwigComponent\Event\PreMountEvent;
use Twig\Environment;
use Twig\Runtime\EscaperRuntime;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*
* @internal
*/
final class ComponentFactory implements ResetInterface
{
private array $mountMethods = [];
/**
* @param array<string, array> $config
* @param array<class-string, string> $classMap
*/
public function __construct(
private ComponentTemplateFinderInterface $componentTemplateFinder,
private ServiceLocator $components,
private PropertyAccessorInterface $propertyAccessor,
private EventDispatcherInterface $eventDispatcher,
private array $config,
private readonly array $classMap,
private readonly Environment $twig,
) {
}
public function metadataFor(string $name): ComponentMetadata
{
if ($config = $this->config[$name] ?? null) {
return new ComponentMetadata($config);
}
if ($template = $this->componentTemplateFinder->findAnonymousComponentTemplate($name)) {
$this->config[$name] = [
'key' => $name,
'template' => $template,
];
return new ComponentMetadata($this->config[$name]);
}
if ($mappedName = $this->classMap[$name] ?? null) {
if ($config = $this->config[$mappedName] ?? null) {
return new ComponentMetadata($config);
}
throw new \InvalidArgumentException(\sprintf('Unknown component "%s".', $name));
}
$this->throwUnknownComponentException($name);
}
/**
* Creates the component and "mounts" it with the passed data.
*/
public function create(string $name, array $data = []): MountedComponent
{
$metadata = $this->metadataFor($name);
if ($metadata->isAnonymous()) {
return $this->mountFromObject(new AnonymousComponent(), $data, $metadata);
}
return $this->mountFromObject($this->components->get($metadata->getName()), $data, $metadata);
}
/**
* @internal
*/
public function mountFromObject(object $component, array $data, ComponentMetadata $componentMetadata): MountedComponent
{
$originalData = $data;
$event = $this->preMount($component, $data, $componentMetadata);
$data = $event->getData();
$this->mount($component, $data, $componentMetadata);
if (!$componentMetadata->isAnonymous()) {
// set data that wasn't set in mount on the component directly
foreach ($data as $property => $value) {
if ($this->propertyAccessor->isWritable($component, $property)) {
$this->propertyAccessor->setValue($component, $property, $value);
unset($data[$property]);
}
}
}
$postMount = $this->postMount($component, $data, $componentMetadata);
$data = $postMount->getData();
// create attributes from "attributes" key if exists
$attributesVar = $componentMetadata->getAttributesVar();
$attributes = $data[$attributesVar] ?? [];
unset($data[$attributesVar]);
foreach ($data as $key => $value) {
if ($value instanceof \Stringable) {
$data[$key] = (string) $value;
}
}
return new MountedComponent(
$componentMetadata->getName(),
$component,
new ComponentAttributes([...$attributes, ...$data], $this->twig->getRuntime(EscaperRuntime::class)),
$originalData,
$postMount->getExtraMetadata(),
);
}
/**
* Returns the "unmounted" component.
*
* @internal
*/
public function get(string $name): object
{
$metadata = $this->metadataFor($name);
if ($metadata->isAnonymous()) {
return new AnonymousComponent();
}
return $this->components->get($metadata->getName());
}
private function mount(object $component, array &$data, ComponentMetadata $componentMetadata): void
{
if ($component instanceof AnonymousComponent) {
$component->mount($data);
return;
}
if (!$componentMetadata->getMounts()) {
return;
}
$mount = $this->mountMethods[$component::class] ??= (new \ReflectionClass($component))->getMethod('mount');
$parameters = [];
foreach ($mount->getParameters() as $refParameter) {
if (\array_key_exists($name = $refParameter->getName(), $data)) {
$parameters[] = $data[$name];
// remove the data element so it isn't used to set the property directly.
unset($data[$name]);
} elseif ($refParameter->isDefaultValueAvailable()) {
$parameters[] = $refParameter->getDefaultValue();
} else {
throw new \LogicException(\sprintf('"%s" has a required $%s parameter. Make sure to pass it or give it a default value.', $component::class.'::mount()', $name));
}
}
$mount->invoke($component, ...$parameters);
}
private function preMount(object $component, array $data, ComponentMetadata $componentMetadata): PreMountEvent
{
$event = new PreMountEvent($component, $data, $componentMetadata);
$this->eventDispatcher->dispatch($event);
$data = $event->getData();
foreach ($componentMetadata->getPreMounts() as $preMount) {
if (null !== $newData = $component->$preMount($data)) {
$event->setData($data = $newData);
}
}
return $event;
}
private function postMount(object $component, array $data, ComponentMetadata $componentMetadata): PostMountEvent
{
$event = new PostMountEvent($component, $data, $componentMetadata);
$this->eventDispatcher->dispatch($event);
$data = $event->getData();
foreach ($componentMetadata->getPostMounts() as $postMount) {
if (null !== $newData = $component->$postMount($data)) {
$event->setData($data = $newData);
}
}
return $event;
}
/**
* @return never
*/
private function throwUnknownComponentException(string $name): void
{
$message = \sprintf('Unknown component "%s".', $name);
$lowerName = strtolower($name);
$nameLength = \strlen($lowerName);
$alternatives = [];
foreach (array_keys($this->config) as $type) {
$lowerType = strtolower($type);
$lev = levenshtein($lowerName, $lowerType);
if ($lev <= $nameLength / 3 || str_contains($lowerType, $lowerName)) {
$alternatives[] = $type;
}
}
if ($alternatives) {
if (1 === \count($alternatives)) {
$message .= ' Did you mean this: "';
} else {
$message .= ' Did you mean one of these: "';
}
$message .= implode('", "', $alternatives).'"?';
} else {
$message .= ' And no matching anonymous component template was found.';
}
throw new \InvalidArgumentException($message);
}
public function reset(): void
{
$this->mountMethods = [];
}
}

View File

@@ -0,0 +1,104 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
final class ComponentMetadata
{
/**
* @internal
*/
public function __construct(private array $config)
{
}
public function getName(): string
{
return $this->config['key'];
}
/**
* @return string Component's twig template
*/
public function getTemplate(): string
{
return $this->config['template'];
}
/**
* @return class-string The Component's FQCN
*/
public function getClass(): string
{
return $this->config['class'];
}
/**
* @return string The Component's service id
*/
public function getServiceId(): string
{
return $this->config['service_id'];
}
public function isPublicPropsExposed(): bool
{
return $this->get('expose_public_props', false);
}
public function isAnonymous(): bool
{
return !isset($this->config['service_id']);
}
public function getAttributesVar(): string
{
return $this->get('attributes_var', 'attributes');
}
/**
* @return list<string>
*
* @internal
*/
public function getPreMounts(): array
{
return $this->get('pre_mount', []);
}
/**
* @return list<string>
*
* @internal
*/
public function getMounts(): array
{
return $this->get('mount', []);
}
/**
* @return list<string>
*
* @internal
*/
public function getPostMounts(): array
{
return $this->get('post_mount', []);
}
public function get(string $key, mixed $default = null): mixed
{
return $this->config[$key] ?? $default;
}
}

View File

@@ -0,0 +1,148 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent;
use Symfony\Component\Cache\Adapter\AdapterInterface;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
/**
* @author Simon André <smn.andre@gmail.com>
*
* @internal
*/
final class ComponentProperties
{
private const CACHE_KEY = 'ux.twig_component.component_properties';
/**
* @var array<class-string, array{
* properties: array<class-string, array{string, array{string, string, bool}, bool}>,
* methods: array<class-string, array{string, array{string, bool}}>,
* }|null>
*/
private array $classMetadata;
public function __construct(
private readonly PropertyAccessorInterface $propertyAccessor,
?array $classMetadata = [],
private readonly ?AdapterInterface $cache = null,
) {
$cacheItem = $this->cache?->getItem(self::CACHE_KEY);
$this->classMetadata = $cacheItem?->isHit() ? [...$cacheItem->get(), ...$classMetadata] : $classMetadata;
}
/**
* @return array<string, mixed>
*/
public function getProperties(object $component, bool $publicProps = false): array
{
return iterator_to_array($this->extractProperties($component, $publicProps));
}
public function warmup(): void
{
if (!$this->cache) {
return;
}
foreach ($this->classMetadata as $class => $metadata) {
if (null === $metadata) {
$this->classMetadata[$class] = $this->loadClassMetadata($class);
}
}
$this->cache->save($this->cache->getItem(self::CACHE_KEY)->set($this->classMetadata));
}
/**
* @return \Generator<string, mixed>
*/
private function extractProperties(object $component, bool $publicProps): \Generator
{
yield from $publicProps ? get_object_vars($component) : [];
$metadata = $this->classMetadata[$component::class] ??= $this->loadClassMetadata($component::class);
foreach ($metadata['properties'] as $propertyName => $property) {
$value = $property['getter'] ? $component->{$property['getter']}() : $this->propertyAccessor->getValue($component, $propertyName);
if ($property['destruct'] ?? false) {
yield from $value;
} else {
yield $property['name'] => $value;
}
}
foreach ($metadata['methods'] as $methodName => $method) {
if ($method['destruct'] ?? false) {
yield from $component->{$methodName}();
} else {
yield $method['name'] => $component->{$methodName}();
}
}
}
/**
* @param class-string $class
*
* @return array{
* properties: array<string, array{
* name?: string,
* getter?: string,
* destruct?: bool
* }>,
* methods: array<string, array{
* name?: string,
* destruct?: bool
* }>,
* }
*/
private function loadClassMetadata(string $class): array
{
$refClass = new \ReflectionClass($class);
$properties = [];
foreach ($refClass->getProperties() as $property) {
if (!$attributes = $property->getAttributes(ExposeInTemplate::class)) {
continue;
}
$attribute = $attributes[0]->newInstance();
$properties[$property->name] = [
'name' => $attribute->name ?? $property->name,
'getter' => $attribute->getter ? rtrim($attribute->getter, '()') : null,
];
if ($attribute->destruct) {
unset($properties[$property->name]['name']);
$properties[$property->name]['destruct'] = true;
}
}
$methods = [];
foreach ($refClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
if (!$attributes = $method->getAttributes(ExposeInTemplate::class)) {
continue;
}
if ($method->getNumberOfRequiredParameters()) {
throw new \LogicException(\sprintf('Cannot use "%s" on methods with required parameters (%s::%s).', ExposeInTemplate::class, $class, $method->name));
}
$attribute = $attributes[0]->newInstance();
$name = $attribute->name ?? (str_starts_with($method->name, 'get') ? lcfirst(substr($method->name, 3)) : $method->name);
$methods[$method->name] = $attribute->destruct ? ['destruct' => true] : ['name' => $name];
}
return [
'properties' => $properties,
'methods' => $methods,
];
}
}

View File

@@ -0,0 +1,148 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Service\ResetInterface;
use Symfony\UX\TwigComponent\Event\PostRenderEvent;
use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent;
use Symfony\UX\TwigComponent\Event\PreRenderEvent;
use Twig\Environment;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*
* @internal
*/
final class ComponentRenderer implements ComponentRendererInterface, ResetInterface
{
private array $templateClasses = [];
public function __construct(
private Environment $twig,
private EventDispatcherInterface $dispatcher,
private ComponentFactory $factory,
private ComponentProperties $componentProperties,
private ComponentStack $componentStack,
) {
}
/**
* Allow the render process to be short-circuited.
*/
public function preCreateForRender(string $name, array $props = []): ?string
{
$event = new PreCreateForRenderEvent($name, $props);
$this->dispatcher->dispatch($event);
return $event->getRenderedString();
}
public function createAndRender(string $name, array $props = []): string
{
if ($preRendered = $this->preCreateForRender($name, $props)) {
return $preRendered;
}
return $this->render($this->factory->create($name, $props));
}
public function render(MountedComponent $mounted): string
{
$this->componentStack->push($mounted);
$event = $this->preRender($mounted);
$variables = $event->getVariables();
// see ComponentNode. When rendering an individual embedded component,
// *not* through its parent, we need to set the parent template.
if ($templateIndex = $event->getTemplateIndex()) {
$variables['__parent__'] = $event->getParentTemplateForEmbedded();
}
try {
return $this->twig->loadTemplate(
$this->templateClasses[$template = $event->getTemplate()] ??= $this->twig->getTemplateClass($template),
$template,
$templateIndex,
)->render($variables);
} finally {
$mounted = $this->componentStack->pop();
$event = new PostRenderEvent($mounted);
$this->dispatcher->dispatch($event);
}
}
public function startEmbeddedComponentRender(string $name, array $props, array $context, string $hostTemplateName, int $index): PreRenderEvent
{
$context[PreRenderEvent::EMBEDDED] = true;
$mounted = $this->factory->create($name, $props);
$mounted->addExtraMetadata('hostTemplate', $hostTemplateName);
$mounted->addExtraMetadata('embeddedTemplateIndex', $index);
$this->componentStack->push($mounted);
return $this->preRender($mounted, $context);
}
public function finishEmbeddedComponentRender(): void
{
$mounted = $this->componentStack->pop();
$event = new PostRenderEvent($mounted);
$this->dispatcher->dispatch($event);
}
private function preRender(MountedComponent $mounted, array $context = []): PreRenderEvent
{
$component = $mounted->getComponent();
$metadata = $this->factory->metadataFor($mounted->getName());
$classProps = [];
if (!$metadata->isAnonymous()) {
$classProps = $this->componentProperties->getProperties($component, $metadata->isPublicPropsExposed());
}
// expose public properties and properties marked with ExposeInTemplate attribute
$props = [...$mounted->getInputProps(), ...$classProps];
$event = new PreRenderEvent($mounted, $metadata, [
...$context,
...$props,
$metadata->getAttributesVar() => $mounted->getAttributes(),
]);
$this->dispatcher->dispatch($event);
$event->setVariables([
...$event->getVariables(),
// add the component as "this"
'this' => $component,
'computed' => new ComputedPropertiesProxy($component),
'outerScope' => $context,
// keep this line for BC break reasons
'__props' => $classProps,
// add the context in a separate variable to keep track
// of what is coming from outside the component, excluding props
// as they override initial context values
'__context' => array_diff_key($context, $props),
]);
return $event;
}
public function reset(): void
{
$this->templateClasses = [];
}
}

View File

@@ -0,0 +1,20 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent;
interface ComponentRendererInterface
{
/**
* Create and render a twig component.
*/
public function createAndRender(string $name, array $props = []): string;
}

View File

@@ -0,0 +1,71 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent;
/**
* @author Ryan Weaver <ryan@symfonycasts.com>
*
* @internal
*/
class ComponentStack implements \IteratorAggregate
{
/**
* @var MountedComponent[]
*/
private array $components = [];
public function push(MountedComponent $components)
{
$this->components[] = $components;
}
public function pop(): ?MountedComponent
{
if (!$this->components) {
return null;
}
return array_pop($this->components);
}
/**
* The current component being rendered.
*/
public function getCurrentComponent(): ?MountedComponent
{
return end($this->components) ?: null;
}
/**
* The parent of the current component being rendered.
*/
public function getParentComponent(): ?MountedComponent
{
$components = $this->components;
array_pop($components);
return array_pop($components);
}
public function hasParentComponent(): bool
{
return (bool) $this->getParentComponent();
}
/**
* @return MountedComponent[]|\ArrayIterator
*/
public function getIterator(): \Traversable
{
return new \ArrayIterator(array_reverse($this->components));
}
}

View File

@@ -0,0 +1,81 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent;
use Twig\Environment;
use Twig\Loader\LoaderInterface;
/**
* @author Matheo Daninos <matheo.daninos@gmail.com>
*/
final class ComponentTemplateFinder implements ComponentTemplateFinderInterface
{
private readonly LoaderInterface $loader;
public function __construct(
Environment|LoaderInterface $loader,
private readonly ?string $directory = null,
) {
if ($loader instanceof Environment) {
trigger_deprecation('symfony/ux-twig-component', '2.13', 'The "%s()" method will require "%s $loader" as first argument in 3.0. Passing an "Environment" instance is deprecated.', __METHOD__, LoaderInterface::class);
$loader = $loader->getLoader();
}
$this->loader = $loader;
if (null === $this->directory) {
trigger_deprecation('symfony/ux-twig-component', '2.13', 'The "%s()" method will require "string $directory" argument in 3.0. Not defining it or passing null is deprecated.', __METHOD__);
}
}
public function findAnonymousComponentTemplate(string $name): ?string
{
$loader = $this->loader;
$componentPath = rtrim(str_replace(':', '/', $name));
// Legacy auto-naming rules < 2.13
if (null === $this->directory) {
if ($loader->exists('components/'.$componentPath.'.html.twig')) {
return 'components/'.$componentPath.'.html.twig';
}
if ($loader->exists($componentPath.'.html.twig')) {
return $componentPath.'.html.twig';
}
if ($loader->exists('components/'.$componentPath)) {
return 'components/'.$componentPath;
}
if ($loader->exists($componentPath)) {
return $componentPath;
}
return null;
}
$template = rtrim($this->directory, '/').'/'.$componentPath.'.html.twig';
if ($loader->exists($template)) {
return $template;
}
$parts = explode('/', $componentPath, 2);
if (\count($parts) < 2) {
return null;
}
$template = '@'.$parts[0].'/components/'.$parts[1].'.html.twig';
if ($loader->exists($template)) {
return $template;
}
return null;
}
}

View File

@@ -0,0 +1,20 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent;
/**
* @author Matheo Daninos <matheo.daninos@gmail.com>
*/
interface ComponentTemplateFinderInterface
{
public function findAnonymousComponentTemplate(string $name): ?string;
}

View File

@@ -0,0 +1,70 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
final class ComputedPropertiesProxy
{
private array $cache = [];
/**
* @internal
*/
public function __construct(private object $component)
{
}
public function __call(string $name, array $arguments): mixed
{
if ($arguments) {
throw new \InvalidArgumentException('Passing arguments to computed methods is not supported.');
}
if (isset($this->component->$name)) {
// try property
return $this->component->$name;
}
if ($this->component instanceof \ArrayAccess && isset($this->component[$name])) {
return $this->component[$name];
}
$method = $this->normalizeMethod($name);
if (isset($this->cache[$method])) {
return $this->cache[$method];
}
if ((new \ReflectionMethod($this->component, $method))->getNumberOfRequiredParameters()) {
throw new \LogicException('Cannot use computed methods for methods with required parameters.');
}
return $this->cache[$method] = $this->component->$method();
}
private function normalizeMethod(string $name): string
{
if (method_exists($this->component, $name)) {
return $name;
}
foreach (['get', 'is', 'has'] as $prefix) {
if (method_exists($this->component, $method = \sprintf('%s%s', $prefix, ucfirst($name)))) {
return $method;
}
}
throw new \InvalidArgumentException(\sprintf('Component "%s" does not have a "%s" method.', $this->component::class, $name));
}
}

View File

@@ -0,0 +1,190 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\DataCollector;
use Symfony\Bundle\FrameworkBundle\DataCollector\AbstractDataCollector;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface;
use Symfony\Component\VarDumper\Caster\ClassStub;
use Symfony\Component\VarDumper\Cloner\Data;
use Symfony\UX\TwigComponent\Event\PostRenderEvent;
use Symfony\UX\TwigComponent\Event\PreRenderEvent;
use Symfony\UX\TwigComponent\EventListener\TwigComponentLoggerListener;
use Twig\Environment;
use Twig\Error\LoaderError;
/**
* @author Simon André <smn.andre@gmail.com>
*
* @internal
*/
final class TwigComponentDataCollector extends AbstractDataCollector implements LateDataCollectorInterface
{
private bool $hasStub;
public function __construct(
private readonly TwigComponentLoggerListener $logger,
private readonly Environment $twig,
) {
$this->hasStub = class_exists(ClassStub::class);
}
public function collect(Request $request, Response $response, ?\Throwable $exception = null): void
{
}
public function lateCollect(): void
{
$this->collectDataFromLogger();
$this->data = $this->cloneVar($this->data);
}
public function getData(): array|Data
{
return $this->data;
}
public function getName(): string
{
return 'twig_component';
}
public function reset(): void
{
$this->logger->reset();
parent::reset();
}
public function getComponents(): array|Data
{
return $this->data['components'] ?? [];
}
public function getComponentCount(): int
{
return $this->data['component_count'] ?? 0;
}
public function getPeakMemoryUsage(): int
{
return $this->data['peak_memory_usage'] ?? 0;
}
public function getRenders(): array|Data
{
return $this->data['renders'] ?? [];
}
public function getRenderCount(): int
{
return $this->data['render_count'] ?? 0;
}
public function getRenderTime(): float
{
return (float) ($this->data['render_time'] ?? 0);
}
private function collectDataFromLogger(): void
{
$components = [];
$renders = [];
$ongoingRenders = [];
$classStubs = [];
$templatePaths = [];
foreach ($this->logger->getEvents() as [$event, $profile]) {
if ($event instanceof PreRenderEvent) {
$mountedComponent = $event->getMountedComponent();
$metadata = $event->getMetadata();
$componentName = $metadata->getName();
$componentClass = $mountedComponent->getComponent()::class;
$components[$componentName] ??= [
'name' => $componentName,
'class' => $componentClass,
'class_stub' => $classStubs[$componentClass] ??= ($this->hasStub ? new ClassStub($componentClass) : $componentClass),
'template' => $template = $metadata->getTemplate(),
'template_path' => $templatePaths[$template] ??= $this->resolveTemplatePath($template),
'render_count' => 0,
'render_time' => 0,
];
$renderId = spl_object_id($mountedComponent);
$renders[$renderId] = [
'name' => $componentName,
'class' => $componentClass,
'is_embed' => $event->isEmbedded(),
'input_props' => $mountedComponent->getInputProps(),
'attributes' => $mountedComponent->getAttributes()->all(),
'template_index' => $event->getTemplateIndex(),
'component' => $mountedComponent->getComponent(),
'depth' => \count($ongoingRenders),
'children' => [],
'render_start' => $profile[0],
];
if ($parentId = end($ongoingRenders)) {
$renders[$parentId]['children'][] = $renderId;
}
$ongoingRenders[$renderId] = $renderId;
continue;
}
if ($event instanceof PostRenderEvent) {
$mountedComponent = $event->getMountedComponent();
$componentName = $mountedComponent->getName();
$renderId = spl_object_id($mountedComponent);
$renderTime = ($profile[0] - $renders[$renderId]['render_start']) * 1000;
$renders[$renderId] += [
'render_end' => $profile[0],
'render_time' => $renderTime,
'render_memory' => (int) $profile[1],
];
++$components[$componentName]['render_count'];
$components[$componentName]['render_time'] += $renderTime;
unset($ongoingRenders[$renderId]);
}
}
// Sort by render count DESC
uasort($components, fn ($a, $b) => $b['render_count'] <=> $a['render_count']);
$this->data['components'] = $components;
$this->data['component_count'] = \count($components);
$this->data['renders'] = $renders;
$this->data['render_count'] = \count($renders);
$rootRenders = array_filter($renders, fn (array $r) => 0 === $r['depth']);
$this->data['render_time'] = array_sum(array_column($rootRenders, 'render_time'));
$this->data['peak_memory_usage'] = max([0, ...array_column($renders, 'render_memory')]);
}
private function resolveTemplatePath(string $logicalName): ?string
{
try {
$source = $this->twig->getLoader()->getSourceContext($logicalName);
} catch (LoaderError) {
return null;
}
return $source->getPath();
}
}

View File

@@ -0,0 +1,145 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\DependencyInjection\Compiler;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\LogicException;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\UX\TwigComponent\Attribute\PostMount;
use Symfony\UX\TwigComponent\Attribute\PreMount;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*
* @internal
*/
final class TwigComponentPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
$componentConfig = [];
$componentReferences = [];
$componentClassMap = [];
$componentNames = [];
$componentDefaults = $container->getParameter('ux.twig_component.component_defaults');
$container->getParameterBag()->remove('ux.twig_component.component_defaults');
$legacyAutoNaming = $container->hasParameter('ux.twig_component.legacy_autonaming');
$container->getParameterBag()->remove('ux.twig_component.legacy_autonaming');
foreach ($container->findTaggedServiceIds('twig.component') as $id => $tags) {
$definition = $container->findDefinition($id);
// component services must not be shared
$definition->setShared(false);
$fqcn = $definition->getClass();
$defaults = $this->findMatchingDefaults($fqcn, $componentDefaults);
foreach ($tags as $tag) {
if (!\array_key_exists('key', $tag)) {
if ($legacyAutoNaming) {
$name = substr($fqcn, strrpos($fqcn, '\\') + 1);
} else {
if (null === $defaults) {
throw new LogicException(\sprintf('Could not generate a component name for class "%s": no matching namespace found under the "twig_component.defaults" to use as a root. Check the config or give your component an explicit name.', $fqcn));
}
$name = str_replace('\\', ':', substr($fqcn, \strlen($defaults['namespace'])));
if ($defaults['name_prefix']) {
$name = \sprintf('%s:%s', $defaults['name_prefix'], $name);
}
}
if (\in_array($name, $componentNames, true)) {
throw new LogicException(\sprintf('Failed creating the "%s" component with the automatic name "%s": another component already has this name. To fix this, give the component an explicit name (hint: using "%s" will override the existing component).', $fqcn, $name, $name));
}
$tag['key'] = $name;
}
$tag['service_id'] = $id;
$tag['class'] = $definition->getClass();
$tag['template'] ??= $this->calculateTemplate($tag['key'], $defaults);
$componentConfig[$tag['key']] = [...$tag, ...$this->getMountMethods($tag['class'])];
$componentReferences[$tag['key']] = new Reference($id);
$componentNames[] = $tag['key'];
$componentClassMap[$tag['class']] = $tag['key'];
}
}
$factoryDefinition = $container->findDefinition('ux.twig_component.component_factory');
$factoryDefinition->setArgument(1, ServiceLocatorTagPass::register($container, $componentReferences));
$factoryDefinition->setArgument(4, $componentConfig);
$factoryDefinition->setArgument(5, $componentClassMap);
$componentPropertiesDefinition = $container->findDefinition('ux.twig_component.component_properties');
$componentPropertiesDefinition->setArgument(1, array_fill_keys(array_keys($componentClassMap), null));
$debugCommandDefinition = $container->findDefinition('ux.twig_component.command.debug');
$debugCommandDefinition->setArgument(3, $componentClassMap);
}
private function findMatchingDefaults(string $className, array $componentDefaults): ?array
{
foreach ($componentDefaults as $namespace => $defaults) {
if (str_starts_with($className, $namespace)) {
return array_merge(['namespace' => $namespace], $defaults);
}
}
return null;
}
private function calculateTemplate(string $componentName, ?array $defaults): string
{
$directory = $defaults && isset($defaults['template_directory']) ? $defaults['template_directory'] : 'components';
// if a name_prefix was added to the name, don't include it in the template path
if ($defaults && $defaults['name_prefix'] ?? null) {
$componentName = substr($componentName, \strlen($defaults['name_prefix']) + 1);
}
return \sprintf('%s/%s.html.twig', rtrim($directory, '/'), str_replace(':', '/', $componentName));
}
/**
* @param class-string $component
*
* @return array{preMount: string[], mount: string[], postMount: string[]}
*/
private function getMountMethods(string $component): array
{
$preMount = $mount = $postMount = [];
foreach ((new \ReflectionClass($component))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
foreach ($method->getAttributes(PreMount::class) as $attribute) {
$preMount[$method->getName()] = $attribute->newInstance()->priority;
}
foreach ($method->getAttributes(PostMount::class) as $attribute) {
$postMount[$method->getName()] = $attribute->newInstance()->priority;
}
if ('mount' === $method->getName()) {
$mount['mount'] = 0;
}
}
arsort($preMount, \SORT_NUMERIC);
arsort($mount, \SORT_NUMERIC);
arsort($postMount, \SORT_NUMERIC);
return [
'pre_mount' => array_keys($preMount),
'mount' => array_keys($mount),
'post_mount' => array_keys($postMount),
];
}
}

View File

@@ -0,0 +1,235 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\Argument\AbstractArgument;
use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument;
use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\DependencyInjection\Exception\LogicException;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
use Symfony\Component\DependencyInjection\Parameter;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Symfony\UX\TwigComponent\CacheWarmer\TwigComponentCacheWarmer;
use Symfony\UX\TwigComponent\Command\TwigComponentDebugCommand;
use Symfony\UX\TwigComponent\ComponentFactory;
use Symfony\UX\TwigComponent\ComponentProperties;
use Symfony\UX\TwigComponent\ComponentRenderer;
use Symfony\UX\TwigComponent\ComponentRendererInterface;
use Symfony\UX\TwigComponent\ComponentStack;
use Symfony\UX\TwigComponent\ComponentTemplateFinder;
use Symfony\UX\TwigComponent\DependencyInjection\Compiler\TwigComponentPass;
use Symfony\UX\TwigComponent\Twig\ComponentExtension;
use Symfony\UX\TwigComponent\Twig\ComponentLexer;
use Symfony\UX\TwigComponent\Twig\ComponentRuntime;
use Symfony\UX\TwigComponent\Twig\TwigEnvironmentConfigurator;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*
* @internal
*/
final class TwigComponentExtension extends Extension implements ConfigurationInterface
{
private const DEPRECATED_DEFAULT_KEY = '__deprecated__use_old_naming_behavior';
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../../config'));
if (!isset($container->getParameter('kernel.bundles')['TwigBundle'])) {
throw new LogicException('The TwigBundle is not registered in your application. Try running "composer require symfony/twig-bundle".');
}
$configuration = $this->getConfiguration($configs, $container);
$config = $this->processConfiguration($configuration, $configs);
$defaults = $config['defaults'];
if ($defaults === [self::DEPRECATED_DEFAULT_KEY]) {
trigger_deprecation('symfony/ux-twig-component', '2.13', 'Not setting the "twig_component.defaults" config option is deprecated. Check the documentation for an example configuration.');
$container->setParameter('ux.twig_component.legacy_autonaming', true);
$defaults = [];
}
$container->setParameter('ux.twig_component.component_defaults', $defaults);
$container->register('ux.twig_component.component_template_finder', ComponentTemplateFinder::class)
->setArguments([
new Reference('twig.loader'),
$config['anonymous_template_directory'],
]);
$container->setAlias(ComponentRendererInterface::class, 'ux.twig_component.component_renderer');
$container->registerAttributeForAutoconfiguration(
AsTwigComponent::class,
static function (ChildDefinition $definition, AsTwigComponent $attribute) {
$definition->addTag('twig.component', array_filter($attribute->serviceConfig()));
}
);
$container->register('ux.twig_component.component_factory', ComponentFactory::class)
->setArguments([
new Reference('ux.twig_component.component_template_finder'),
new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)),
new Reference('property_accessor'),
new Reference('event_dispatcher'),
new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)),
new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)),
new Reference('twig'),
])
->addTag('kernel.reset', ['method' => 'reset'])
;
$container->register('ux.twig_component.component_stack', ComponentStack::class);
$container->register('ux.twig_component.component_properties', ComponentProperties::class)
->setArguments([
new Reference('property_accessor'),
new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)),
new Reference('cache.ux.twig_component', ContainerInterface::IGNORE_ON_INVALID_REFERENCE),
])
;
$container->register('ux.twig_component.component_renderer', ComponentRenderer::class)
->setArguments([
new Reference('twig'),
new Reference('event_dispatcher'),
new Reference('ux.twig_component.component_factory'),
new Reference('ux.twig_component.component_properties'),
new Reference('ux.twig_component.component_stack'),
])
->addTag('kernel.reset', ['method' => 'reset'])
;
$container->register('ux.twig_component.twig.component_extension', ComponentExtension::class)
->addTag('twig.extension')
;
$container->register('ux.twig_component.twig.component_runtime', ComponentRuntime::class)
->setArguments([
new Reference('ux.twig_component.component_renderer'),
new ServiceLocatorArgument(new TaggedIteratorArgument('ux.twig_component.twig_renderer', indexAttribute: 'key', needsIndexes: true)),
])
->addTag('twig.runtime')
;
$container->register('ux.twig_component.twig.lexer', ComponentLexer::class);
$container->register('ux.twig_component.twig.environment_configurator', TwigEnvironmentConfigurator::class)
->setDecoratedService(new Reference('twig.configurator.environment'))
->setArguments([new Reference('ux.twig_component.twig.environment_configurator.inner')]);
$container->register('ux.twig_component.command.debug', TwigComponentDebugCommand::class)
->setArguments([
new Parameter('twig.default_path'),
new Reference('ux.twig_component.component_factory'),
new Reference('twig'),
new AbstractArgument(\sprintf('Added in %s.', TwigComponentPass::class)),
$config['anonymous_template_directory'],
])
->addTag('console.command')
;
$container->setAlias('console.command.stimulus_component_debug', 'ux.twig_component.command.debug')
->setDeprecated('symfony/ux-twig-component', '2.13', '%alias_id%');
if ($container->getParameter('kernel.debug') && $config['profiler']) {
$loader->load('debug.php');
}
$loader->load('cache.php');
$container->register('ux.twig_component.cache_warmer', TwigComponentCacheWarmer::class)
->setArguments([new Reference(\Psr\Container\ContainerInterface::class)])
->addTag('kernel.cache_warmer')
->addTag('container.service_subscriber', ['id' => 'ux.twig_component.component_properties'])
;
}
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('twig_component');
$rootNode = $treeBuilder->getRootNode();
\assert($rootNode instanceof ArrayNodeDefinition);
$rootNode
->validate()
->always(function ($v) {
if (!isset($v['anonymous_template_directory'])) {
trigger_deprecation('symfony/twig-component-bundle', '2.13', 'Not setting the "twig_component.anonymous_template_directory" config option is deprecated. It will default to "components" in 3.0.');
$v['anonymous_template_directory'] = null;
}
return $v;
})
->end()
->children()
->arrayNode('defaults')
->defaultValue([self::DEPRECATED_DEFAULT_KEY])
->useAttributeAsKey('namespace')
->validate()
->always(function ($v) {
foreach ($v as $namespace => $defaults) {
if (!str_ends_with($namespace, '\\')) {
throw new InvalidConfigurationException(\sprintf('The twig_component.defaults namespace "%s" is invalid: it must end in a "\".', $namespace));
}
}
return $v;
})
->end()
->arrayPrototype()
->beforeNormalization()
->ifString()
->then(function (string $v) {
return ['template_directory' => $v];
})
->end()
->children()
->scalarNode('template_directory')
->defaultValue('components')
->end()
->scalarNode('name_prefix')
->defaultValue('')
->end()
->end()
->end()
->end()
->scalarNode('anonymous_template_directory')
->info('Defaults to `components`')
->end()
->booleanNode('profiler')
->info('Enables the profiler for Twig Component (in debug mode)')
->defaultValue('%kernel.debug%')
->end()
->scalarNode('controllers_json')
->setDeprecated('symfony/ux-twig-component', '2.18', 'The "twig_component.controllers_json" config option is deprecated, and will be removed in 3.0.')
->defaultNull()
->end()
->end();
return $treeBuilder;
}
public function getConfiguration(array $config, ContainerBuilder $container): ConfigurationInterface
{
return $this;
}
}

View File

@@ -0,0 +1,83 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Event;
use Symfony\Contracts\EventDispatcher\Event;
use Symfony\UX\TwigComponent\ComponentMetadata;
/**
* @author Ryan Weaver <ryan@symfonycasts.com>
*/
final class PostMountEvent extends Event
{
private ?ComponentMetadata $metadata;
private array $extraMetadata;
public function __construct(
private object $component,
private array $data,
array|ComponentMetadata $metadata = [],
$extraMetadata = [],
) {
if (\is_array($metadata)) {
trigger_deprecation('symfony/ux-twig-component', '2.13', 'In TwigComponent 3.0, the third argument of "%s()" will be a "%s" object and the "$extraMetadata" array should be passed as the fourth argument.', __METHOD__, ComponentMetadata::class);
$this->metadata = null;
$this->extraMetadata = $metadata;
} else {
if (null !== $metadata && !$metadata instanceof ComponentMetadata) {
throw new \InvalidArgumentException(\sprintf('Expecting "$metadata" to be null or an instance of "%s", given: "%s."', ComponentMetadata::class, get_debug_type($metadata)));
}
if (!\is_array($extraMetadata)) {
throw new \InvalidArgumentException(\sprintf('Expecting "$extraMetadata" to be array, given: "%s".', get_debug_type($extraMetadata)));
}
$this->metadata = $metadata;
$this->extraMetadata = $extraMetadata;
}
}
public function getComponent(): object
{
return $this->component;
}
public function getData(): array
{
return $this->data;
}
public function setData(array $data): void
{
$this->data = $data;
}
public function getMetadata(): ?ComponentMetadata
{
return $this->metadata;
}
public function getExtraMetadata(): array
{
return $this->extraMetadata;
}
public function addExtraMetadata(string $key, mixed $value): void
{
$this->extraMetadata[$key] = $value;
}
public function removeExtraMetadata(string $key): void
{
unset($this->extraMetadata[$key]);
}
}

View File

@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Event;
use Symfony\Contracts\EventDispatcher\Event;
use Symfony\UX\TwigComponent\MountedComponent;
final class PostRenderEvent extends Event
{
/**
* @internal
*/
public function __construct(private MountedComponent $mounted)
{
}
public function getMountedComponent(): MountedComponent
{
return $this->mounted;
}
}

View File

@@ -0,0 +1,63 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Event;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Dispatched at the start of the component rendering process.
*
* This event occurs before the component is created & mounted.
*/
final class PreCreateForRenderEvent extends Event
{
private ?string $renderedString = null;
public function __construct(
private string $name,
private array $inputProps = [],
) {
}
public function getName(): string
{
return $this->name;
}
/**
* @deprecated since Symfony UX 2.8, use getInputProps() instead.
*/
public function getProps(): array
{
return $this->inputProps;
}
/**
* @return array the array of "input" data passed to originally create this component
*/
public function getInputProps(): array
{
return $this->inputProps;
}
public function setRenderedString(string $renderedString): void
{
$this->renderedString = $renderedString;
$this->stopPropagation();
}
public function getRenderedString(): ?string
{
return $this->renderedString;
}
}

View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Event;
use Symfony\Contracts\EventDispatcher\Event;
use Symfony\UX\TwigComponent\ComponentMetadata;
/**
* @author Ryan Weaver <ryan@symfonycasts.com>
*/
final class PreMountEvent extends Event
{
public function __construct(private object $component, private array $data, private readonly ?ComponentMetadata $metadata = null)
{
if (null === $this->metadata) {
trigger_deprecation('symfony/ux-twig-component', '2.13', 'In TwigComponent 3.0, "%s()" method will require a "%s $metadata" argument. Not passing it is deprecated.', __METHOD__, ComponentMetadata::class);
}
}
public function getComponent(): object
{
return $this->component;
}
public function getData(): array
{
return $this->data;
}
public function setData(array $data): void
{
$this->data = $data;
}
public function getMetadata(): ?ComponentMetadata
{
return $this->metadata;
}
}

View File

@@ -0,0 +1,123 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Event;
use Symfony\Contracts\EventDispatcher\Event;
use Symfony\UX\TwigComponent\ComponentMetadata;
use Symfony\UX\TwigComponent\MountedComponent;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
final class PreRenderEvent extends Event
{
/** @internal */
public const EMBEDDED = '__embedded';
/**
* Only relevant when rendering a specific embedded component.
* This is the "component template" that the embedded component
* should extend.
*/
private string $parentTemplateForEmbedded;
private string $template;
private ?int $templateIndex = null;
/**
* @internal
*/
public function __construct(
private MountedComponent $mounted,
private ComponentMetadata $metadata,
private array $variables,
) {
$this->template = $this->metadata->getTemplate();
$this->parentTemplateForEmbedded = $this->template;
}
public function isEmbedded(): bool
{
return $this->variables[self::EMBEDDED] ?? false;
}
/**
* @return string The twig template used for the component
*/
public function getTemplate(): string
{
return $this->template;
}
/**
* Change the twig template used.
*/
public function setTemplate(string $template, ?int $index = null): self
{
$this->template = $template;
$this->templateIndex = $index;
// only if we are *not* targeting an embedded component, change the parent template
if (null === $index) {
$this->parentTemplateForEmbedded = $template;
}
return $this;
}
/**
* @return string The twig template index used for the component, in case it's an embedded template
*/
public function getTemplateIndex(): ?int
{
return $this->templateIndex;
}
public function getParentTemplateForEmbedded(): string
{
return $this->parentTemplateForEmbedded;
}
public function getComponent(): object
{
return $this->mounted->getComponent();
}
/**
* @return array the variables that will be available in the component's template
*/
public function getVariables(): array
{
return $this->variables;
}
/**
* Change the twig variables used.
*/
public function setVariables(array $variables): self
{
$this->variables = $variables;
return $this;
}
public function getMetadata(): ComponentMetadata
{
return $this->metadata;
}
/**
* @internal
*/
public function getMountedComponent(): MountedComponent
{
return $this->mounted;
}
}

View File

@@ -0,0 +1,66 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\EventListener;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Service\ResetInterface;
use Symfony\UX\TwigComponent\Event\PostRenderEvent;
use Symfony\UX\TwigComponent\Event\PreRenderEvent;
/**
* @author Simon André <smn.andre@gmail.com>
*
* @internal
*/
final class TwigComponentLoggerListener implements EventSubscriberInterface, ResetInterface
{
private array $events = [];
public static function getSubscribedEvents(): array
{
return [
PreRenderEvent::class => ['onPreRender', 255],
PostRenderEvent::class => ['onPostRender', -255],
];
}
/**
* @return list<array{
* PreRenderEvent|PostRenderEvent,
* array{float, int},
* }>
*/
public function getEvents(): array
{
return $this->events;
}
public function onPreRender(PreRenderEvent $event): void
{
$this->logEvent($event);
}
public function onPostRender(PostRenderEvent $event): void
{
$this->logEvent($event);
}
public function reset(): void
{
$this->events = [];
}
private function logEvent(object $event): void
{
$this->events[] = [$event, [microtime(true), memory_get_usage(true)]];
}
}

View File

@@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Exception;
class RuntimeException extends \RuntimeException
{
}

View File

@@ -0,0 +1,77 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*
* @internal
*/
final class MountedComponent
{
/**
* @param array|null $inputProps if the component was just originally created,
* (not hydrated from a request), this is the
* array of initial props used to create the component
*/
public function __construct(
private string $name,
private object $component,
private ComponentAttributes $attributes,
private ?array $inputProps = [],
private array $extraMetadata = [],
) {
}
public function getName(): string
{
return $this->name;
}
public function getComponent(): object
{
return $this->component;
}
public function getAttributes(): ComponentAttributes
{
return $this->attributes;
}
public function getInputProps(): array
{
if (null === $this->inputProps) {
throw new \LogicException('The component was not created from input props.');
}
return $this->inputProps;
}
public function addExtraMetadata(string $key, mixed $metadata): void
{
$this->extraMetadata[$key] = $metadata;
}
public function hasExtraMetadata(string $key): bool
{
return \array_key_exists($key, $this->extraMetadata);
}
public function getExtraMetadata(string $key): mixed
{
if (!$this->hasExtraMetadata($key)) {
throw new \InvalidArgumentException(\sprintf('No extra metadata for key "%s" found.', $key));
}
return $this->extraMetadata[$key];
}
}

View File

@@ -0,0 +1,69 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Test;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
trait InteractsWithTwigComponents
{
protected function mountTwigComponent(string $name, array $data = []): object
{
if (!$this instanceof KernelTestCase) {
throw new \LogicException(\sprintf('The "%s" trait can only be used on "%s" classes.', __TRAIT__, KernelTestCase::class));
}
return static::getContainer()->get('ux.twig_component.component_factory')->create($name, $data)->getComponent();
}
/**
* @param array<string,string> $blocks
*/
protected function renderTwigComponent(string $name, array $data = [], ?string $content = null, array $blocks = []): RenderedComponent
{
if (!$this instanceof KernelTestCase) {
throw new \LogicException(\sprintf('The "%s" trait can only be used on "%s" classes.', __TRAIT__, KernelTestCase::class));
}
$blocks = array_filter(array_merge($blocks, ['content' => $content]));
if (!$blocks) {
return new RenderedComponent(
self::getContainer()->get('twig')
->createTemplate('{{ component(name, data) }}')
->render([
'name' => $name,
'data' => $data,
])
);
}
$template = \sprintf('{%% component "%s" with data %%}', addslashes($name));
foreach (array_keys($blocks) as $blockName) {
$template .= \sprintf('{%% block %1$s %%}{{ blocks.%1$s|raw }}{%% endblock %%}', $blockName);
}
$template .= '{% endcomponent %}';
return new RenderedComponent(
self::getContainer()->get('twig')
->createTemplate($template)
->render([
'data' => $data,
'blocks' => $blocks,
])
);
}
}

View File

@@ -0,0 +1,46 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Test;
use Symfony\Component\DomCrawler\Crawler;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
final class RenderedComponent implements \Stringable
{
/**
* @internal
*/
public function __construct(private string $html)
{
}
public function crawler(): Crawler
{
if (!class_exists(Crawler::class)) {
throw new \LogicException(\sprintf('"symfony/dom-crawler" is required to use "%s()" (install with "composer require symfony/dom-crawler").', __METHOD__));
}
return new Crawler($this->html);
}
public function toString(): string
{
return $this->html;
}
public function __toString(): string
{
return $this->html;
}
}

View File

@@ -0,0 +1,74 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Twig;
use Symfony\UX\TwigComponent\CVA;
use Twig\DeprecatedCallableInfo;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*
* @internal
*/
final class ComponentExtension extends AbstractExtension
{
public function getFunctions(): array
{
return [
new TwigFunction('component', [ComponentRuntime::class, 'render'], ['is_safe' => ['all']]),
new TwigFunction('cva', [$this, 'cva'], [
...(class_exists(DeprecatedCallableInfo::class)
? ['deprecation_info' => new DeprecatedCallableInfo('symfony/ux-twig-component', '2.20', 'html_cva', 'twig/html-extra')]
: ['deprecated' => '2.20', 'deprecating_package' => 'symfony/ux-twig-component', 'alternative' => 'html_cva']),
]),
];
}
public function getTokenParsers(): array
{
return [
new ComponentTokenParser(),
new PropsTokenParser(),
];
}
/**
* Create a CVA instance.
*
* base some base class you want to have in every matching recipes
* variants your recipes class
* compoundVariants compounds allow you to add extra class when multiple variation are matching in the same time
* defaultVariants allow you to add a default class when no recipe is matching
*
* @see https://symfony.com/bundles/ux-twig-component/current/index.html#component-with-complex-variants-cva
*
* @param array{
* base: string|string[]|null,
* variants: array<string, array<string, string|string[]>>,
* compoundVariants: list<array<string, string|string[]>>,
* defaultVariants: array<string, string>,
* } $cva
*/
public function cva(array $cva): CVA
{
trigger_deprecation('symfony/ux-twig-component', '2.20', 'Twig Function "cva" is deprecated; use "html_cva" from the "twig/html-extra" package (available since version 3.12) instead.');
return new CVA(
$cva['base'] ?? '',
$cva['variants'] ?? [],
$cva['compoundVariants'] ?? [],
$cva['defaultVariants'] ?? [],
);
}
}

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Twig;
use Twig\Lexer;
use Twig\Source;
use Twig\TokenStream;
/**
* @author Mathèo Daninos <matheo.daninos@gmail.com>
*
* @internal
*
* thanks to @giorgiopogliani for the inspiration on this lexer <3
*
* @see https://github.com/giorgiopogliani/twig-components
*/
class ComponentLexer extends Lexer
{
public function tokenize(Source $source): TokenStream
{
$preLexer = new TwigPreLexer();
$preparsed = $preLexer->preLexComponents($source->getCode());
return parent::tokenize(
new Source(
$preparsed,
$source->getName(),
$source->getPath()
)
);
}
}

View File

@@ -0,0 +1,210 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Twig;
use Symfony\UX\TwigComponent\BlockStack;
use Twig\Attribute\YieldReady;
use Twig\Compiler;
use Twig\Environment;
use Twig\Extension\CoreExtension;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Node;
use Twig\Node\NodeOutputInterface;
use Twig\Template;
/**
* @author Fabien Potencier <fabien@symfony.com>
* @author Kevin Bond <kevinbond@gmail.com>
*
* @internal
*/
#[YieldReady]
final class ComponentNode extends Node implements NodeOutputInterface
{
public function __construct(string $component, string $embeddedTemplateName, int $embeddedTemplateIndex, ?AbstractExpression $props, bool $only, int $lineno)
{
$nodes = [];
if (null !== $props) {
$nodes['props'] = $props;
}
parent::__construct($nodes, [], $lineno);
$this->setAttribute('only', $only);
$this->setAttribute('embedded_template', $embeddedTemplateName);
$this->setAttribute('embedded_index', $embeddedTemplateIndex);
$this->setAttribute('component', $component);
}
public function compile(Compiler $compiler): void
{
$compiler->addDebugInfo($this);
$useYield = method_exists(Environment::class, 'useYield') && $compiler->getEnvironment()->useYield();
// since twig/twig 3.9.0: Using the internal "twig_to_array" function is deprecated.
if (method_exists(CoreExtension::class, 'toArray')) {
$twig_to_array = 'Twig\Extension\CoreExtension::toArray';
} else {
$twig_to_array = 'twig_to_array';
}
$componentRuntime = $compiler->getVarName();
$compiler
->write(\sprintf('$%s = $this->env->getRuntime(', $componentRuntime))
->string(ComponentRuntime::class)
->raw(");\n");
/*
* Block 1) PreCreateForRender handling
*
* We call code to trigger the PreCreateForRender event. If the event returns
* a string, we return that string and skip the rest of the rendering process.
*/
$compiler
->write(\sprintf('$preRendered = $%s->preRender(', $componentRuntime))
->string($this->getAttribute('component'))
->raw(', ')
->raw($twig_to_array)
->raw('(');
$this->writeProps($compiler)
->raw(')')
->raw(");\n");
$compiler
->write('if (null !== $preRendered) {')
->raw("\n")
->indent();
if (method_exists(Environment::class, 'useYield')) {
$compiler->write('yield $preRendered; ');
} else {
$compiler->write('echo $preRendered; ');
}
$compiler->raw("\n")
->outdent()
->write('} else {')
->raw("\n")
->indent();
/*
* Block 2) Create the component & return render info
*
* We call code that creates the component and dispatches the
* PreRender event. The result $preRenderEvent variable holds
* the final template, template index & variables.
*/
$compiler
->write(\sprintf('$preRenderEvent = $%s->startEmbedComponent(', $componentRuntime))
->string($this->getAttribute('component'))
->raw(', ')
->raw($twig_to_array)
->raw('(');
$this->writeProps($compiler)
->raw('), ')
->raw($this->getAttribute('only') ? '[]' : '$context')
->raw(', ')
->string($this->getAttribute('embedded_template'))
->raw(', ')
->raw($this->getAttribute('embedded_index'))
->raw(");\n");
$compiler
->write('$embeddedContext = $preRenderEvent->getVariables();')
->raw("\n")
// Add __parent__ to the embedded context: this is used in its extends
// Note: PreRenderEvent::getTemplateIndex() is not used here. This is
// only used during "normal" {{ component() }} rendering, which allows
// you to target rendering a specific "embedded template" that originally
// came from a {% component %} tag. This is used by LiveComponents to
// allow an "embedded component" syntax live component to be re-rendered.
// In this case, we are obviously rendering an entire template, which
// happens to contain a {% component %} tag. So we don't need to worry
// about trying to allow a specific embedded template to be targeted.
->write('$embeddedContext["__parent__"] = $preRenderEvent->getTemplate();')
->raw("\n");
/*
* Block 3) Add & update the block stack
*
* We add the outerBlock to the context if it doesn't exist yet.
* Then add them to the block stack and get the converted embedded blocks.
*/
$compiler
->write(\sprintf('$embeddedContext["outerBlocks"] ??= new \%s();', BlockStack::class))
->raw("\n");
$compiler->write('$embeddedBlocks = $embeddedContext["outerBlocks"]->convert($blocks, ')
->raw($this->getAttribute('embedded_index'))
->raw(");\n");
/*
* Block 4) Render the component template
*
* This will actually render the child component template.
*/
if ($useYield) {
$compiler->write('yield from ');
}
// Support for Twig ^3.21
if (method_exists(Template::class, 'load')) {
$compiler
->write('$this->load(')
->string($this->getAttribute('embedded_template'))
->raw(', ')
->repr($this->getTemplateLine())
->raw(', ')
->string($this->getAttribute('embedded_index'))
->raw(')');
} else {
$compiler
->write('$this->loadTemplate(')
->string($this->getAttribute('embedded_template'))
->raw(', ')
->repr($this->getTemplateName())
->raw(', ')
->repr($this->getTemplateLine())
->raw(', ')
->string($this->getAttribute('embedded_index'))
->raw(')');
}
if ($useYield) {
$compiler->raw('->unwrap()->yield(');
} else {
$compiler->raw('->display(');
}
$compiler
->raw('$embeddedContext, $embeddedBlocks')
->raw(");\n");
$compiler->write(\sprintf('$%s->finishEmbedComponent();', $componentRuntime))
->raw("\n")
;
$compiler
->outdent()
->write('}')
->raw("\n")
;
}
private function writeProps(Compiler $compiler): Compiler
{
if ($this->hasNode('props')) {
return $compiler->subcompile($this->getNode('props'));
}
return $compiler->raw('[]');
}
}

View File

@@ -0,0 +1,62 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Twig;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\UX\TwigComponent\ComponentRenderer;
use Symfony\UX\TwigComponent\Event\PreRenderEvent;
/**
* @author Kevin Bond <kevinbond@gmail.com>
* @author Simon André <smn.andre@gmail.com>
*
* @internal
*/
final class ComponentRuntime
{
public function __construct(
private readonly ComponentRenderer $renderer,
private readonly ServiceLocator $renderers,
) {
}
public function finishEmbedComponent(): void
{
$this->renderer->finishEmbeddedComponentRender();
}
/**
* @param array<string, mixed> $props
*/
public function preRender(string $name, array $props): ?string
{
return $this->renderer->preCreateForRender($name, $props);
}
public function render(string $name, array $props = []): string
{
if ($this->renderers->has($normalized = strtolower($name))) {
return $this->renderers->get($normalized)->render($props);
}
return $this->renderer->createAndRender($name, $props);
}
/**
* @param array<string, mixed> $props
* @param array<string, mixed> $context
*/
public function startEmbedComponent(string $name, array $props, array $context, string $hostTemplateName, int $index): PreRenderEvent
{
return $this->renderer->startEmbeddedComponentRender($name, $props, $context, $hostTemplateName, $index);
}
}

View File

@@ -0,0 +1,147 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Twig;
use Symfony\UX\TwigComponent\BlockStack;
use Twig\Error\SyntaxError;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\NameExpression;
use Twig\Node\Node;
use Twig\Token;
use Twig\TokenParser\AbstractTokenParser;
/**
* @author Fabien Potencier <fabien@symfony.com>
* @author Kevin Bond <kevinbond@gmail.com>
*
* @internal
*/
final class ComponentTokenParser extends AbstractTokenParser
{
private array $lineAndFileCounts = [];
public function parse(Token $token): Node
{
$stream = $this->parser->getStream();
if (method_exists($this->parser, 'parseExpression')) {
// Since Twig 3.21
$componentName = $this->componentName($this->parser->parseExpression());
} else {
$componentName = $this->componentName($this->parser->getExpressionParser()->parseExpression());
}
if (null === $componentName) {
throw new SyntaxError('Could not parse component name.', $stream->getCurrent()->getLine(), $stream->getSourceContext());
}
[$propsExpression, $only] = $this->parseArguments();
// Write a fake: "extends __parent__" into the "embedded" template.
// The `__parent__` will be passed in as a context variable.
$fakeParentToken = new Token(Token::NAME_TYPE, '__parent__', $token->getLine());
$stream->injectTokens([
new Token(Token::BLOCK_START_TYPE, '', $token->getLine()),
new Token(Token::NAME_TYPE, 'extends', $token->getLine()),
$fakeParentToken,
new Token(Token::BLOCK_END_TYPE, '', $token->getLine()),
// Add an empty block which can act as a fallback for when an outer
// block is referenced that is not passed in from the embedded component.
// See BlockStack::__call()
new Token(Token::BLOCK_START_TYPE, '', $token->getLine()),
new Token(Token::NAME_TYPE, 'block', $token->getLine()),
new Token(Token::NAME_TYPE, BlockStack::OUTER_BLOCK_FALLBACK_NAME, $token->getLine()),
new Token(Token::BLOCK_END_TYPE, '', $token->getLine()),
new Token(Token::BLOCK_START_TYPE, '', $token->getLine()),
new Token(Token::NAME_TYPE, 'endblock', $token->getLine()),
new Token(Token::BLOCK_END_TYPE, '', $token->getLine()),
]);
// create the "fake" ModuleNode template then add it to the parser
$module = $this->parser->parse($stream, fn (Token $token) => $token->test("end{$this->getTag()}"), true);
$this->parser->embedTemplate($module);
// override the embedded index with a deterministic value, so it can be loaded in a controlled manner
$module->setAttribute('index', $this->generateEmbeddedTemplateIndex($stream->getSourceContext()->getName(), $token->getLine()));
$stream->expect(Token::BLOCK_END_TYPE);
return new ComponentNode($componentName, $module->getTemplateName(), $module->getAttribute('index'), $propsExpression, $only, $token->getLine());
}
public function getTag(): string
{
return 'component';
}
private function componentName(AbstractExpression $expression): ?string
{
if ($expression instanceof ConstantExpression) { // using {% component 'name' %}
return $expression->getAttribute('value');
}
if ($expression instanceof NameExpression) { // using {% component name %}
return $expression->getAttribute('name');
}
return null;
}
/**
* @return array{ArrayExpression|null, bool}
*/
private function parseArguments(): array
{
$stream = $this->parser->getStream();
$variables = null;
if ($stream->nextIf(Token::NAME_TYPE, 'with')) {
if (method_exists($this->parser, 'parseExpression')) {
// Since Twig 3.21
$variables = $this->parser->parseExpression();
} else {
$variables = $this->parser->getExpressionParser()->parseExpression();
}
}
$only = false;
if ($stream->nextIf(Token::NAME_TYPE, 'only')) {
$only = true;
}
$stream->expect(Token::BLOCK_END_TYPE);
return [$variables, $only];
}
private function generateEmbeddedTemplateIndex(string $file, int $line): int
{
$fileAndLine = \sprintf('%s-%d', $file, $line);
if (!isset($this->lineAndFileCounts[$fileAndLine])) {
$this->lineAndFileCounts[$fileAndLine] = 0;
}
$index = crc32($fileAndLine).++$this->lineAndFileCounts[$fileAndLine];
if (4 === \PHP_INT_SIZE) {
// On 32-bit PHP, the index can be negative or greater than PHP_INT_MAX
// we need to convert it to a positive 32-bit integer
$index = fmod(abs($index), \PHP_INT_MAX) + 1;
}
return (int) $index;
}
}

View File

@@ -0,0 +1,118 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Twig;
use Twig\Attribute\YieldReady;
use Twig\Compiler;
use Twig\Node\Node;
/**
* @author Matheo Daninos <matheo.daninos@gmail.com>
*
* @internal
*/
#[YieldReady]
class PropsNode extends Node
{
public function __construct(array $propsNames, array $values, $lineno = 0)
{
parent::__construct($values, ['names' => $propsNames], $lineno);
}
public function compile(Compiler $compiler): void
{
$compiler
->addDebugInfo($this)
->write('$propsNames = [];')
;
if (!$this->getAttribute('names')) {
return;
}
foreach ($this->getAttribute('names') as $name) {
$compiler
->write('if (isset($context[\'__props\'][\''.$name.'\'])) {')
->raw("\n")
->write('$componentClass = isset($context[\'this\']) ? get_debug_type($context[\'this\']) : "";')
->raw("\n")
->write('throw new \Twig\Error\RuntimeError(\'Cannot define prop "'.$name.'" in template "'.$this->getTemplateName().'". Property already defined in component class "\'.$componentClass.\'".\');')
->raw("\n")
->write('}')
->raw("\n")
;
$compiler
->write('$propsNames[] = \''.$name.'\';')
->write("\n")
->write('$context[\'attributes\'] = $context[\'attributes\']->remove(\''.$name.'\');')
->write("\n")
->write('if (!isset($context[\''.$name.'\'])) {');
if (!$this->hasNode($name)) {
$compiler
->indent()
->write('throw new \Twig\Error\RuntimeError("'.$name.' should be defined for component '.$this->getTemplateName().'.");')
->write("\n")
->outdent()
->write('}')
->write("\n");
continue;
}
$compiler
->indent()
->write('$context[\''.$name.'\'] = ')
->subcompile($this->getNode($name))
->raw(";\n")
->outdent()
->write('}')
->write("\n")
;
// overwrite the context value if a props with a similar name and a default value exist
if ($this->hasNode($name)) {
$compiler
->write('if (isset($context[\'__context\'][\''.$name.'\'])) {')
->raw("\n")
->indent()
->write('$context[\''.$name.'\'] = ')
->subcompile($this->getNode($name))
->raw(";\n")
->outdent()
->write('}')
->raw("\n")
;
}
}
$compiler
->write('$attributesKeys = array_keys($context[\'attributes\']->all());')
->raw("\n")
->write('foreach ($context as $key => $value) {')
->raw("\n")
->indent()
->write('if (in_array($key, $attributesKeys) && !in_array($key, $propsNames)) {')
->raw("\n")
->indent()
->raw('unset($context[$key]);')
->raw("\n")
->outdent()
->write('}')
->raw("\n")
->outdent()
->write('}')
->raw("\n")
;
}
}

View File

@@ -0,0 +1,59 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Twig;
use Twig\Node\Node;
use Twig\Token;
use Twig\TokenParser\AbstractTokenParser;
/**
* @author Matheo Daninos <matheo.daninos@gmail.com>
*
* @internal
*/
class PropsTokenParser extends AbstractTokenParser
{
public function parse(Token $token): Node
{
$parser = $this->parser;
$stream = $parser->getStream();
$names = [];
$values = [];
while (!$stream->nextIf(Token::BLOCK_END_TYPE)) {
$name = $stream->expect(Token::NAME_TYPE)->getValue();
if ($stream->nextIf(Token::OPERATOR_TYPE, '=')) {
if (method_exists($parser, 'parseExpression')) {
// Since Twig 3.21
$values[$name] = $parser->parseExpression();
} else {
$values[$name] = $parser->getExpressionParser()->parseExpression();
}
}
$names[] = $name;
if (!$stream->nextIf(Token::PUNCTUATION_TYPE)) {
$stream->expect(Token::BLOCK_END_TYPE);
break;
}
}
return new PropsNode($names, $values, $token->getLine());
}
public function getTag(): string
{
return 'props';
}
}

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Twig;
use Symfony\Bundle\TwigBundle\DependencyInjection\Configurator\EnvironmentConfigurator;
use Symfony\UX\TwigComponent\ComponentAttributes;
use Twig\Environment;
use Twig\Extension\EscaperExtension;
use Twig\Runtime\EscaperRuntime;
/**
* @final
*/
class TwigEnvironmentConfigurator
{
public function __construct(
private readonly EnvironmentConfigurator $decorated,
) {
}
public function configure(Environment $environment): void
{
$this->decorated->configure($environment);
$environment->setLexer(new ComponentLexer($environment));
if (class_exists(EscaperRuntime::class)) {
$environment->getRuntime(EscaperRuntime::class)->addSafeClass(ComponentAttributes::class, ['html']);
} elseif ($environment->hasExtension(EscaperExtension::class)) {
$environment->getExtension(EscaperExtension::class)->addSafeClass(ComponentAttributes::class, ['html']);
}
}
}

View File

@@ -0,0 +1,496 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent\Twig;
use Twig\Error\SyntaxError;
use Twig\Lexer;
/**
* Rewrites <twig:component> syntaxes to {% component %} syntaxes.
*/
class TwigPreLexer
{
private string $input;
private int $length;
private int $position = 0;
private int $line;
/**
* @var array<array{name: string, hasDefaultBlock: bool}>
*/
private array $currentComponents = [];
public function __construct(int $startingLine = 1)
{
$this->line = $startingLine;
}
public function preLexComponents(string $input): string
{
if (!str_contains($input, '<twig:')) {
return $input;
}
$this->input = $input = str_replace(["\r\n", "\r"], "\n", $input);
$this->length = \strlen($input);
$output = '';
$inTwigEmbed = false;
while ($this->position < $this->length) {
// ignore content inside verbatim block #947
if ($this->consume('{% verbatim %}')) {
$output .= '{% verbatim %}';
$output .= $this->consumeUntil('{% endverbatim %}');
$this->consume('{% endverbatim %}');
$output .= '{% endverbatim %}';
if ($this->position === $this->length) {
break;
}
}
// ignore content inside twig comments, see #838
if ($this->consume('{#')) {
$output .= '{#';
$output .= $this->consumeUntil('#}');
$this->consume('#}');
$output .= '#}';
if ($this->position === $this->length) {
break;
}
}
if ($this->consume('{% embed')) {
$inTwigEmbed = true;
$output .= '{% embed';
$output .= $this->consumeUntil('%}');
continue;
}
if ($this->consume('{% endembed %}')) {
$inTwigEmbed = false;
$output .= '{% endembed %}';
continue;
}
$isTwigHtmlOpening = $this->consume('<twig:');
$isTraditionalBlockOpening = false;
if ($isTwigHtmlOpening || (0 !== \count($this->currentComponents) && $isTraditionalBlockOpening = $this->consume('{% block'))) {
$componentName = $isTraditionalBlockOpening ? 'block' : $this->consumeComponentName();
if ('block' === $componentName) {
// if we're already inside the "default" block, let's close it
if (!empty($this->currentComponents) && $this->currentComponents[\count($this->currentComponents) - 1]['hasDefaultBlock'] && !$inTwigEmbed) {
$output .= '{% endblock %}';
$this->currentComponents[\count($this->currentComponents) - 1]['hasDefaultBlock'] = false;
}
if ($isTraditionalBlockOpening) {
// add what we've consumed so far
$output .= '{% block';
$output .= $stringUntilClosingTag = $this->consumeUntil('%}');
// If the last-consumed string does not match the Twig's block name regex, we assume the block is self-closing
$isBlockSelfClosing = '' !== preg_replace(Lexer::REGEX_NAME, '', trim($stringUntilClosingTag));
if ($isBlockSelfClosing && $this->consume('%}')) {
$output .= '%}';
} else {
$output .= $this->consumeUntilEndBlock();
}
continue;
}
$output .= $this->consumeBlock($componentName);
continue;
}
// if we're already inside a component,
// *and* we've just found a new component, then we should try to
// open the default block
if (!empty($this->currentComponents)
&& !$this->currentComponents[\count($this->currentComponents) - 1]['hasDefaultBlock']) {
$output .= '{% block content %}';
$this->currentComponents[\count($this->currentComponents) - 1]['hasDefaultBlock'] = true;
}
$attributes = $this->consumeAttributes($componentName);
$isSelfClosing = $this->consume('/>');
if (!$isSelfClosing) {
$this->consume('>');
$this->currentComponents[] = ['name' => $componentName, 'hasDefaultBlock' => false];
}
if ($isSelfClosing) {
// use the simpler component() format, so that the system doesn't think
// this is an "embedded" component with blocks
// see https://github.com/symfony/ux/issues/810
$output .= "{{ component('{$componentName}'".($attributes ? ", { {$attributes} }" : '').') }}';
} else {
$output .= "{% component '{$componentName}'".($attributes ? " with { {$attributes} }" : '').' %}';
}
continue;
}
if (!empty($this->currentComponents) && $this->check('</twig:')) {
$this->consume('</twig:');
$closingComponentName = $this->consumeComponentName();
$this->consume('>');
$lastComponent = array_pop($this->currentComponents);
$lastComponentName = $lastComponent['name'];
if ($closingComponentName !== $lastComponentName) {
throw new SyntaxError("Expected closing tag '</twig:{$lastComponentName}>' but found '</twig:{$closingComponentName}>'.", $this->line);
}
// we've reached the end of this component. If we're inside the
// default block, let's close it
if ($lastComponent['hasDefaultBlock']) {
$output .= '{% endblock %}';
}
$output .= '{% endcomponent %}';
continue;
}
$char = $this->input[$this->position];
if ("\n" === $char) {
++$this->line;
}
// handle adding a default block if we find non-whitespace outside of a block
if (!empty($this->currentComponents)
&& !$this->currentComponents[\count($this->currentComponents) - 1]['hasDefaultBlock']
&& preg_match('/\S/', $char)
&& !$this->check('{% block')
) {
$this->currentComponents[\count($this->currentComponents) - 1]['hasDefaultBlock'] = true;
$output .= '{% block content %}';
}
$output .= $char;
$this->consumeChar();
}
if (!empty($this->currentComponents)) {
$lastComponent = array_pop($this->currentComponents)['name'];
throw new SyntaxError(\sprintf('Expected closing tag "</twig:%s>" not found.', $lastComponent), $this->line);
}
return $output;
}
private function consumeComponentName(?string $customExceptionMessage = null): string
{
if (preg_match('/\G[A-Za-z0-9_:@\-.]+/', $this->input, $matches, 0, $this->position)) {
$componentName = $matches[0];
$this->position += \strlen($componentName);
return $componentName;
}
throw new SyntaxError($customExceptionMessage ?? 'Expected component name when resolving the "<twig:" syntax.', $this->line);
}
private function consumeAttributes(string $componentName): string
{
$attributes = [];
while ($this->position < $this->length && !$this->check('>') && !$this->check('/>')) {
$this->consumeWhitespace();
if ($this->check('>') || $this->check('/>')) {
break;
}
if ($this->check('{{...') || $this->check('{{ ...')) {
$this->consume('{{...');
$this->consume('{{ ...');
$attributes[] = '...'.trim($this->consumeUntil('}}'));
$this->consume('}}');
continue;
}
$isAttributeDynamic = false;
// :someProp="dynamicVar"
$this->consumeWhitespace();
if ($this->check(':')) {
$this->consume(':');
$isAttributeDynamic = true;
}
$message = \sprintf('Expected attribute name when parsing the "<twig:%s" syntax.', $componentName);
// was called 'consumeAttributeName'
$key = $this->consumeComponentName($message);
// <twig:component someProp> -> someProp: true
if (!$this->check('=')) {
$this->consumeWhitespace();
// don't allow "<twig:component :someProp>"
if ($isAttributeDynamic) {
throw new SyntaxError(\sprintf('Expected "=" after ":%s" when parsing the "<twig:%s" syntax.', $key, $componentName), $this->line);
}
$attributes[] = \sprintf('%s: true', preg_match('/[-:@]/', $key) ? "'$key'" : $key);
$this->consumeWhitespace();
continue;
}
$this->expectAndConsumeChar('=');
$quote = $this->consumeChar(["'", '"']);
if ($isAttributeDynamic) {
// :someProp="dynamicVar"
$attributeValue = $this->consumeUntil($quote);
} else {
$attributeValue = $this->consumeAttributeValue($quote);
}
$attributes[] = \sprintf('%s: %s', preg_match('/[-:@]/', $key) ? "'$key'" : $key, '' === $attributeValue ? "''" : $attributeValue);
$this->expectAndConsumeChar($quote);
$this->consumeWhitespace();
}
return implode(', ', $attributes);
}
/**
* If the next character(s) exactly matches the given string, then
* consume it (move forward) and return true.
*/
private function consume(string $string): bool
{
if (str_starts_with(substr($this->input, $this->position), $string)) {
$this->position += \strlen($string);
return true;
}
return false;
}
private function consumeChar($validChars = null): string
{
if ($this->position >= $this->length) {
throw new SyntaxError('Unexpected end of input.', $this->line);
}
$char = $this->input[$this->position];
if (null !== $validChars && !\in_array($char, (array) $validChars, true)) {
throw new SyntaxError('Expected one of [.'.implode('', (array) $validChars)."] but found '{$char}'.", $this->line);
}
++$this->position;
return $char;
}
/**
* Moves the position forward until it finds $endString.
*
* Any string consumed *before* finding that string is returned.
* The position is moved forward to just *before* $endString.
*/
private function consumeUntil(string $endString): string
{
if (false === $endPosition = strpos($this->input, $endString, $this->position)) {
$start = $this->position;
$this->position = $this->length;
return substr($this->input, $start);
}
$content = substr($this->input, $this->position, $endPosition - $this->position);
$this->line += substr_count($content, "\n");
$this->position = $endPosition;
return $content;
}
private function consumeWhitespace(): void
{
$whitespace = substr($this->input, $this->position, strspn($this->input, " \t\n\r\0\x0B", $this->position));
$this->line += substr_count($whitespace, "\n");
$this->position += \strlen($whitespace);
if ($this->check('#')) {
$this->consume('#');
$this->consumeUntil("\n");
$this->consumeWhitespace();
}
}
/**
* Checks that the next character is the one given and consumes it.
*/
private function expectAndConsumeChar(string $char): void
{
if (1 !== \strlen($char)) {
throw new \InvalidArgumentException('Expected a single character.');
}
if ($this->position >= $this->length) {
throw new SyntaxError("Expected '{$char}' but reached the end of the file.", $this->line);
}
if ($this->input[$this->position] !== $char) {
throw new SyntaxError("Expected '{$char}' but found '{$this->input[$this->position]}'.", $this->line);
}
++$this->position;
}
private function check(string $chars): bool
{
return $this->position + \strlen($chars) <= $this->length
&& 0 === substr_compare($this->input, $chars, $this->position, \strlen($chars));
}
private function consumeBlock(string $componentName): string
{
$attributes = $this->consumeAttributes($componentName);
$this->consume('>');
$blockName = '';
foreach (explode(', ', $attributes) as $attr) {
[$key, $value] = explode(': ', $attr);
if ('name' === $key) {
$blockName = trim($value, "'");
break;
}
}
if (empty($blockName)) {
throw new SyntaxError('Expected block name.', $this->line);
}
$output = "{% block {$blockName} %}";
$closingTag = '</twig:block>';
if (false === strpos($this->input, $closingTag, $this->position)) {
throw new SyntaxError("Expected closing tag '{$closingTag}' for block '{$blockName}'.", $this->line);
}
$blockContents = $this->consumeUntilEndBlock();
$subLexer = new self($this->line);
$output .= $subLexer->preLexComponents($blockContents);
$this->consume($closingTag);
$output .= '{% endblock %}';
return $output;
}
private function consumeUntilEndBlock(): string
{
$start = $this->position;
$depth = 1;
$inComment = false;
while ($this->position < $this->length) {
if ($inComment && '#}' === substr($this->input, $this->position, 2)) {
$inComment = false;
}
if (!$inComment && '{#' === substr($this->input, $this->position, 2)) {
$inComment = true;
}
if (!$inComment && '</twig:block>' === substr($this->input, $this->position, 13)) {
if (1 === $depth) {
break;
} else {
--$depth;
}
}
if (!$inComment && '{% endblock %}' === substr($this->input, $this->position, 14)) {
if (1 === $depth) {
// in this case, we want to advance ALL the way beyond the endblock
// strlen('{% endblock %}') = 14
$this->position += 14;
break;
} else {
--$depth;
}
}
if (!$inComment && '<twig:block' === substr($this->input, $this->position, 11)) {
++$depth;
}
if (!$inComment && '{% block' === substr($this->input, $this->position, 8)) {
++$depth;
}
if ("\n" === $this->input[$this->position]) {
++$this->line;
}
++$this->position;
}
return substr($this->input, $start, $this->position - $start);
}
private function consumeAttributeValue(string $quote): string
{
$parts = [];
$currentPart = '';
while ($this->position < $this->length) {
if ($this->check($quote)) {
break;
}
if ("\n" === $this->input[$this->position]) {
++$this->line;
}
if ($this->check('{{')) {
// mark any previous static text as complete: push into parts
if ('' !== $currentPart) {
$parts[] = \sprintf("'%s'", str_replace("'", "\'", $currentPart));
$currentPart = '';
}
// consume the entire {{ }} block
$this->consume('{{');
$this->consumeWhitespace();
$parts[] = \sprintf('(%s)', rtrim($this->consumeUntil('}}')));
$this->expectAndConsumeChar('}');
$this->expectAndConsumeChar('}');
continue;
}
$currentPart .= $this->input[$this->position];
++$this->position;
}
if ('' !== $currentPart) {
$parts[] = \sprintf("'%s'", str_replace("'", "\'", $currentPart));
}
return implode('~', $parts);
}
}

View File

@@ -0,0 +1,32 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\TwigComponent;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
use Symfony\UX\TwigComponent\DependencyInjection\Compiler\TwigComponentPass;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
final class TwigComponentBundle extends Bundle
{
public function build(ContainerBuilder $container): void
{
$container->addCompilerPass(new TwigComponentPass());
}
public function getPath(): string
{
return \dirname(__DIR__);
}
}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" data-icon-name="icon-tabler-chevron-down" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 9l6 6l6 -6"></path>
</svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="1.5" viewBox="0 0 24 24">
<path d="M12 8.29c0-1.37-.55-2.68-1.54-3.64a5.3 5.3 0 0 0-3.71-1.5H4.12v1.7a5.1 5.1 0 0 0 1.54 3.64A5.3 5.3 0 0 0 9.37 10H12m0 1.47c0-1.3.55-2.55 1.54-3.46a5.45 5.45 0 0 1 3.71-1.44h2.63v.82c0 1.3-.56 2.54-1.54 3.46a5.45 5.45 0 0 1-3.71 1.44H12m0 4.57V7.7M7.11 15 4 18l3.11 3M17 15l3.11 3L17 21"/>
</svg>

After

Width:  |  Height:  |  Size: 475 B

View File

@@ -0,0 +1,304 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block page_title 'Twig Components' %}
{% block head %}
{{ parent() }}
<style>
.twig-component-dump {
display: block;
background: rgba(0, 0, 0, .15);
--font-size-monospace: 12px;
font-weight: 400;
border-radius: 4px;
padding: 5px;
}
.twig-component-metrics {
margin-block-end: 3rem;
}
.twig-component-component {
margin-block-end: 3rem;
}
.twig-component-component th:first-child,
.twig-component-component td:first-child {
width: 25%;
}
.twig-component-component thead th {
font-weight: 200;
vertical-align: middle;
padding: .75rem 1rem;
}
.twig-component-component thead strong {
font-weight: 600;
display: block;
}
.twig-component-component td {
vertical-align: middle;
padding: .75rem 1rem;
}
.twig-component-component tbody td.metric {
text-align: right;
}
.twig-component-component thead small,
.twig-component-component thead strong {
display: block;
}
.twig-component-component .cell-right {
width: 4rem;
text-align: right;
}
.twig-component-renders {
margin-bottom: 2rem;
}
.twig-component-render {
margin-left: calc(var(--render-depth) * .5rem);
width: calc(100% - calc(var(--render-depth) * .5rem));
}
.twig-component-render thead th {
text-align: left;
border-bottom: none;
vertical-align: middle;
}
.twig-component-render thead tr {
vertical-align: middle;
opacity: .9;
}
.twig-component-render thead tr:hover {
opacity: 1;
cursor: pointer;
}
.twig-component-render .sf-toggle .toggle-button {
color: inherit;
}
.twig-component-render .sf-toggle-on .toggle-button {
transform: rotate(0deg);
opacity: 1;
transition: all 150ms ease-in-out;
}
.twig-component-render .sf-toggle-off .toggle-button {
transform: rotate(90deg);
opacity: .85;
transition: all 250ms ease-in-out;
}
.twig-component-render th:first-child,
.twig-component-render tr:first-child {
width: 25%;
}
.twig-component-render th,
.twig-component-render tbody th {
font-weight: normal;
}
.twig-component-render th:first-child {
font-weight: bolder;
}
.twig-component-render th:first-child svg {
transform: rotate(45deg);
transform-origin: inherit;
transform-style: initial;
width: 1.25rem;
vertical-align: inherit;
}
.twig-component-render th:last-child {
width: 2rem;
}
.twig-component-render th.renderTime {
width: 4rem;
font-weight: initial;
}
.twig-component-render tbody.sf-toggle-visible {
display: table-row-group;
width: inherit;
}
.twig-component-render tbody th {
font-weight: normal !important;
}
</style>
{% endblock %}
{% block toolbar %}
{% if collector.renderCount %}
{% set icon %}
{{ source('@TwigComponent/Collector/icon.svg') }}
<span class="sf-toolbar-value">{{ collector.renderCount }}</span>
<span class="sf-toolbar-info-piece-additional-detail">
<span class="sf-toolbar-label">in</span>
<span class="sf-toolbar-value">{{ collector.renderTime|round }}</span>
<span class="sf-toolbar-label">ms</span>
</span>
{% endset %}
{% set text %}
{% for _component in collector.components %}
<div class="sf-toolbar-info-piece">
<b class="label">{{ _component.name }}</b>
<span class="sf-toolbar-status">{{ _component.render_count }}</span>
</div>
{% endfor %}
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', {link: profiler_url}) }}
{% endif %}
{% endblock %}
{% block menu %}
<span class="label{{ collector.components is empty ? ' disabled' }}">
<span class="icon">{{ source('@TwigComponent/Collector/icon.svg') }}</span>
<strong>Twig Components</strong>
</span>
{% endblock %}
{% block panel %}
<h2>Components</h2>
{% if not collector.componentCount|default %}
<div class="empty empty-panel">
<p>No component were rendered for this request.</p>
</div>
{% else %}
<section class="twig-component-metrics metrics">
<div class="metric-group">
{{ _self.metric(collector.componentCount, "Twig Components") }}
</div>
<div class="metric-divider"></div>
<div class="metric-group">
{{ _self.metric(collector.renderCount, "Render Count") }}
{{ _self.metric(collector.renderTime|round, "Render Time", "ms") }}
</div>
<div class="metric-divider"></div>
<div class="metric-group">
{{ _self.metric((collector.peakMemoryUsage / 1024 / 1024)|number_format(1), "Memory Usage", "MiB") }}
</div>
</section>
<section class="twig-component-components">
<h3>Components</h3>
{{ block('table_components') }}
</section>
<section class="twig-component-renders">
<h3>Render calls</h3>
{{ block('table_renders') }}
</section>
{% endif %}
{% endblock %}
{% macro metric(value, label, unit = '') %}
<div class="metric">
<span class="value">
{{ value }}
{% if unit %}
<span class="unit text-small">{{ unit }}</span>
{% endif %}
</span>
<span class="label">
{{- label -}}
</span>
</div>
{% endmacro %}
{% block table_components %}
<table class="twig-component-component">
<thead>
<tr>
<th class="key">
<strong>Name</strong>
</th>
<th>
<strong>Metadata</strong>
</th>
<th class="cell-right">
<small>Render</small>
<strong>Count</strong>
</th>
<th class="cell-right">
<small>Render</small>
<strong>Time</strong>
</th>
</tr>
</thead>
<tbody>
{% for component in collector.components %}
<tr>
<td>{{ component.name }}</td>
<td>
{% if component.class == 'Symfony\\UX\\TwigComponent\\AnonymousComponent' %}
<pre class="sf-dump"><span class="text-muted">[Anonymous]</span></pre>
{% else %}
{{ profiler_dump(component.class_stub) }}
{% endif %}
{% if component.template_path %}
<a class=text-muted" href="{{ component.template_path|file_link(1) }}">
{{- component.template -}}
</a>
{% else %}
<span class=text-muted">{{ component.template }}</span>
{% endif %}
</td>
<td class="cell-right">{{ component.render_count }}</td>
<td class="cell-right">
{{- component.render_time|number_format(2) -}}
<span class="text-muted text-small">ms</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% block table_renders %}
<div class="twig-component-renders">
{% set _memory = null %}
{% for render in collector.renders %}
<table class="twig-component-render" style="--render-depth:{{ render.depth }};">
<thead
class="sf-toggle {{ loop.index == 1 ? 'sf-toggle-on' : 'sf-toggle-off' }}"
data-toggle-selector="#render-{{ loop.index }}--details"
data-toggle-initial="{{ loop.index == 1 ? 'display' }}"
>
<tr>
<th class="key">{{ render.depth ? source('@TwigComponent/Collector/chevron-down.svg') }}{{ render.name }}</th>
<th>
{% if render.class == 'Symfony\\UX\\TwigComponent\\AnonymousComponent' %}
<pre class="sf-dump"><span class="text-muted">[Anonymous]</span></pre>
{% else %}
{{ render.class }}
{% endif %}
</th>
<th class="cell-right renderTime">
{% set _render_memory = render.render_memory|default(0) / 1024 / 1024 %}
<span class="{{ _render_memory == _memory ? 'text-muted' }}">
{{- _render_memory|number_format(1) -}}
</span>
<span class="text-muted text-small">MiB</span>
{% set _memory = _render_memory %}
</th>
<th class="cell-right renderTime">
{{ render.render_time|number_format(2) }}
<span class="text-muted text-small">ms</span>
</th>
<th class="cell-right">
<button class="btn btn-link toggle-button" type="button" aria-label="Toggle details">
{{ source('@TwigComponent/Collector/chevron-down.svg') }}
</button>
</th>
</tr>
</thead>
<tbody id="render-{{ loop.index }}--details">
<tr class="{{ not render.input_props|default ? 'opacity-50' }}">
<th scope="row">Input props</th>
<td colspan="4">{{ profiler_dump(render.input_props) }}</td>
</tr>
<tr class="{{ not render.attributes|default ? 'opacity-50' }}">
<th scope="row">Attributes</th>
<td colspan="4">{{ profiler_dump(render.attributes) }}</td>
</tr>
<tr>
<th scope="row">Component</th>
<td colspan="4">{{ profiler_dump(render.component) }}</td>
</tr>
</tbody>
</table>
{% endfor %}
</div>
{% endblock %}